Skip to content

Instantly share code, notes, and snippets.

@jclyne
Created February 9, 2026 17:02
Show Gist options
  • Select an option

  • Save jclyne/b5bdecd4000ac1cffe9f6368237dcc72 to your computer and use it in GitHub Desktop.

Select an option

Save jclyne/b5bdecd4000ac1cffe9f6368237dcc72 to your computer and use it in GitHub Desktop.

Blocker Handling Design: Agent-Friendly vs Native UI Blockers

Status: Draft

Summary

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.


Motivation

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:

  1. Simple, non-sensitive data collection can be handled conversationally by the LLM — the agent asks the user, collects the answer, and submits it programmatically.
  2. 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

Architecture Overview

┌─────────────────────────────────────────────────────────────────────────┐
│                           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     │  │
│     └──────────────────────────┘  └──────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────────┘

Blocker Classification

The agentFriendly Field

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

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

Path 1: Agent-Friendly Conversational Blockers (agentFriendly = true)

Flow

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 ──│                           │

Response Format

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
  }
}

Schema-Driven Data Collection

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.

Acknowledgment-Only Requests

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).

Session Management

When an agent-friendly blocker is returned:

  1. EnableCardInteractor / SubmitBlockerInteractor saves the flow session to DynamoDB: {sessionId, flowToken, flowType, customerToken, blockerDescriptorId, blockerType}
  2. The request_id (flowToken:descriptorId:action) ties the LLM's response back to the correct blocker
  3. SubmitBlockerInteractor validates the request_id against the stored session before submitting to Plasma
  4. On success, the session is deleted. On the next blocker, the session is updated.

Path 2: Native UI Blockers (agentFriendly = false)

Flow

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 ──────────────────│                    │                   │

Response Format

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.

kgoose Behavior

When kgoose receives a tool response with agent_friendly: false:

  1. Enter waiting state: The agent loop pauses, waiting for external user input
  2. 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
  3. Client renders inline: The client renders the blocker's native UI inline within the chat conversation (not as a separate screen/modal)
  4. 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.
  5. Signal completion: After successful Plasma submission, the client signals kgoose that the blocker is complete
  6. Resume agent loop: kgoose exits the waiting state and transfers control back to the LLM

The completeScenario Bridge

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_scenario call effectively re-submits or queries the flow state. Plasma handles this idempotently.
  • Continues the flow: The response from complete_scenario will be either:
    • Success — the flow is complete
    • BlockerRequired — 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.

Why Not Route Native Blockers Through the LLM?

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

Confirmation Blockers

HumanConfirmation

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:

  1. Plasma returns a HumanConfirmation blocker (optionally with additional_message)
  2. moneybot-core classifies it as agentFriendly = false
  3. kgoose passes it to the client
  4. Client renders inline: displays the confirmation message with Accept/Decline buttons
  5. User taps Accept → client submits proof to Plasma directly
  6. 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.


Data Flow Comparison

Agent-Friendly Path

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)

Native UI Path

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)

Implementation Details

Service Layer (domain model)

BlockerResponse (service/entity/BlockerResponse.kt):

  • FormRequired: Raw proto bytes, agentFriendly = false
  • InlineDataRequest: 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 complete
  • BlockerRequired(flowToken, blockerResponse) — user interaction needed
  • Error(type, message) — failure

Adapter Layer (Plasma integration)

ProtoExtensions.kt (adapter/plasma/ProtoExtensions.kt):

  • BlockerDescriptor.toBlockerResponse(flowToken) — classification logic
  • BlockerType.isAgentFriendly() — routing decision
  • BlockerDescriptor.determineBlockerType() — maps proto fields to enum
  • BlockerDescriptor.hasInteractiveElements() — form element analysis

Transport Layer (MCP)

McpBlockerResponse (transport/mcp/plasma_adapter/McpBlockerResponse.kt):

  • Serializable sealed class with FormRequired and InlineDataRequest variants
  • toMcpResponse() extension maps domain → transport
  • toJsonSchema() generates JSON Schema from DataRequestSchema KClass
  • deserialize() converts incoming JSON back to typed data

SubmitBlockerDataTool (transport/mcp/plasma_adapter/tools/SubmitBlockerDataTool.kt):

  • Accepts request_id, data (JSON matching schema), and action (ACCEPT/DECLINE/CANCEL)
  • Delegates to SubmitBlockerInteractor

kgoose Integration

Tool response routing — kgoose inspects the agent_friendly field in the tool response data:

  • true → LLM processes the data_schema and message, collects data conversationally
  • false → kgoose enters waiting state, passes blocker_descriptor to client

Waiting state — when kgoose receives a native UI blocker:

  1. Pauses the agent loop (no LLM inference)
  2. Sends blocker to client via existing client-renderable channel
  3. Waits for completion signal from client
  4. 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.


Session Management

Agent-Friendly Blockers

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

Native UI Blockers

  • No server-side session needed — the client holds the blocker state and submits directly to Plasma
  • The complete_scenario call 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

Security Considerations

Sensitive Data Isolation

  • 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 agentFriendly classification is server-side — the LLM cannot override it

Confirmation Integrity

  • HumanConfirmation uses 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 ID Validation

  • request_id format: {flowToken}:{blockerDescriptorId}:action
  • Validated against stored session before accepting submissions
  • Prevents replay or cross-session attacks

Adding New Blocker Types

To add a new agent-friendly blocker:

  1. Create a DataRequestSchema implementation in service/entity/
  2. Add the blocker type to BlockerType.isAgentFriendly() in ProtoExtensions.kt
  3. Add the mapping in toBlockerResponse() to construct InlineDataRequest with the new type
  4. Register the schema in toJsonSchema() and deserialize() in McpBlockerResponse.kt

To add a new native UI blocker:

  1. Add the blocker type to BlockerType if not already present
  2. Ensure isAgentFriendly() returns false (the default)
  3. The FormRequired path handles it automatically — the raw proto bytes are passed through
  4. Client-side: implement rendering for the new BlockerDescriptor type

Future Considerations

  • 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment