Protocol

Updated Sep 2, 2026View as Markdown

The engine POSTs a decision request to your worker. Your worker returns a decision response. Both are JSON.

The types use TypeScript notation. ? marks an optional field. unknown is any JSON value. Timestamps are RFC 3339 strings. Money is a decimal string.

The machine-readable source is schemas/protocol.schema.json. See Typed bindings to generate types for your language.

Field naming

Fields that the engine defines are snake_case. A payload borrowed whole from another spec keeps that spec's spelling, so a worker can forward one without renaming its fields. MCP's content blocks stay mimeType, and AG-UI's interrupt payload stays expiresAt.

A decision request

The model called your get_weather tool.

{
  "session_id": "0193a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
  "decision_id": "0193a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a61",
  "agent_id": "oncall",
  "identity": { "subject": { "issuer": "app", "id": "user_42" }, "visibility": "shared" },
  "trigger": {
    "type": "tool.execute",
    "id": "call_abc",
    "name": "get_weather",
    "arguments": "{\"city\":\"Tokyo\"}",
    "input": { "status": "valid", "value": { "city": "Tokyo" } },
    "attempt": 1
  },
  "proposed": {},
  "state": { "turns": 2 },
  "agent": {
    "model": "claude-sonnet-4-5",
    "system": "You are the on-call assistant.",
    "tools": [{ "name": "get_weather", "description": "Get the weather." }]
  },
  "calls": [
    { "id": "call_abc", "kind": "tool_call", "status": "pending", "attempt": 1, "name": "get_weather" }
  ],
  "pending_calls": 1,
  "messages": [
    { "id": "m1", "role": "user", "content": "weather in Tokyo?" },
    { "id": "m2", "role": "assistant", "tool_calls": [ { "id": "call_abc", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}" } } ] }
  ],
  "message_tree": { "nodes": [], "head_id": "m2" },
  "ancestry": [],
  "attempts": 1,
  "deadline": "2026-08-05T18:04:00Z",
  "turn_id": "0193a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60"
}

proposed is empty here. Only your worker can run the tool.

A decision response

Run the tool and return its result.

{
  "actions": [
    { "type": "tool.result", "result": { "content": [{ "type": "text", "text": "It is clear in Tokyo." }] } }
  ],
  "state": { "turns": 3 }
}

The engine records the result, ends the call, and prompts the model again.

Request fields

type DecisionRequest = {
    session_id: string
    decision_id: string
    agent_id: string
    identity: WorkerIdentity
    trigger: Trigger
    proposed: DecisionResponse
    state: unknown
    agent: AgentConfig | null
    calls: Call[]
    pending_calls: number
    messages: Message[]
    message_tree: MessageTree
    ancestry: string[]
    attempts: number
    deadline: string | null
    turn_id: string | null
}
FieldMeaning
session_idThe conversation this decision belongs to.
decision_idThis decision. Only one is live at a time.
agent_idWhich agent to act as. Route on this when one worker serves several agents.
identityThe session's owner. The engine sets it once and vouches for it. See Session identity.
triggerWhy the engine is asking. See Triggers.
proposedWhat the engine plans to do. Return it unchanged to accept it. Empty when only your worker knows what to do.
stateYour agent state, stored exactly as you wrote it. null when the session has none.
agentThe config resolved for the active path. null when nothing has set one.
callsTool, model, subagent, and connector calls in flight.
pending_callsHow many tool and subagent calls are in flight.
messagesThe active conversation, root to head. This is what the model sees.
message_treeEvery branch. See Conversations.
ancestryParent session IDs, for a subagent. Empty for a root session.
attemptsHow many times the engine has delivered this decision.
deadlineWhen this attempt expires.
turn_idThe turn this decision belongs to.
type Subject = {
    issuer: string              // where the name comes from: "slack", "app", "cli", …
    id: string                  // that source's own name for the person
}

type WorkerIdentity = {
    subject?: Subject           // absent: nobody is behind this session
    visibility: "shared" | "private"
    metadata?: Record<string, string>
}

type Call = {
    id: string                  // subagents: the call this child answers. connectors: the connection
    kind: "tool_call" | "llm_call" | "subagent" | "connector_sync"
    status: "pending" | "completed" | "failed" | "retry_scheduled" | "queued"
    attempt: number
    deadline?: string
    anchor?: string             // the tree node where the call was requested
    name?: string
    arguments?: string
    handler?: "server" | "worker" | "client"
    stream?: boolean
    agent_id?: string
    session_id?: string         // subagents: the child session the turn runs in
}

A queued call has not started yet. Every wait has a deadline.

Response fields

type DecisionResponse = {
    messages?: DraftMessage[]
    actions?: Action[]
    state?: unknown
    agent?: AgentConfig
    channels?: Record<string, unknown>
}
FieldMeaning
messagesMessages to record on the active path.
actionsWhat the engine does next. See Actions.
stateReplaces your state. Omit or send null to keep it. Send {} to clear it.
agentReplaces the config. Omit to keep it.
channelsHow a frontend shows this decision, keyed by kind. The engine passes it through. See Slack.

An empty response {} writes nothing. Use it to leave a tool call open. See Async tools.

Triggers

What fires, what the engine proposes, and what you return.

TriggerFires whenproposed holdsReturn
session.startThe session was created.The config from the file. Empty when the section sets none.An agent config.
client.messagesA client sent or edited messages.Record the view, then call the model.proposed
client.actionA client called a named action.Empty. A Slack prompt button proposes interrupt.resolve.Your own actions.
tool.executeThe model called your tool.Empty. A tool.error when the arguments failed validation or the tool is undeclared.tool.result or tool.error.
tool.finishedA tool call ended, after retries.Record the result, then call the model. Waits when other calls are in flight.proposed
llm.executeThe agent's LLM block is type = "worker".Empty.llm.result or llm.error, or a stream.
llm.finishedA model call ended.Record the reply, then start its tool calls or end the turn.proposed
subagent.finishedA child session's turn ended.Record the child's result as the tool result, then call the model.proposed
interrupt.resumedSomeone resumed a paused branch.Call the model again over the transcript.proposed
turn.finishedA turn completed. Carries its cost and output.done.proposed
type Trigger =
    | { type: "session.start" }
    | {
          type: "client.messages"
          messages: DraftMessage[]  // the client's full view of the conversation
          new_from: number          // the index of the first new message
          client: ClientContext
      }
    | { type: "client.action"; name: string; args?: unknown }
    | {
          type: "tool.execute"
          id: string
          name: string
          arguments: string         // the raw argument string
          input: ToolInput          // the engine's validation of arguments
          attempt: number
          deadline?: string
      }
    | {
          type: "tool.finished"
          id: string
          ok: boolean
          name: string
          result?: StoredResult
          error?: ErrorInfo
      }
    | {
          type: "llm.execute"
          id: string
          request: unknown          // LlmRequest, or the provider's body when format is set
          format?: "openai" | "anthropic"
          stream: boolean
          attempt: number
          deadline?: string
      }
    | {
          type: "llm.finished"
          id: string
          ok: boolean
          message?: DraftMessage
          truncated: boolean
          usage?: Usage
          cost?: string
          error?: ErrorInfo
      }
    | {
          type: "subagent.finished"
          id: string                // the tool call
          ok: boolean
          session_id: string        // the child
          agent_id: string
          result?: string
          error?: ErrorInfo
      }
    | { type: "interrupt.resumed"; interrupt_id: string; payload?: unknown }
    | {
          type: "turn.finished"
          turn_id: string
          data?: unknown            // the turn's final output
          cost: string
          usage: Usage
      }

type ToolInput =
    | { status: "valid"; value: unknown }
    | { status: "invalid"; value: unknown; error: string }
    | { status: "malformed"; error: string }  // not a JSON object

type ClientContext = {
    tools?: AgentTool[]         // tools the client runs, added to the proposed config
    context?: unknown[]
    state?: unknown
    forwarded_props?: unknown
}

Actions

ActionDoesAnswers
llm.callMake a model call.Any trigger.
tool.callStart a tool call.Any trigger.
tool.resultEnd a tool call with a result.tool.execute
tool.errorEnd a tool call with a failure.tool.execute
llm.resultEnd a model call your worker made.llm.execute
llm.errorEnd a model call with a failure.llm.execute
subagent.spawnStart a child session.Any trigger.
message.sendWrite a message into a session.Any trigger.
interruptPause the active branch.Any trigger.
interrupt.resolveClear an open interrupt and resume.Any trigger.
connector.syncFetch a connection's tools again.Any trigger.
doneEnd the turn.Any trigger.
type Action =
    | {
          type: "llm.call"        // every field is optional. the engine fills a
          id?: string             // missing field from the agent config, then
          llm?: string            // from its own defaults
          model?: string
          messages?: DraftMessage[]  // set these and the engine adds no system prompt
          tools?: LlmTool[]
          temperature?: number
          max_completion_tokens?: number
          reasoning?: ReasoningConfig
          stream?: boolean
          retry?: RetryOverride
      }
    | {
          type: "tool.call"
          id?: string             // omitted: the engine creates one
          name: string            // the name decides where the call runs
          arguments: unknown
          retry?: RetryOverride
      }
    | {
          type: "tool.result"
          id?: string             // id and attempt default to those of the
          attempt?: number        // tool.execute you answer
          result?: unknown        // a ToolResult, or any value, which becomes one
          content?: ToolContent[] // the blocks, instead of result. never both
          structured_content?: unknown
          is_error?: boolean
      }
    | {
          type: "tool.error"
          id?: string
          attempt?: number
          error: string           // the model reads this. write it for the model
          retryable?: boolean     // default false
          code?: ErrorCode
          detail?: unknown
      }
    | {
          type: "llm.result"
          id?: string
          attempt?: number
          response: unknown       // LlmResponse, or the provider's response
      }                           // when the llm.execute carried a format
    | {
          type: "llm.error"
          id?: string
          attempt?: number
          error: string
          retryable?: boolean     // default false
          code?: ErrorCode
          detail?: unknown
      }
    | {
          type: "subagent.spawn"
          session_id?: string     // omitted: start a child. named: continue that one
          agent_id: string
          tool_call_id: string    // the model's tool call this child answers
          message?: DraftMessage  // the child's first message
          retry?: RetryOverride
      }
    | { type: "message.send"; session_id: string; message: DraftMessage }
    | {
          type: "interrupt"
          interrupt_id?: string   // omitted: the engine creates one
          reason: string
          payload?: unknown
      }
    | { type: "interrupt.resolve"; interrupt_id: string; payload?: unknown }
    | { type: "connector.sync"; path: string }   // "mcp.<id>" or "plugin.<id>.mcp.<server>"
    | { type: "done"; data?: unknown }

{ "type": "llm.call" } on its own prompts the model with the agent's config over the current conversation.

llm.call takes an llm to send one call to another block. The config stays as it is. See LLMs.

connector.sync names a connection that the current config already names. Use it after a person corrects a credential. The fetch runs again with a full retry budget, and the tools that it returns replace the ones the session held. The engine then delivers the decision that was waiting on that connection. See Connectors.

Errors

type ErrorInfo = {
    message: string             // one sentence, safe to show a person
    code: ErrorCode
    param?: string              // the input to fix, such as `agent.llm`
    detail?: unknown
}

type ErrorCode =
    | "provider_error"
    | "rate_limited"
    | "refused"
    | "budget_exceeded"
    | "deadline_exceeded"

code describes a failure. retryable decides whether the engine tries again. See Retries.

Agent config

type AgentConfig = {
    model: string               // the only required field
    llm?: string                // the [llm.<id>] block calls run on
    system?: string
    effort?: "xhigh" | "high" | "medium" | "low" | "minimal" | "none"
    retry?: RetryConfig
    tools?: AgentTool[]
    subagents?: Subagent[]
    subagent_tools?: {              // what shape the subagents take as tools.
        strategy?: "per_agent" | "single"  //   omitted: "per_agent"
    }
    mcp?: McpServer[]
    plugins?: AgentPlugin[]
    defer_tools?: boolean | {   // the default for every tool of this agent.
        strategy?: "search"     //   presence is the switch. omitted: no opinion
        max_matches?: number    //   matches per search, >= 1. omitted: 5
    }
    mcp_announce?: "auto" | "never"  // tell the model a connection exists. omitted: "auto"
    attachments?: Attachments   // how a file reaches the model. omitted: as media
}

type Attachments = {
    tools?: ("read" | "view")[]  // the attachment tools the agent offers
    max_inline?: number | string // bytes, or "20mb". an inline file over it becomes an attachment
    rules?: {                    // mime pattern to disposition. most specific wins
        [pattern: string]: "inline" | "attachment"
    }
}

type AgentTool = {
    name: string
    description?: string
    input?: unknown             // JSON Schema for the arguments
    output?: unknown            // JSON Schema the result must match
    handler?: "worker" | "client"  // default worker
    defer?: boolean             // keep it out of the request; a search still finds it.
                                // omitted: the agent's defer_tools
}

type Subagent = {
    id: string                  // the agent to start, and the tool name the model sees
    description?: string
    defer?: boolean             // keep the tool out of the request. omitted: the agent's defer_tools
    prefix?: boolean            // offer the tool as "agent__<id>". omitted: the bare id
}

type McpServer = {
    path: string                // where the connection is declared: "mcp.<id>" or
                                //   "plugin.<id>.mcp.<server>". never a URL
    tools?: McpTools            // omitted: every tool the connection offers
    auth_failure?: "interrupt" | "degrade"          // one that needs authorizing. omitted: "interrupt"
    tool_sync_failure?: "warn" | "silent"           // one the engine cannot fetch. omitted: "warn"
    approve?: "never" | "destructive" | "always"    // calls that wait for a person. omitted: "never"
}

type McpTools = {
    include?: string[]          // globs over the tool's name on the connection
    exclude?: string[]
    read_only?: boolean         // these read the MCP annotations
    non_destructive?: boolean
    idempotent?: boolean
    defer?: boolean             // omitted: the agent's defer_tools
}

type AgentPlugin = {
    id: string                  // a plugin the engine holds. never a directory path
    description?: string
    servers?: string[]          // connection paths of this plugin's servers
    skills?: { name: string; description?: string }[]   // listed to the model
    tools?: McpTools            // applied to each of the plugin's servers
    approve?: "never" | "destructive" | "always"
    auth_failure?: "interrupt" | "degrade"
    tool_sync_failure?: "warn" | "silent"
}

effort sits on the agent because it pairs with the model. Unset, it sends no reasoning config and leaves the provider its own default. See Plugins for what an AgentPlugin names.

Who runs a model call is not on the config. The [llm.<id>] block's type decides it, and that block's format sets the wire shape a worker answers in. See LLMs.

An [agent.<id>] section in subs.toml uses these same names. See Config.

Retries

type RetryPolicy = {
    queue_timeout_secs: number | null  // the wait for an executor
    run_timeout_secs: number | null    // the work itself. null runs forever
    total_timeout_secs: number | null  // the whole effect. null has no limit
    max_attempts: number               // attempts, not retries
    backoff_base_secs: number
    backoff_max_secs: number
}

type RetryOverride = {          // names only the fields it changes
    queue_timeout_secs?: number
    run_timeout_secs?: number
    total_timeout_secs?: number
    max_attempts?: number
    backoff_base_secs?: number
    backoff_max_secs?: number
}

type RetryConfig = {            // one override per kind. they stack
    default?: RetryOverride
    llm?: RetryOverride
    tool?: RetryOverride
    subagent?: RetryOverride
    connector?: RetryOverride
}

Messages

type Role = "system" | "user" | "assistant" | "tool"

type Content = string | (StoredContent | ContentPart)[]

// What the log keeps. A client may send either shape. A `ContentPart` is
// recorded as a stored part: a `data:<mime>;base64,…` URI, in any part or in
// a `blob`, is stored and becomes a `blob://` ref, so nothing large enters
// the log; a URL becomes a `link`. An image link reaches the model as an
// image.
type StoredContent =
    | { type: "text"; text: string }
    | { type: "blob"; uri: string }
    | { type: "link"; uri: string; name?: string; mimeType?: string }
    | { type: "attachment"; id: string; mime: string; size: number; uri: string }   // reaches the model as one line naming it

// What a worker-hosted model receives: each `blob` inlined as the part its
// mime names. A provider adapter maps the same media to what that provider
// takes, and notes what it does not.
type ContentPart =
    | { type: "text"; text: string }
    | { type: "image_url"; image_url: { url: string } }
    | { type: "file"; file: { filename: string; file_data: string } }
    | { type: "input_audio"; input_audio: { data: string; format: string } }
    | { type: "video_url"; video_url: { url: string } }

type ToolCall = {
    id: string
    type: string
    function: { name: string; arguments: string }
}

// A recorded message. DraftMessage is the same shape with an optional id.
type Message = {
    id: string
    role: Role
    content?: Content
    tool_calls?: ToolCall[]
    tool_call_id?: string
    name?: string
}

type MessageTree = {
    nodes: { message: Message; parent_id?: string }[]
    head_id?: string
}

Model requests and responses

The engine uses these shapes when the config sets no format.

type LlmRequest = {
    model: string
    messages: DraftMessage[]
    tools?: LlmTool[]
    temperature?: number
    max_completion_tokens?: number
    reasoning?: ReasoningConfig
}

type LlmTool = {
    name: string
    description: string
    input?: unknown             // JSON Schema
    output?: unknown
}

type ReasoningConfig = {
    effort?: "xhigh" | "high" | "medium" | "low" | "minimal" | "none"
    max_tokens?: number
    exclude?: boolean
    enabled?: boolean
}

type LlmResponse = {
    model: string
    content?: string
    tool_calls?: ToolCall[]
    finish_reason?: string
    usage?: Usage
    cost?: string               // dollars, decimal string
    images?: { url: string }[]
}

// What one call read and wrote. Each adapter normalizes the vendor's own
// counts into these fields, so totals add up across models and providers.
type Usage = {
    input: number           // every input token, cached or not
    output: number
    uncached_input: number  // the part of `input` read fresh
    cache_read: number      // the part of `input` read from the cache
    cache_write: number     // the part of `input` written to the cache
    total: number           // `input` and `output` together
    provider?: unknown      // the counts as the provider reported them
}

Client inputs

What a client submits. The session comes from outside the input: from the CLI's --session, or from the request body.

type ClientInput =
    | {
          type: "client.message"
          agent_id: string
          turn_id?: string        // idempotency key
          message: DraftMessage
          stream?: boolean
          queue?: boolean         // wait for the next turn
      }
    | {
          type: "client.messages"
          agent_id: string
          turn_id?: string
          messages: DraftMessage[]  // the client's full view
          stream?: boolean
          client?: ClientContext
      }
    | {
          type: "client.append"
          agent_id: string
          turn_id?: string
          messages: DraftMessage[]  // added at the head, never branching
          stream?: boolean
          client?: ClientContext
          queue?: boolean
      }
    | {
          type: "client.action"
          agent_id: string
          turn_id?: string
          name: string
          args?: unknown
      }
    | { type: "interrupt.resume"; interrupt_id: string; payload?: unknown }
    | { type: "tool.result"; id: string; attempt?: number; result?: unknown }
    | {
          type: "tool.error"
          id: string
          error: string
          retryable: boolean
          attempt?: number
      }

agent_id routes the turn. It creates the session when the session is new. A turn_id that is already complete returns that turn.

Three ways to send messages

InputEffect
client.messageAdds one message at the head.
client.appendAdds several messages at the head. Use it to sync an outside conversation.
client.messagesReplaces the conversation with your view, and branches where the two differ.

client.append never branches. The engine composes it against the active path when it delivers it, and drops any message whose ID it already recorded.

Queued turns

A session runs one turn at a time. A submit that arrives during a turn is refused with turn_already_active.

Set queue: true to wait instead. The engine holds the message and starts it when the running turn completes. The reply carries queued: true while it waits.

Only client.message and client.append take this flag. Queued turns run in the order they arrived.

Delivery

The engine POSTs to your worker's URL.

HeaderValue
Content-Typeapplication/json
Accepttext/event-stream, application/json
traceparentW3C trace context
X-Substructure-Signaturesha256=<hex HMAC-SHA256 of the body>, when the agent has a signing secret

Answer with application/json holding a DecisionResponse. To stream, answer with text/event-stream.

Streaming

A worker that answers an llm.execute with stream: true can reply with text/event-stream.

event: llm.token.delta
data: { "text": "hel" }

event: llm.token.delta
data: { "text": "lo" }

event: decision.result
data: { "actions": [{ "type": "llm.result", "response": … }] }

Each llm.token.delta carries a StreamDelta, or a provider stream event when the llm.execute carried a format. The stream ends with one decision.result frame holding a DecisionResponse. It can end with a decision.error frame instead, holding message and retryable. retryable defaults to true.

type StreamDelta = {
    text?: string
    reasoning?: string
    tool_calls?: { id: string; name?: string; arguments?: string }[]
    finish_reason?: string
}

Next steps

  • Workers: the code that answers these.
  • Config: the same types, declared in the file.
  • Events: what the engine streams to clients.
  • REST API: the endpoints clients call.