This document describes how moneybot-core classifies and routes Plasma flow blockers through two distinct paths based on the agentFriendly metadata field: conversational resolution (handled entirely by the LLM agent) and native UI rendering (rendered inline in the chat by the client, bypassing the LLM for data collection). Confirmation blockers (e.g., HumanConfirmation) are always natively rendered to guarantee that human intent is correctly captured.
Plasma flows frequently require user interaction via "blockers" — verification steps, data entry forms, confirmations, etc. In an agent-driven chat context, we need two fundamentally different strategies:
- Simple, non-sensitive data collection can be handled conversationally by the LLM — the agent asks the user, collects the answer, and submits it programmatically.
- Sensitive data entry, complex forms, and confirmations must be rendered as native UI inline in the chat. This guarantees:
- Sensitive data (PCI, PII, credentials) never enters the LLM context
- Complex form interactions (card entry, address autocomplete, option pickers) get proper UX
- Confirmations are deterministically captured — the LLM cannot misinterpret or fabricate a user's consent
┌─────────────────────────────────────────────────────────────────────────┐
│ kgoose (LLM Agent) │
│ │
│ 1. Calls MCP tool (e.g., enable_card) │
│ 2. Receives MoneybotOutput with blocker response │
│ 3. Routes based on agent_friendly field: │
│ ┌──────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ agent_friendly = true │ │ agent_friendly = false │ │
│ │ │ │ │ │
│ │ LLM reads data_schema, │ │ kgoose passes blocker_info │ │
│ │ asks user, collects │ │ to client → client renders │ │
│ │ data, calls │ │ blocker inline in chat → │ │
│ │ submit_blocker_data │ │ user submits form directly │ │
│ │ with structured JSON │ │ to Plasma → on completion, │ │
│ │ │ │ control returns to LLM → │ │
│ │ │ │ LLM calls completeScenario │ │
│ └──────────────────────────┘ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Every BlockerResponse exposes an agentFriendly: Boolean property that determines routing:
sealed interface BlockerResponse {
val blockerDescriptorId: String
val blockerType: BlockerType
val agentFriendly: Boolean
// ...
}Classification happens in the adapter layer (ProtoExtensions.kt) when converting Plasma's BlockerDescriptor proto into a domain BlockerResponse. The logic maps blocker types to their handling strategy:
| BlockerType | agentFriendly | Rationale |
|---|---|---|
PASSCODE_VERIFICATION |
true |
Simple 4-digit input, but marked isSensitive so encrypted in transit |
FORM_INFORMATIONAL |
true |
Display-only content (text, acknowledgments) — no sensitive data |
PASSCODE_CREATION |
false |
Requires specialized PIN entry UI |
EMAIL_VERIFICATION |
false |
OTP flow with native input |
PHONE_VERIFICATION |
false |
OTP flow with native input |
IDENTITY_VERIFICATION |
false |
KYC flow — document capture, PII |
CARD_ENTRY |
false |
PCI-scoped card number entry |
ADDRESS_ENTRY |
false |
Autocomplete, structured address fields |
FORM_INTERACTIVE |
false |
Complex input elements (pickers, checkboxes, etc.) |
APPLE_PAY_SELECTION |
false |
Native Apple Pay sheet |
TRANSFER_OPTION_SELECTION |
false |
Multi-option selection UI |
TREEHOUSE |
false |
Embedded native UI component |
| All confirmation blockers | false |
Must be natively rendered (see below) |
Confirmation blockers (including HumanConfirmation) are always natively rendered regardless of their simplicity. This is a deliberate safety decision:
- The LLM must not be able to fabricate, misinterpret, or hallucinate user consent
- Plasma uses a shared proof constant (
HUMAN_CONFIRMATION_PROOF) that the agent cannot guess — the client submits it directly - Native rendering creates a deterministic confirmation UX with explicit Accept/Decline buttons
LLM moneybot-core Plasma
│ │ │
│─── enable_card ────────────>│ │
│ │──── startFlow ───────────>│
│ │<─── BlockerRequired ──────│
│ │ │
│ │ [classify: agentFriendly] │
│ │ [save flow session] │
│ │ │
│<── inline_data_request ─────│ │
│ { │ │
│ request_id: "...", │ │
│ message: "Enter...", │ │
│ data_schema: {...}, │ │
│ is_sensitive: true, │ │
│ agent_friendly: true │ │
│ } │ │
│ │ │
│ [LLM asks user, collects] │ │
│ │ │
│─── submit_blocker_data ────>│ │
│ { │ │
│ request_id: "...", │──── submitBlocker ───────>│
│ data: { passcode: "1234" }, │
│ action: "ACCEPT" │<─── Success/Blocker ──────│
│ } │ │
│ │ │
│<── success / next blocker ──│ │
When an agent-friendly blocker is returned, the MCP tool response includes an inline_data_request in the data field of MoneybotOutput:
{
"message": "Card enabling requires additional verification.",
"data": {
"type": "inline_data_request",
"request_id": "flow-token-123:descriptor-456:action",
"message": "This operation requires your Cash App passcode for verification.",
"data_schema": {
"type": "object",
"properties": {
"passcode": {
"type": "string",
"description": "The customer's 4-digit Cash App passcode"
}
},
"required": ["passcode"]
},
"is_sensitive": true,
"blocker_type": "PASSCODE_VERIFICATION",
"agent_friendly": true
}
}Each agent-friendly blocker type maps to a DataRequestSchema implementation. The transport layer generates JSON Schemas from these Kotlin types at runtime using generateJsonSchema<T>():
// Service layer — defines expected data shape
@Serializable
data class PasscodeVerificationRequest(
@Description("The customer's 4-digit Cash App passcode")
val passcode: String
) : DataRequestSchema
// Transport layer — generates JSON Schema for LLM
fun KClass<out DataRequestSchema>.toJsonSchema(): JsonObject =
when (this) {
PasscodeVerificationRequest::class -> generateJsonSchema<PasscodeVerificationRequest>()
// future types registered here
else -> throw IllegalStateException("No schema registered for: $simpleName")
}The LLM receives the JSON Schema, understands the required fields, asks the user conversationally, then submits structured JSON via submit_blocker_data.
For FORM_INFORMATIONAL blockers with no data to collect (e.g., displaying terms or status messages), dataRequestType is null and data_schema is omitted. The LLM reads the message, communicates it to the user, and submits with data: null and action: ACCEPT (or DECLINE/CANCEL).
When an agent-friendly blocker is returned:
EnableCardInteractor/SubmitBlockerInteractorsaves the flow session to DynamoDB:{sessionId, flowToken, flowType, customerToken, blockerDescriptorId, blockerType}- The
request_id(flowToken:descriptorId:action) ties the LLM's response back to the correct blocker SubmitBlockerInteractorvalidates therequest_idagainst the stored session before submitting to Plasma- On success, the session is deleted. On the next blocker, the session is updated.
LLM moneybot-core kgoose/Client Plasma
│ │ │ │
│─── enable_card ────────────>│ │ │
│ │──── startFlow ────────────────────────>│
│ │<─── BlockerRequired ──────────────────│
│ │ │ │
│ │ [classify: NOT │ │
│ │ agentFriendly] │ │
│ │ │ │
│<── form_required ───────────│ │ │
│ { │ │ │
│ blocker_descriptor_id, │ │ │
│ blocker_type, │ │ │
│ agent_friendly: false, │ │ │
│ blocker_descriptor: │ │ │
│ "<base64 proto>" │ │ │
│ } │ │ │
│ │ │ │
│ [LLM returns form_required │ │ │
│ to kgoose] │ │ │
│ │ │ │
│ kgoose passes blocker ───>│ │
│ to client, enters │ │
│ "waiting for user │ │
│ input" state │ │
│ │ │ │
│ │ [Client renders blocker │
│ │ inline in chat] │
│ │ │ │
│ │ [User fills form, taps │
│ │ Submit] │
│ │ │ │
│ │ │── submitBlocker ─>│
│ │ │<── response ──────│
│ │ │ │
│ kgoose receives │ │
│ completion signal, │ │
│ exits waiting state │ │
│ │ │ │
│ [LLM resumes, calls │ │ │
│ completeScenario] │ │ │
│ │ │ │
│─── complete_scenario ──────>│ │ │
│ │──── submitBlocker (idempotent) ──────>│
│ │<─── Success / next blocker ──────────│
│<── result ──────────────────│ │ │
When a native UI blocker is returned, the MCP tool response includes a form_required in the data field:
{
"message": "Card enabling requires additional verification that must be completed in the Cash App.",
"data": {
"type": "form_required",
"blocker_descriptor_id": "descriptor-456",
"blocker_type": "CARD_ENTRY",
"agent_friendly": false,
"blocker_descriptor": "CgxkZXNjcmlwdG9yLTEyMxoE..."
}
}The blocker_descriptor is the base64-encoded BlockerDescriptor proto — the complete Plasma blocker definition that the client already knows how to render.
When kgoose receives a tool response with agent_friendly: false:
- Enter waiting state: The agent loop pauses, waiting for external user input
- Pass blocker to client: kgoose sends the
blocker_descriptor(decoded from base64) to the client — similar to how it currently passes client routes for action cards - Client renders inline: The client renders the blocker's native UI inline within the chat conversation (not as a separate screen/modal)
- Direct Plasma submission: When the user completes the form, the client submits the response directly to Plasma using the standard blocker submission API. The LLM never sees this data.
- Signal completion: After successful Plasma submission, the client signals kgoose that the blocker is complete
- Resume agent loop: kgoose exits the waiting state and transfers control back to the LLM
After the client submits the blocker directly to Plasma and the agent loop resumes, the LLM calls complete_scenario on the plasma adapter MCP. This is analogous to how the LLM currently handles closing the loop on client-renderable action cards.
Key properties of complete_scenario:
- Idempotent: Because the client already submitted the blocker to Plasma, the
complete_scenariocall effectively re-submits or queries the flow state. Plasma handles this idempotently. - Continues the flow: The response from
complete_scenariowill be either:Success— the flow is completeBlockerRequired— the next blocker in the flow (which may be agent-friendly or native UI)Error— something went wrong
- Deterministic data path: Sensitive form data flowed from user → client → Plasma. The LLM only learns the outcome (success/failure/next-blocker), never the data itself.
| Concern | Agent-Friendly | Native UI |
|---|---|---|
| Data sensitivity | Encrypted in transit, minimal fields | PCI/PII — must never enter LLM context |
| UX complexity | Simple text prompts | Card scanners, address autocomplete, OTP flows |
| Correctness | LLM can reliably collect simple strings | Complex validations, masked inputs, etc. |
| Confirmation integrity | N/A | Human intent must be captured deterministically |
Plasma's HumanConfirmation blocker is designed specifically for agent contexts. It ensures that a human (not the LLM) has explicitly confirmed an action.
Why natively rendered:
- Uses a shared proof constant (
HUMAN_CONFIRMATION_PROOF = "7ba9c052-f24a-4c18-b562-2036d55edb2a") that the LLM does not know - The client submits this proof directly to Plasma — if the LLM tried to guess, it would fail
- Renders as explicit Accept/Decline buttons — no ambiguity in interpretation
Flow:
- Plasma returns a
HumanConfirmationblocker (optionally withadditional_message) - moneybot-core classifies it as
agentFriendly = false - kgoose passes it to the client
- Client renders inline: displays the confirmation message with Accept/Decline buttons
- User taps Accept → client submits proof to Plasma directly
- Control returns to LLM →
complete_scenario→ flow continues
Other confirmation-style blockers (e.g., terms acceptance, legal disclosures) follow the same pattern — they are always natively rendered to ensure human responses are not filtered through LLM interpretation.
User ──(chat)──> LLM ──(submit_blocker_data)──> moneybot-core ──> Plasma
- User data passes through the LLM context
- Appropriate only for non-sensitive, simple data
- LLM provides conversational UX (can ask clarifying questions, validate format)
User ──(form)──> Client ──(direct)──> Plasma
│
LLM <──(complete_scenario)── moneybot-core <── (outcome only)
- User data never enters the LLM context
- Client handles all validation, formatting, and submission
- LLM only learns the outcome (success/failure/next-blocker)
BlockerResponse (service/entity/BlockerResponse.kt):
FormRequired: Raw proto bytes,agentFriendly = falseInlineDataRequest: Request ID, message, schema type reference, sensitivity flag,agentFriendly = true
DataRequestSchema (service/entity/DataRequestSchema.kt):
- Sealed interface — each agent-friendly data type implements this
- Currently:
PasscodeVerificationRequest - Future: any new conversational data types
FlowResult (service/entity/FlowResult.kt):
Success(flowToken)— flow completeBlockerRequired(flowToken, blockerResponse)— user interaction neededError(type, message)— failure
ProtoExtensions.kt (adapter/plasma/ProtoExtensions.kt):
BlockerDescriptor.toBlockerResponse(flowToken)— classification logicBlockerType.isAgentFriendly()— routing decisionBlockerDescriptor.determineBlockerType()— maps proto fields to enumBlockerDescriptor.hasInteractiveElements()— form element analysis
McpBlockerResponse (transport/mcp/plasma_adapter/McpBlockerResponse.kt):
- Serializable sealed class with
FormRequiredandInlineDataRequestvariants toMcpResponse()extension maps domain → transporttoJsonSchema()generates JSON Schema fromDataRequestSchemaKClassdeserialize()converts incoming JSON back to typed data
SubmitBlockerDataTool (transport/mcp/plasma_adapter/tools/SubmitBlockerDataTool.kt):
- Accepts
request_id,data(JSON matching schema), andaction(ACCEPT/DECLINE/CANCEL) - Delegates to
SubmitBlockerInteractor
Tool response routing — kgoose inspects the agent_friendly field in the tool response data:
true→ LLM processes thedata_schemaandmessage, collects data conversationallyfalse→ kgoose enters waiting state, passesblocker_descriptorto client
Waiting state — when kgoose receives a native UI blocker:
- Pauses the agent loop (no LLM inference)
- Sends blocker to client via existing client-renderable channel
- Waits for completion signal from client
- Resumes agent loop — LLM calls
complete_scenario
This mirrors the existing action card pattern: kgoose already passes ClientRenderable action cards to the client and waits for user interaction before continuing. Native blockers use the same mechanism, but with the blocker proto instead of an action card.
Flow sessions are stored in DynamoDB and managed by interactors:
| Event | Action |
|---|---|
BlockerRequired (InlineDataRequest) |
Save session: {sessionId, flowToken, flowType, customerToken, blockerDescriptorId, blockerType} |
BlockerRequired (FormRequired) |
No session saved — client manages |
Success |
Delete session |
Error |
Keep session (allows retry) |
CANCEL / DECLINE action |
Delete session |
- No server-side session needed — the client holds the blocker state and submits directly to Plasma
- The
complete_scenariocall is stateless and idempotent; it re-queries the flow state from Plasma - If the flow has advanced (because the client already submitted), Plasma returns the next state
- Native UI blockers ensure PCI/PII data (card numbers, SSN, addresses) never enters the LLM context
- Agent-friendly blockers with
isSensitive = true(e.g., passcode) are encrypted in transit via Fidelius - The
agentFriendlyclassification is server-side — the LLM cannot override it
HumanConfirmationuses a shared proof constant unknown to the LLM- All confirmation blockers are natively rendered — the LLM cannot fabricate consent
- The client submits the proof directly to Plasma; moneybot-core does not relay it
request_idformat:{flowToken}:{blockerDescriptorId}:action- Validated against stored session before accepting submissions
- Prevents replay or cross-session attacks
- Create a
DataRequestSchemaimplementation inservice/entity/ - Add the blocker type to
BlockerType.isAgentFriendly()inProtoExtensions.kt - Add the mapping in
toBlockerResponse()to constructInlineDataRequestwith the new type - Register the schema in
toJsonSchema()anddeserialize()inMcpBlockerResponse.kt
- Add the blocker type to
BlockerTypeif not already present - Ensure
isAgentFriendly()returnsfalse(the default) - The
FormRequiredpath handles it automatically — the raw proto bytes are passed through - Client-side: implement rendering for the new
BlockerDescriptortype
- Progressive enhancement: Some blocker types could start as native UI and migrate to agent-friendly as LLM capabilities improve (e.g., address entry with structured schema)
- Hybrid blockers: A single flow step could return both a conversational message and a native UI component
- Blocker prefetching: kgoose could speculatively render native blockers before the LLM finishes processing, reducing perceived latency
- Analytics: Track agent-friendly vs native blocker usage to inform which types to invest in for conversational handling