Date: 2026-02-11
Author: jclyne
Status: Draft
Related Plan: thoughts/shared/plans/2026-02-10-actioncard-completescenario-plasma-flow.md
Related Research: thoughts/shared/research/2026-02-10-clientrenderable-actioncard-plasma-flow-architecture.md
Related Review: thoughts/shared/reviews/2026-02-11-actioncard-completescenario-design-review.md
This specification describes the client-side behavior for enabling plasma-adapter MCP tools to return ActionCard UI components that trigger completeScenario Plasma flows. It covers both cash-ios (with actual code references) and cash-android.
The server creates the Plasma flow server-side to discover blockers. Agent-friendly blockers (requiresForm == false) are resolved conversationally by the LLM. Non-agent-friendly blockers (requiresForm == true) produce an ActionCard for client handoff. When the client taps the ActionCard and calls completeScenario with the same flow token, Plasma's PlasmaFlowCreator returns the existing flow idempotently. There is minor redundant processing on this second call (see design review Section 2.1) which is accepted as a trade-off to enable conversational blocker resolution.
-
ActionCard with CompleteScenario Route: MCP tools (EnableCard, DisableCard, CreateSavingsGoal) return
ClientRenderableActionCards with a server-providedflow_token. When tapped, the client calls Plasma'scompleteScenarioAPI with that token, resuming the existing flow and rendering remaining blockers natively. -
Completion Card Replacement: When a flow completes, the Plasma
KgoosePublisheremits aCompletePlasmaFlowevent. The client receives aclientRenderingUpdateactivity and replaces the original ActionCard with a completion card. -
Agent-Friendly Blocker Routing (Future): When the client encounters a blocker with
agent_friendly = trueduring a kgoose-initiated Plasma flow, the client navigates back to chat and sends a hidden message with blocker details for LLM handling.
The .completeScenario client route is already registered in FlowsPlugin.swift and already accepts a flowToken:
File: Code/CoreLibraries/Flows/FlowsPlugin/Sources/FlowsPlugin.swift
registry.register(.completeScenario) { [weak self] payload, routingParams in
guard let self else { return }
let clientScenarioPayload = ClientRoute.ClientScenario(clientScenarioName: payload.clientScenario)
var updatedRoutingParams = routingParams
updatedRoutingParams.analyticsParams.payload = .startPlasmaFlow(.init(flowToken: payload.flowToken))
navigator.navigate(
to: .clientScenario(clientScenarioPayload),
metadata: updatedRoutingParams,
)
}The route reroutes to .clientScenario with the flowToken embedded in StartPlasmaFlowAnalyticsParams.
Test confirmation (FlowsPluginTests.swift):
func test_completeScenario() throws {
let payload = ClientRoute.CompleteScenario(
clientScenario: "TEST_SCENARIO",
flowToken: "test-flow-token-123",
)
// ... navigates to .clientScenario with flowToken in startPlasmaFlowAnalyticsParams
}File: Code/OSHooks/ClientRouting/ClientRoutingImplementations/Sources/SignedInFlowsPlugin.swift
The .clientScenario handler extracts the flow token and passes goose context:
registry.register(.clientScenario) { [weak self] (payload, routingParams) in
guard let self else { return }
guard let scenario = SQPBFranklinApiClientScenario(string: payload.clientScenarioName.uppercased()) else {
fatalError("No client scenario exists with the name: \(payload.clientScenarioName)")
}
// Get the flow token from the routing params, use a new token if we don't have one
let flowTokenString = routingParams.analyticsParams.startPlasmaFlowAnalyticsParams?.flowToken
let flowToken = flowTokenString.flatMap { try? FlowToken($0) } ?? .init()
// If we have a scenario plan map, use it. Otherwise call through to complete-scenario
let scenarioPlan = scenarioPlanMapProvider()?.scenarioPlan(for: scenario)
if let scenarioPlan, scenarioPlan.hasBlockers {
let flow = legacyFlowFactory.makeFlow(
clientScenario: scenario,
flowToken: flowToken,
// ...
scenarioPlan: scenarioPlan,
)
presentFlow(flow, source: routingParams.source)
} else {
presentClientScenario(scenario, source: routingParams.source, flowToken: flowToken)
}
}The presentClientScenario method extracts goose IDs and builds the request:
private func presentClientScenario(
_ clientScenario: SQPBFranklinApiClientScenario,
source: RoutingParams.Source,
flowToken: FlowToken = .init(),
) {
let (gooseSessionID, gooseToolRequestID) = extractMoneybotIDs(from: source)
let request = CompleteScenarioRequest(
clientScenario: clientScenario,
gooseSessionID: gooseSessionID,
gooseToolRequestID: gooseToolRequestID,
)
let loadingViewFlowViewController = chainedFlowServerFactory.makeChainedFlow(
// ...
serverRequest: request,
gooseSessionID: gooseSessionID,
sourceScreen: source.sourceString,
flowToken: flowToken,
config: .defaultConfig,
)
presentationManager.presentModally(loadingViewFlowViewController)
}File: Code/CoreLibraries/Flows/FlowFactories/Sources/CompleteScenarioRequest.swift
public struct CompleteScenarioRequest: FlowServerRequest, Hashable {
public static let endpoint: RequestEndpoint = "2.0/cash/complete-scenario"
public init(
clientScenario: SQPBFranklinApiClientScenario,
gooseSessionID: String? = nil,
gooseToolRequestID: String? = nil,
paymentTokens: [String] = [],
) {
headers[RequestBuilder.HeaderKeys.clientScenario] = clientScenario.stringValue
if let gooseSessionID {
headers[GooseHeaders.sessionID] = gooseSessionID
}
if let gooseToolRequestID {
headers[GooseHeaders.toolRequestID] = gooseToolRequestID
}
}
}Note: The CompleteScenarioRequest does NOT set the Cash-Flow-Token header itself. The flowToken is passed separately to chainedFlowServerFactory.makeChainedFlow(flowToken:), which is responsible for setting it on the underlying HTTP request via the flow networking layer.
File: Code/CoreLibraries/Flows/FlowNetworking/Sources/GooseHeaders.swift
public struct GooseHeaders {
public static let sessionID = "Cash-Goose-Session-Id"
public static let toolRequestID = "Cash-Goose-Tool-Request-Id"
}File: Code/OSHooks/ClientRouting/ClientRouting/Sources/RoutingParams+Goose.swift
public extension RoutingParams {
var gooseSessionID: String? {
if case let .tap(.moneybot(sessionID, _)) = source {
return sessionID
}
return nil
}
var gooseToolRequestID: String? {
if case let .tap(.moneybot(_, toolRequestID)) = source {
return toolRequestID
}
return nil
}
}File: Code/SystemResources/Networking/Networking/Sources/RequestBuilder.swift
extension RequestBuilder {
public enum HeaderKeys {
public static let clientScenario = "Cash-Client-Scenario"
public static let flowToken = "Cash-Flow-Token"
}
}Proto parsing: ActionCardProto.swift → parses title, description, headerIcon, tapBehavior, accessory
Content factory: ActionCardContentFactory.swift → makeContent() builds ActionCardModel from ActionCardProto, wiring up tap behavior with navigation
Tap behavior: TapBehaviorProto.swift → .cardTapAction(CardTapActionProto) or .cardButtons(CardButtonsProto)
Card tap action: CardTapActionProto.swift → contains ClientRouteActionProto (has client_route_url) and optional hiddenMessage
Client route navigation on tap: ActionCardContentFactory.swift:
private func handleClientRouteNavigation(
_ networkURL: NetworkURL,
context: ClientRenderContext,
) async {
await navigator.navigate(
to: networkURL,
source: .tap(.moneybot(sessionID: context.sessionID, toolRequestID: context.toolRequestID)),
)
}This sets source: .tap(.moneybot(...)) which propagates gooseSessionID and gooseToolRequestID through RoutingParams to the flow infrastructure.
View: ActionCard.swift → SwiftUI view with ActionCardModel, supports both .cardTapAction (whole card tap) and .cardButtons (primary/secondary buttons)
Model: ActionCardModel.swift → contains title, description, headerIcon, tapBehavior, accessory, isErrorState
File: IncomingMessagesProcessor.swift
The processSessionActivities method handles clientRenderingUpdate activities, matching by toolRequestID:
func processSessionActivities(_ activities: [ActivityProto], ...) {
for activity in newActivities {
switch activity.activityType {
case let .clientRenderingUpdate(clientRenderingUpdate):
if let clientRenderable = clientRenderingUpdate.clientRenderable {
if let renderedContent = renderClientRenderable(
clientRenderable,
sessionID: sessionID,
toolRequestID: clientRenderingUpdate.toolRequestID,
toolResponseID: clientRenderingUpdate.toolRequestID,
// ...
) {
updateRenderedContent(
potentialRenderedContent: renderedContent,
toolID: clientRenderingUpdate.toolRequestID,
timestamp: activity.created,
)
}
}
}
}
}Content is keyed by toolRequestID in renderedContentByToolResponseID, and newer timestamps replace older ones. Header icons are preserved across transitions via transitionHeaderIconToActionCard.
2.7. Hidden Messages -- ALREADY WORKS
File: MessageSender.swift
public extension MessageSender {
func sendHiddenMessage(_ text: String) async {
await sendMessages([InputMessage(text: text, isHidden: true)], suggestionID: nil, clientSuggestionID: nil)
}
}Used by ActionCardContentFactory when CardTapActionProto.hiddenMessage or CardButtonsProto.hiddenMessage is present:
if let hiddenMessage {
await messageSender.sendHiddenMessage(hiddenMessage)
}File: Code/CoreLibraries/Flows/FlowCore/Sources/FlowToken.swift
iOS generates 25-character alphanumeric tokens:
public struct FlowToken: Codable, Hashable, Sendable {
public init() {
// 25 random alphanumeric characters
let allowedCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
let string = (0..<25).map { _ in String(allowedCharacters.randomElement(using: &generator)!) }.joined()
try! self.init(string)
}
public init(_ token: String) throws {
guard !token.isEmpty else { throw Error.emptyToken }
self.value = token
}
}Server generates UUIDs (PlasmaFlowAdapter.kt:44): UUID.randomUUID().toString()
The server-provided flow token (UUID format) will work fine on iOS since FlowToken.init(_ token: String) accepts any non-empty string. No format validation is performed.
File: Code/CoreLibraries/Flows/FlowCore/Sources/BlockerDescriptor.swift
The iOS BlockerDescriptor wraps SQPBFranklinCommonScenariosBlockerDescriptor but does NOT expose agent_friendly. A codebase-wide search for agent_friendly returns zero results in cash-ios.
The server returns ActionCards with a flow_token query parameter. Zero iOS changes are needed because the entire infrastructure already exists:
- Server returns ActionCard with URL
/dl/complete-scenario/ENABLE_ISSUED_CARD?flow_token=550e8400-... - iOS
.completeScenarioroute already parsespayload.flowToken(FlowsPlugin.swift) FlowsPluginwraps it inStartPlasmaFlowAnalyticsParamsSignedInFlowsPluginextracts it:routingParams.analyticsParams.startPlasmaFlowAnalyticsParams?.flowTokenFlowToken("550e8400-...")succeeds (any non-empty string is valid)- The token is passed to
chainedFlowServerFactory.makeChainedFlow(flowToken:) - Goose context propagation already works via
RoutingParams+Goose→CompleteScenarioRequest - ActionCard rendering and tap handling already work
- Completion card replacement already works via
IncomingMessagesProcessor
What happens on tap: The client calls POST /2.0/cash/complete-scenario with the server-provided flow_token. Plasma's PlasmaFlowCreator.createFlow() finds the existing flow by (customerToken, flowToken) and returns its current snapshot (with any remaining non-agent-friendly blockers). The client renders those blockers natively.
One thing to verify: Confirm that ChainedFlowServerFactory.makeChainedFlow(flowToken:) sets the Cash-Flow-Token header on the underlying HTTP request. The CompleteScenarioRequest does not set this header itself — it's handled by the chained flow infrastructure.
This requires iOS changes:
File to modify: Code/CoreLibraries/Flows/FlowCore/Sources/BlockerDescriptor.swift
Add a public property reading from the proto:
public struct BlockerDescriptor: Equatable, Identifiable, Sendable {
// ... existing properties ...
/// Whether this blocker can be handled conversationally by an AI agent
public let isAgentFriendly: Bool
public init(_ proto: SQPBFranklinCommonScenariosBlockerDescriptor) throws {
// ... existing init code ...
self.isAgentFriendly = proto.agentFriendly // or proto.agent_friendly depending on generated accessor
}
}The blocker processing pipeline needs to check isAgentFriendly before rendering. The exact location depends on where blockers are dispatched — likely in the BlockerDescriptorIterator or the flow step state machine layer.
File: Code/CoreLibraries/Flows/FlowCore/Sources/BlockerDescriptorIterator.swift
Before yielding a blocker for native rendering, check:
if blockerDescriptor.isAgentFriendly, let moneybotContext = flowContext.moneybotContext {
// Route back to chat instead of rendering natively
routeAgentFriendlyBlockerToChat(
blockerDescriptor: blockerDescriptor,
flowToken: flowToken,
moneybotContext: moneybotContext,
)
return
}Navigate back to the moneybot chat screen and send a hidden message:
private func routeAgentFriendlyBlockerToChat(
blockerDescriptor: BlockerDescriptor,
flowToken: FlowToken,
moneybotContext: MoneybotContext,
) {
let hiddenMessage = buildAgentFriendlyBlockerMessage(
flowToken: flowToken,
blockerDescriptor: blockerDescriptor,
)
// Dismiss the current flow presentation
presentationManager.dismissModal {
// Send hidden message to the kgoose session
Task {
await messageSender.sendHiddenMessage(hiddenMessage)
}
}
}3.4. Hidden Message Format
func buildAgentFriendlyBlockerMessage(
flowToken: FlowToken,
blockerDescriptor: BlockerDescriptor,
) -> String {
let json: [String: Any] = [
"type": "agent_friendly_blocker",
"flow_token": flowToken.value,
"blocker_descriptor_id": blockerDescriptor.id.rawValue,
"blocker_type": determineBlockerType(blockerDescriptor),
"display_message": extractDisplayMessage(blockerDescriptor),
"form_elements": extractFormElements(blockerDescriptor),
"primary_action": extractPrimaryAction(blockerDescriptor),
]
return String(data: try! JSONSerialization.data(withJSONObject: json), encoding: .utf8)!
}The Android codebase is not available locally. The following is derived from the research document at thoughts/shared/research/2026-02-10-clientrenderable-actioncard-plasma-flow-architecture.md.
Needs verification: Unlike iOS (where FlowsPlugin already parses flowToken), Android may require changes to parse the flow_token query parameter from the URL:
- Parse
flow_tokenfrom URL inClientRoute.CompleteScenario: Add optionalflowTokenquery parameter parsing - Forward to
BlockersHelper.completeClientScenario(): Pass the flow token instead of generating a new one - Set
Cash-Flow-Tokenheader: Ensure the parsed token is set on the Plasma API call
If Android doesn't support flow_token parsing yet, the client will generate a new FlowToken and call completeScenario with a different token than the server used. Plasma will create a second, separate flow — the server-side flow becomes orphaned. This is functional (the user still gets the native blocker UI) but loses completion card replacement since the kgoose context is on the original flow.
Same pattern as iOS:
- Expose
agent_friendlyonBlockerDescriptorin the blocker rendering pipeline - Route back to moneybot chat with hidden message if
agent_friendly == trueandMoneybotContextis available
| Header | Value | iOS Source |
|---|---|---|
Cash-Client-Scenario |
Scenario name | CompleteScenarioRequest.init → RequestBuilder.HeaderKeys.clientScenario |
Cash-Flow-Token |
UUID from server or client-generated | ChainedFlowServerFactory.makeChainedFlow(flowToken:) → RequestBuilder.HeaderKeys.flowToken |
Cash-Goose-Session-Id |
Kgoose session ID | CompleteScenarioRequest.init → GooseHeaders.sessionID |
Cash-Goose-Tool-Request-Id |
Tool request ID | CompleteScenarioRequest.init → GooseHeaders.toolRequestID |
ActionCardContentFactory.handleClientRouteNavigation()
→ navigator.navigate(source: .tap(.moneybot(sessionID, toolRequestID)))
→ FlowsPlugin.completeScenario handler
→ wraps flowToken in StartPlasmaFlowAnalyticsParams
→ navigator.navigate(to: .clientScenario, metadata: updatedRoutingParams)
→ SignedInFlowsPlugin.clientScenario handler
→ extracts flowToken from analyticsParams
→ extractMoneybotIDs(from: source) → (gooseSessionID, gooseToolRequestID)
→ CompleteScenarioRequest(gooseSessionID:, gooseToolRequestID:)
→ chainedFlowServerFactory.makeChainedFlow(flowToken:, gooseSessionID:)
→ POST /2.0/cash/complete-scenario with all headers
→ Plasma stores kgoose context in flow snapshot
→ KgoosePublisher emits CompletePlasmaFlow
→ IncomingMessagesProcessor.processSessionActivities()
→ replaces ActionCard by toolRequestID
6. Hidden Message Format (Phase 2)
{
"type": "agent_friendly_blocker",
"flow_token": "<string>",
"blocker_descriptor_id": "<string>",
"blocker_type": "<string>",
"display_message": "<string>",
"form_elements": [
{
"id": "<string>",
"type": "<string>",
"label": "<string>",
"keyboard_type": "<string optional>"
}
],
"primary_action": {
"id": "<string>",
"text": "<string>"
}
}{
"type": "agent_friendly_blocker",
"flow_token": "550e8400-e29b-41d4-a716-446655440000",
"blocker_descriptor_id": "enable_card_terms_acknowledgment",
"blocker_type": "FORM_INFORMATIONAL",
"display_message": "By enabling your card, you agree to the Cash Card terms of service.",
"form_elements": [],
"primary_action": { "id": "Acknowledge", "text": "I Agree" }
}- Flow token format: Server sends UUID, iOS
FlowTokenaccepts any non-empty string. No issue. - No flow token in URL: Client generates one (
FlowToken()= 25-char alphanumeric). Plasma creates a new, separate flow. The server-side flow is orphaned but cleaned up by TTL. Completion card replacement won't work since kgoose context is on the server-side flow. This scenario only applies to Android if it doesn't supportflow_tokenparsing yet — iOS already handles it. - Multiple taps: Plasma is idempotent on same
(customerToken, flowToken). iOS should debounce —ActionCardContentFactoryalready usesTapDebouncer. - Flow completed before tap:
KgoosePublisherfiresCompletePlasmaFlow→IncomingMessagesProcessorreplaces ActionCard with completion card before user taps. - No MoneybotContext for agent-friendly blocker: If
gooseSessionIDis nil, skip agent-friendly detection, render blocker natively. - Old client versions: Don't parse
flow_tokenfrom URL → generate new FlowToken → create new flow. Server-side flow orphaned but cleaned up by TTL. Functional but no completion card replacement. - Stale ActionCard after new chat message: The card URL is static. If the user taps after a long time, the server-side flow may be expired. The
completeScenarioAPI will return an error which the client should handle gracefully. - Redundant processing on tap: When the client calls
completeScenariowith the server-provided token, Plasma returns the existing flow idempotently.executeAppApi()runs fastpath collection and quota consumption unnecessarily. This is accepted — see design review Section 2.1.
Phase 1: No new iOS tests needed (existing FlowsPluginTests.test_completeScenario already covers the flow token path). Verify:
ChainedFlowServerFactorysetsCash-Flow-Tokenheader whenflowTokenis providedCompleteScenarioRequestheaders include goose session/tool request IDsActionCardContentFactorysets moneybot source on navigation
Phase 2:
BlockerDescriptorexposesisAgentFriendlyfrom proto- Agent-friendly blocker routes to chat when
MoneybotContextis available - Hidden message JSON format is correct
- Non-agent-friendly blockers still render natively
- End-to-end: ActionCard tap → completeScenario → blocker → completion card replacement
- Flow token roundtrip: server UUID → iOS FlowToken → Cash-Flow-Token header → Plasma flow creation
| File | Path | Relevance |
|---|---|---|
| FlowsPlugin.swift | Code/CoreLibraries/Flows/FlowsPlugin/Sources/ |
.completeScenario route registration, flowToken → analyticsParams |
| SignedInFlowsPlugin.swift | Code/OSHooks/ClientRouting/ClientRoutingImplementations/Sources/ |
.clientScenario handler, flowToken extraction, goose ID extraction |
| CompleteScenarioRequest.swift | Code/CoreLibraries/Flows/FlowFactories/Sources/ |
HTTP request with goose headers |
| GooseHeaders.swift | Code/CoreLibraries/Flows/FlowNetworking/Sources/ |
Cash-Goose-Session-Id, Cash-Goose-Tool-Request-Id |
| RoutingParams+Goose.swift | Code/OSHooks/ClientRouting/ClientRouting/Sources/ |
Extract gooseSessionID/gooseToolRequestID from source |
| RequestBuilder.swift | Code/SystemResources/Networking/Networking/Sources/ |
Cash-Client-Scenario, Cash-Flow-Token header constants |
| ActionCardProto.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Networking/Proto/ |
Proto → model parsing |
| ActionCardContentFactory.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Models/ClientRender/ |
Tap handling, navigation with moneybot context |
| ActionCardModel.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Views/ChatContent/ |
Model: title, description, tapBehavior |
| ActionCard.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Views/ChatContent/ |
SwiftUI view |
| CardTapActionProto.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Networking/Proto/ |
ClientRouteAction + hiddenMessage |
| ClientRouteActionProto.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Networking/Proto/ |
client_route_url parsing |
| TapBehaviorProto.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Networking/Proto/ |
.cardTapAction / .cardButtons |
| FlowLauncherToolHandler.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Models/ |
Existing tool handler pattern for client-side flow launching |
| IncomingMessagesProcessor.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Presentation/ |
Activity processing, completion card replacement |
| MessageSender.swift | Code/Features/Moneybot/MoneybotImplementations/Sources/Models/ |
sendHiddenMessage() |
| BlockerDescriptor.swift | Code/CoreLibraries/Flows/FlowCore/Sources/ |
Proto wrapper (NO agent_friendly today) |
| FlowToken.swift | Code/CoreLibraries/Flows/FlowCore/Sources/ |
25-char alphanumeric tokens, accepts any non-empty string |
| FlowsPluginTests.swift | Code/CoreLibraries/Flows/FlowsPlugin/UnitTests/ |
Tests for .completeScenario route with flowToken |
Server FlowType |
Plasma Scenario Name | URL Path Segment |
|---|---|---|
ENABLE_CARD |
ENABLE_ISSUED_CARD |
ENABLE_ISSUED_CARD |
DISABLE_CARD |
DISABLE_ISSUED_CARD_IN_POSTCARD |
DISABLE_ISSUED_CARD_IN_POSTCARD |
CREATE_SAVINGS_GOAL |
SET_SAVINGS_GOAL_V2 |
SET_SAVINGS_GOAL_V2 |
- Cash-Flow-Token header: Confirm that
ChainedFlowServerFactory.makeChainedFlow(flowToken:)sets theCash-Flow-Tokenheader on the underlying HTTP request to Plasma. TheCompleteScenarioRequestdoes not set this header itself. - agent_friendly proto accessor: What is the generated Swift accessor name for the
agent_friendlyfield onSQPBFranklinCommonScenariosBlockerDescriptor? It may beagentFriendly,agent_friendly, or require a proto regeneration. - Blocker interception point: What is the best place to intercept agent-friendly blockers in the flow step pipeline? Candidates:
BlockerDescriptorIterator, blocker state machine evaluation, orDefaultCompleteScenarioPlanPresenter.