Octov0.11.7
ReferenceBlocks

AI Blocks

Agents, routers, mapping, retry, memory management, and the MCP router.

Reference for the AI block family. For narrative guides see the AI section: agents, routing, mapping, retry, memory, and MCP servers.

ai-agent, ai-router, ai-retry, and mcp-router are composites: their keys sit at the top level of the block, not under settings:. ai-mapping, ai-embed, and clear-agent-memory are leaf blocks configured under settings:. The connector field on each LLM-driven block names a connector you configured (the instance name, like claude or embeddings, not the type). It must be an LLM provider connector of type llm-anthropic, llm-openai, llm-gemini, or llm-openrouter. Any of the four works, except for ai-embed, which needs llm-openai, llm-gemini or llm-openrouter, because Anthropic has no embeddings API.

ai-agent

Lets an LLM accomplish a task by calling flow branches as tools in a loop. Each tool is wired to the model as a callable function: the model's arguments become the branch's message body and the branch's output body is returned as the tool result. The calls of one turn run concurrently, each on its own copy of the message, so a variable a tool sets is that call's own; what carries across the loop is the model's answer and each tool's result. Set maxParallelTools: 1 for branches that must hand each other state through the message — see tool calls run together.

KeyTypeRequiredDefaultDescription
connectorstringYesnoneName of the LLM connector that drives the agent loop.
promptstringYesnoneTask instruction; the model calls tools as needed, then stops.
guardrailstringNononeDescribes when the model should fall back to the default path.
inputexpression (string)NononeThe agent's opening user turn. Empty hands the model the whole input body as a JSON document to work from.
attachmentsexpression (list)NononeFiles the opening turn carries: a list of {mimeType, data, name} maps whose data is base64 or a data: URL. Requires input, and a connector whose model reads media — a block missing either fails to build. See attachments.
keepAttachmentsboolNofalseHold the attachments on every turn of the run, and in the memory it saves, instead of shedding them once the model has read them. See attachments ride one turn.
responseMediastringNononeMessage variable the files the model produced are written to, as a list of {name, mimeType, size, data} maps whose data is base64. Empty writes nothing. See generated media.
answerenum json | textNojsonThe shape the model is told to reply in. json suits an agent whose answer is the next block's body; text leaves the format to your prompt.
maxIterationsintNo8Cap on tool-calling turns (one model call each) before falling back to the guardrail.
maxParallelToolsintNo10How many of one turn's tool calls run at the same time. The calls go on a queue drained by this many consumers, each on its own copy of the message — so nothing a tool branch writes to the message reaches the next call. Set it to 1 for an agent whose tools hand each other state; see tool calls run together.
memoryThreadIdexpression (string)NononeConversation thread id. When set, the agent loads the thread's prior transcript before the run and saves it after. Empty disables memory. It is also what a run is claimed on while it is in flight, so a later message on the same thread joins it rather than starting a rival that would overwrite its memory. Claims are scoped to this block, so two agents whose expressions agree do not collide.
stopWhenexpression (bool)NononeEnds the run already working on this message's conversation instead of starting one. Requires memoryThreadId, which is what names the conversation. A stop that finds nothing running is a no-op, so a client can send one blind.
authorizeIdexpression (string)NononeWhich tool authorization an incoming message answers: the id the tool_authorization event carried. A non-empty result makes the invocation an answer rather than a message, handed to the run working on this conversation, and the flow stops. Requires memoryThreadId, and is required by any tool that declares authorize.
authorizeAllowexpression (bool)NononeWhat that answer says. Read only on an invocation authorizeId already identified as one, so anything but a plain yes (including an expression that fails to evaluate) denies the call. Requires authorizeId.
authorizeTimeoutdurationNo5mHow long a gated call waits for a person before it is denied on their behalf. A run parked on a call nobody will answer is billed for the wait, so there is always a limit.
contextMaxTokensintNo200000Token budget for the whole prompt (system, tools and conversation), measured from what the provider reports it read. The transcript is compacted when the prompt would exceed it, before the turn and again before saving. Applies with or without memory.
memoryCompactionenum prune | summarizeNopruneHow memory over budget shrinks: prune drops the oldest turns; summarize folds them into a running summary (an extra model call).
memoryVolatileboolNofalseKeep transcripts in the volatile KV tier (Redis in a cluster, process memory standalone) rather than the persistent one. For a conversation whose loss costs nothing, such as a specialist in another agent's tool slot working a minted scope. Never for one somebody will ask to see again.
agentIdstringNononeStable name for this logical agent. Setting it opts the block into first-class memory: durable conversation history the platform can list and replay, working memory checkpointed during the run, and (with userMemory) curated facts about a person. Without it the agent keeps the older per-thread transcript only. It is stated and never derived; see why. Requires memoryThreadId.
userIdexpression (string)NononeWho the agent is talking to. Scopes user memory and labels stored conversations, so the platform can list one person's threads.
historyenum record | offNorecord with agentIdWhether completed turns are appended to durable conversation history. Unlike working memory this record is never compacted, so it stays readable after the agent has summarized its own context away. Requires agentId; defaults to off when the block declares no agentId, and when memoryVolatile is set.
forwardContextexpression (map)NononeOpaque context forwarded with every agent memory call. Evaluated once per run against the inbound message, so a value the flow already holds — an encryption key, a tenant — reaches the memory service without the runtime keeping a copy of it. What the service does with it is the service's business; the runtime only carries it. Requires agentId.
userMemoryboolNofalseGive the agent remember, forget and search_memory tools so it can keep curated facts about a person and carry them into later conversations. Requires agentId and userId.
toolslistYesnoneTool branches (below). At least one required.
skillslistNononeLoadable instruction resources (below).
nameThreadflowNononeNames a conversation, once, on the exchange that opened it (below). Requires agentId.
eventsflowNononeObserver path, run once per agent event with the event as the body (below).
emitstring listNononeWhich event types reach events. Empty emits every type the block can produce.
streamboolNofalseDrive the provider's streaming API so model output reaches events as it is produced. Requires events and a provider that streams.
defaultflowNononeGuardrail path, run when the model refuses or hits maxIterations.

Each tools entry:

FieldTypeRequiredDescription
namestringYesTool name the model calls; must be unique and not the reserved load_skill.
descriptionstringYesTells the model what the tool does.
inputSchemastring (JSON)NoJSON Schema for the tool's arguments, written inline as a string. Defaults to {"type":"object"}.
authorizeexpression (bool)NoWhether this call needs a person before it runs. CEL over the call the model asked for: input is the decoded arguments, tool.name and tool.id name it, and the message scope is in scope as everywhere else. Absent is free, which is the default for every tool. See Tool authorization.
processblock listYesChain that runs the tool: arguments arrive as the body, the output body is the tool result.

Each skills entry (see Agent skills and tools):

FieldTypeRequiredDescription
namestringYesSkill name; unique, must not collide with a tool name or load_skill.
descriptionstringYesAdvertised to the model up front, with the name.
resourcestringYesTemplate resource (its resources.templates alias) whose rendered content the implicit load_skill tool returns on demand.

Attachments

An agent can be handed files — a screenshot to look at, a scanned invoice to read, a voice note to transcribe — instead of a description of them. The attachments expression yields a list, one entry per file:

- type: ai-agent
  name: look
  connector: claude
  input: body.question
  attachments: has(body.files) ? body.files : []
  prompt: Answer the question about the attached files.
  tools: [...]

Each entry is a map:

FieldTypeRequiredDescription
mimeTypestringYes*IANA type, e.g. image/png. *Optional when data is a data: URL that states one; a stated type wins.
datastringYesThe file, base64. A data:<mime>;base64,<payload> URL is accepted and unwrapped, which is what a browser hands you.
namestringNoFilename to show the model, where the provider has somewhere to put one.

data is a string rather than bytes because CEL has no bytes type — a value reaching a block through an expression goes through JSON, and base64 is what survives that.

Which types are accepted is the provider's business, and they differ; see LLM connectors. A type the connector cannot send is an error, never a silent omission — a model answering confidently about a file it never received is a failure nothing downstream can see.

Two things fail the build rather than a turn, for the same reason:

  • A connector whose model reads only text.
  • attachments without input. The default opening turn is the whole body as JSON, so a flow that put its files on the body would send each of them twice: once as a file the model can read, and once as a base64 string in the middle of the prompt. The text copy is the expensive half — it is prose, not an attachment, so nothing sheds it and it persists into memory to be re-read on every later turn. It works, and it bills quietly, which is why it is refused.

A tool branch cannot return a file. ai-agent tool results are text, so a tool that renders a chart or screenshots a page can only describe what it produced (#512).

Attachments ride one turn

The model reads the attachments on the agent's first model call. They are dropped from the transcript as soon as that call returns, so every later turn of the run is text, working memory never stores the bytes, and a follow-up on the same conversation does not re-send them.

That is what keeps a screenshot from costing you its own size on every turn for the rest of the conversation. The model has read the file by then, and what it understood is in its own answer.

Set keepAttachments: true for an agent that has to look at the file again after its tools have run — measuring something, checking a detail its own summary would have lost — and accept that every turn re-sends and re-pays for it, and that the next run loads it out of memory and pays again.

Durable conversation history is text either way: reopening a stored thread shows the question without its picture (#513). A message sent while a run is already in flight cannot carry attachments (#516).

Generated media

Some models return files as well as text. Name a variable in responseMedia and they are written to it:

- type: ai-agent
  name: chartist
  connector: gemini
  input: body.question
  responseMedia: art
  prompt: Draw the chart the user asks for.
  tools: [...]

vars.art is then a list of {name, mimeType, size, data} maps, data base64 — the same shape attachments reads, so one agent's output feeds another's input. Files produced on any turn are collected, not just the last one: a model that draws something and then calls a tool to do something with it drew it on a turn that was not the last.

responseMedia is meant for the very next block — store the file, attach it to an email — not for carrying down a long flow. It is bytes on a message variable, and everything downstream copies it. With variable capture on, a file over the trace payload cap also costs you every other variable on that record (#515).

A streamed turn reports its media only when the turn ends, not progressively (#517).

Agent events

An agent says nothing until its loop finishes unless an events path is set. That path is run once per event, with the event as the message body. Its result is discarded and it runs on its own message, so the agent's variables and body are untouched. The agent's variables are copied onto it, which lets an sse-event in the path reach the caller with no configuration.

typeWhenFields beyond type and iteration
turn_startBefore each model callnone
textA fragment of the answertext, index
thinkingA fragment of the model's reasoningtext, index
tool_inputA fragment of a tool call's argumentstext, index, tool, toolCallId
customA provider event with no canonical equivalentname, text, index
tool_callThe model asked for a tool, arguments completetool, toolCallId, input
tool_authorizationA gated call is waiting on a persontool, toolCallId, authorizationId, input, expiresInSeconds
tool_resultThe branch that ran it returnedtool, toolCallId, output, isError
turn_endA model turn finishedtext, stopReason, usage, contextTokens, contextMaxTokens
compaction_startThe agent is about to shrink its own conversationstrategy, before, contextTokens, contextMaxTokens
compaction_endIt finishedthe same, plus after and dropped
signalSomething was posted to the run from outside itsignal (context, stop, authorize or unanswered), text; an authorize signal carries authorizationId and allowed instead
guardrailThe agent fell back to defaultreason
doneThe agent finished with an answertext
errorA model call failederror

contextTokens and contextMaxTokens are the context gauge: how full the prompt is, and how full it is allowed to get. They ride on every turn_end, so a progress UI can show the context filling. The reading is exact (the prompt the provider reported reading, plus the reply it produced) and is omitted for a turn the provider reported no usage for.

text, thinking, tool_input and custom arrive only with stream: true; the rest are the agent's own and need no streaming. Not every provider produces every kind; see LLM providers.

Two settings choose what you get. emit decides what is built at all, so a type left out costs nothing; a filter inside the path decides on the event's contents. On a token stream the first matters:

- type: ai-agent
  connector: claude
  prompt: Answer the customer's question about their order.
  stream: true
  emit: [text, tool_call, done]     # text fragments never built for other types
  events:
    process:
      - type: filter                # and only the tool the caller cares about
        settings:
          expr: 'body.type != "tool_call" || body.tool == "lookup"'
      - type: sse-event             # vars.sseStream is already on the message
        settings:
          event: agent
  tools: [...]

A stop from the events path (usually sse-event with ifClosed: stop, after the caller hung up) ends the run: the agent abandons the turn mid-stream rather than paying a provider for output nobody will read.

Behavior notes:

  • The final assistant text becomes the message body, parsed as JSON when possible and stored as text otherwise; an empty answer leaves the last tool's body standing.
  • Set answer: text for an agent a person reads. The default adds "respond with the final result as JSON only (no prose, no markdown code fences)" ahead of your prompt, which contradicts a prompt asking for Markdown. A model handed both instructions picks one, and which one is the provider's choice rather than yours. The reply is parsed the same way either way.
  • The events path cannot fail the run: an error inside it is logged and the agent carries on. Only a stop travels back.
  • A tool-branch error or dropped message becomes an error result fed back to the model rather than aborting the agent.
  • The tool calls of one turn run concurrently, up to maxParallelTools; the model is answered in the order it asked, whatever order the answers arrived in.
  • On refusal or maxIterations, the default flow runs; with no default configured, the block errors so the failure reaches a recovery path.
  • With skills configured, the agent gets an implicit load_skill(name) tool; skill bodies are rendered against the current message like any template.
  • Memory is saved best-effort (a save failure logs, it does not fail the flow); wipe a thread with clear-agent-memory.

Working memory and conversation history are not the same thing

Working memory is what the model re-reads. It is bounded by contextMaxTokens and compacted (pruned or summarized) whenever the next turn would not fit, and checkpointed during the run so an interrupted agent resumes where it was.

Conversation history is what a person re-reads. It is append-only, never compacted, and it is what the platform lists and replays. Recording it needs an agentId, because a durable record has to be stored somewhere a later edit to the flow cannot move.

Why agentId is not derived

An address is a position in a file, so naming an agent after it would mean that renaming the block, or moving it into another branch, silently destroyed every conversation stored under it. So agentId is stated by you, and history: record or userMemory: true without an agentId is a build error. Two blocks declaring the same agentId share one memory.

User memory

With userMemory: true the model is handed three tools:

ToolWhat it does
rememberStore a durable fact about this person under a short stable name. Re-using a name replaces that memory, so correcting the agent works.
forgetDrop one memory by name.
search_memoryLook through earlier conversations and stored memories for something no longer in context.

There is deliberately no recall tool. Stored memories are placed in the request every turn, and never in the transcript, where a memory corrected between runs would sit beside stale copies of itself.

What is placed there is bounded: the most recent memories up to a fixed count and byte budget, with a single oversized one clipped. The agent is told how many were left out and reaches them with search_memory. The preamble is charged against contextMaxTokens, reserved out of the budget before the conversation is fitted to what remains.

The opening turn

By default the agent's first user message hands the model the whole input body as a JSON document: Accomplish the task for this input message body: followed by the body. That is what an agent transforming a payload wants, and why the enrich-lead example below can ask for a JSON answer and get one.

A conversational agent wants the opposite. Handed a body, a model tends to reply in the shape it was given: an agent whose input body is {"message": "hello"} answers {"message": "hello"} rather than saying hello. Set input to say which part of the message is the question:

- type: ai-agent
  name: support-chat
  connector: claude
  stream: true
  input: >-
    body.message + "\n\n[context: page=" + body.page + "]"
  prompt: >
    Answer the user's question in Markdown prose.
  tools: [...]

The expression is evaluated against the message like any other, so toJson(...) can fold structured context in. Leave input unset and the body-document framing applies.

- type: ai-agent
  name: enrich-lead
  connector: claude
  maxIterations: 6
  memoryThreadId: body.threadId        # optional per-thread memory
  prompt: >
    Enrich the inbound lead. Classify the company size and decide whether it
    is a good fit. Respond with a JSON object {"tier": "...", "fit": true|false}.
  guardrail: >
    If you cannot classify the lead with confidence, take the default path.
  tools:
    - name: classify_size
      description: Classify a company's size tier from its name and domain.
      inputSchema: |
        {"type": "object", "required": ["company"],
         "properties": {"company": {"type": "string"}, "domain": {"type": "string"}}}
      process:
        - type: set-payload
          settings:
            value: '{"tier": "smb"}'
  default:
    process:
      - type: set-payload
        settings:
          value: '{"status": "needs-manual-review"}'

ai-router

Asks an LLM to pick exactly one of its named, described routes for each message. The model gets read-only inspection tools (get_body, list_variables, get_variable) plus a select_route decision tool, and may inspect for up to 5 turns before the guardrail is taken.

KeyTypeRequiredDefaultDescription
connectorstringYesnoneName of the LLM connector that picks the route.
promptstringYesnoneRouting instruction.
guardrailstringNononeDescribes when to fall back to the default path (low confidence, ambiguity).
routeslistYesnoneNamed branches; each entry is {name, description, process} (all required). Names must be unique.
defaultflowNononeGuardrail path, run when the model is not confident or never decides. Omitted: the message passes through unchanged (mirroring switch).
- type: ai-router
  name: triage-ticket
  connector: claude
  prompt: >
    Read the support ticket in the message body and route it to the team
    best suited to handle it.
  guardrail: >
    If the ticket is ambiguous or you are not confident, take the default path.
  routes:
    - name: billing
      description: Payment failures, refunds, invoices, subscription changes.
      process:
        - type: set-payload
          settings: { value: '{"team": "billing"}' }
    - name: technical
      description: Bugs, outages, API errors, integration problems.
      process:
        - type: set-payload
          settings: { value: '{"team": "technical"}' }
  default:
    process:
      - type: set-payload
        settings: { value: '{"team": "human-triage"}' }

ai-mapping

Leaf block: reshapes the message body to a target shape described by a prompt, optional input/output examples, and an optional output JSON Schema. The model's JSON response replaces the body.

SettingTypeRequiredDefaultDescription
connectorstringYesnoneName of the LLM connector to use.
promptstringYesnoneInstruction describing how to reshape the body.
inputexpression (string)NononeWhat the model is handed, replacing the default — the whole body as a JSON document. Needed only to send attachments; see attachments.
attachmentsexpression (list)NononeFiles sent alongside the input, in the same shape ai-agent takes. Requires input, and a connector whose model reads media.
responseMediastringNononeMessage variable the files the model produced are written to. Empty writes nothing.
inputExampleJSONNononeExample input payload that guides recognition. Written as an inline JSON string (block scalar) or a native YAML map.
outputExampleJSONNononeExample output payload that shapes the result. Same formats.
outputSchemaJSONNononeJSON Schema the result is validated against; also recorded on the message as its body schema.
maxTokensintNo0 (connector default)Response token cap for this call.

Errors: the block errors when the model's response is not valid JSON or fails outputSchema validation, so it composes with ai-retry, handle-errors, or the flow-level error path. A malformed schema or example fails at startup.

- type: ai-mapping
  name: normalize
  settings:
    connector: claude
    prompt: >
      Map the raw contact payload to our canonical contact shape. Split the
      full name into firstName and lastName. Lowercase the email.
    outputSchema: |
      {"type": "object", "required": ["firstName", "lastName", "email"],
       "properties": {"firstName": {"type": "string"}, "lastName": {"type": "string"},
                      "email": {"type": "string"}, "phone": {"type": "string"}}}

ai-embed

Leaf block: turns text into one or more embedding vectors and stores them in a variable. Unlike ai-mapping it never touches the message body; the vector is auxiliary, alongside whatever the body already holds.

SettingTypeRequiredDefaultDescription
connectorstringYesnoneName of the LLM connector to embed with. Its provider must serve embeddings (type llm-openai, llm-gemini or llm-openrouter); naming an llm-anthropic connector fails at startup.
textexpression (string or list of strings)YesnoneText to embed. A string embeds singly; a list embeds as one batch call. The shape decides the result shape (below).
modelstringYesnoneProvider-specific embedding model id (e.g. text-embedding-3-small, gemini-embedding-001).
dimensionsintNomodel defaultRequests a shortened output embedding, on models that support it (OpenAI text-embedding-3-*, newer Gemini embedding models).
resultVarstringNoembeddingVariable the result is stored in.

Result shape: when text evaluates to a string, resultVar holds a single vector (a list of numbers). When it evaluates to a list of strings, resultVar holds a list of vectors, one per input, in order.

- type: ai-embed
  name: embed-question
  settings:
    connector: openai-embeddings
    text: body.question
    model: text-embedding-3-small
    dimensions: 1024
    resultVar: questionVector

Batch form, embedding several chunks in one call:

- type: ai-embed
  name: embed-chunks
  settings:
    connector: openai-embeddings
    text: body.chunks.map(c, c.text)
    model: text-embedding-3-small
    resultVar: chunkVectors

ai-retry

Protects a process chain with an LLM-driven retry loop. When the chain fails, the model inspects the error (vars.error) and the current message, revises the body and variables, and the chain re-runs, up to maxAttempts revisions. When attempts are exhausted, the error chain runs (with vars.error set); with no error chain, the last error propagates.

KeyTypeRequiredDefaultDescription
connectorstringYesnoneName of the LLM connector that revises the message between attempts.
promptstringYesnoneInstruction for repairing the message from vars.error before a retry.
maxAttemptsintNo3How many times to revise and re-run the process chain.
processblock listYesnoneThe protected chain, re-run after each revision.
errorblock listNononeRuns when attempts are exhausted; reads vars.error.

vars.error has the shape {message, flow, block} as with handle-errors. If the model cannot produce a revision, the loop ends early and falls through to the error chain, or the error propagates.

- type: ai-retry
  name: resilient-charge
  connector: claude
  maxAttempts: 3
  prompt: >
    A step failed building the charge request. Inspect vars.error and the
    message body, correct the body, and produce a revised message to retry.
  process:
    - type: ai-mapping
      name: build-charge
      settings:
        connector: claude
        prompt: "Build a Stripe charge request from the order."
        outputSchema: |
          {"type": "object", "required": ["amount", "currency"],
           "properties": {"amount": {"type": "integer"}, "currency": {"type": "string"}}}
  error:
    - type: set-payload
      settings:
        value: '{"status": "degraded", "reason": vars.error.message}'

Tool calls run together

A model that wants three lookups asks for all three in one turn. The agent runs them concurrently — a queue of the turn's calls drained by maxParallelTools consumers (ten by default) — so the turn costs the slowest call rather than the sum of them.

Each call runs on its own copy of the message. That is what makes running them at the same time safe, and it is the whole of the trade:

  • A variable a tool branch sets is not visible to the other calls of that turn, and does not survive to the next one. Across turns nothing changed: the model's own answer, and each tool's result, are what carry forward.
  • A copy is a copy of the message, not of everything a variable points at. Two branches handed the same nested value in a variable and mutating it in place still race — the same limit fork has. Have a tool return its finding rather than write into something shared.
  • A branch that requests stop still halts the run. The flag is collected back out of the copies deliberately, because a branch stopping the run means the run.
  • The authorization gate still runs in call order, before anything is dispatched. Two gated calls in one turn ask a person one after the other, not both at once.

Set maxParallelTools: 1 to put the sequential loop back, which is the right answer for an agent whose tools deliberately hand each other state through the message.

What a tool branch is told

A tool's branch runs on the agent's own message, so it sees the variables the run carries. Three describe the call it is inside:

VariableWhat it is
vars.toolScopeA scope minted for this branch: stable for as long as the conversation the call belongs to (or the run, when the agent is stateless), and distinct per tool. Two calls to one tool in one conversation get the same scope; two tools never do.
vars.toolNameThe tool being run.
vars.toolCallIdThe provider's id for this one call: unique per call, and the same id the trace and the panel's tool chip carry.

Nothing here is about what a branch contains. An object-write keying its state, a rest call wanting an idempotency key, a cli-run naming a scratch file, and a nested ai-agent all read the same three variables and decide for themselves.

The nested case is why toolScope is minted for the branch: an ai-agent in another agent's tool slot needs a conversation of its own, and the only other identity available to it is the caller's, which is how two agents end up sharing one transcript. memoryThreadId: vars.toolScope with memoryVolatile: true gives a specialist a conversation that survives from one delegation to the next and costs nothing when it is lost.

clear-agent-memory

Leaf block: erases an ai-agent conversation thread's stored memory by its thread id. The clear is idempotent (a missing thread is not an error) and the message passes through unchanged.

SettingTypeRequiredDefaultDescription
threadIdexpression (string)YesnoneEvaluated to the thread id whose memory is cleared; matches the agent's memoryThreadId value. Both KV tiers are cleared, so a thread written by an agent with memoryVolatile is reached without saying so here.
agentIdstringNononeThe agent whose conversation is erased, matching its agentId. Required to reach a conversation in first-class memory, which is keyed by agent as well as thread; its working memory, its recorded turns and its thread row all go. Omit it for an agent that declares no agentId.
- name: forget
  process:
    - type: clear-agent-memory
      name: wipe-thread
      settings:
        threadId: body.threadId
    - type: set-payload
      settings:
        value: '{"cleared": true}'

mcp-router

Turns a flow into a stateless MCP server. It sits behind an HTTP source: each request body is one MCP JSON-RPC request and the router's output body is the JSON-RPC response, which the source returns. It advertises tool flows as MCP tools, template resources as MCP resources and prompts, and routes tools/call to the matching flow. It calls no LLM; it is a protocol adapter.

KeyTypeRequiredDefaultDescription
serverNamestringNoblock name, then octo-mcpName reported in the MCP initialize response.
toolslistNo*noneFlows exposed as MCP tools; same entry shape as ai-agent tools (name, description, inputSchema, process).
resourceslistNo*noneTemplate resources advertised as MCP resources and served on resources/read (below).
promptslistNo*noneTemplate resources advertised as MCP prompts and rendered on prompts/get (below). *At least one of tools, resources, or prompts is required.

Each resources entry:

FieldTypeRequiredDefaultDescription
uristringYesnoneStable id clients read by; unique.
namestringYesnoneAdvertised resource name.
descriptionstringNononeAdvertised description.
mimeTypestringNotext/plainAdvertised MIME type.
resourcestringYesnoneTemplate resource whose rendered content is returned.

Each prompts entry:

FieldTypeRequiredDefaultDescription
namestringYesnoneId clients get the prompt by; unique.
descriptionstringNononeAdvertised description.
argumentslistNononeAdvertised argument metadata: {name, description, required} per entry.
resourcestringYesnoneTemplate resource rendered as the prompt message; the supplied arguments are exposed to the template as the body (body.<arg>).

Behavior notes:

  • Handled methods: initialize, ping, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get. Anything else returns a JSON-RPC method-not-found error.
  • A request with no id is a notification: acknowledged with an empty 202 body, not answered.
  • An unknown tool or a tool-flow failure is reported as an isError tool result (an application error the client sees), not a protocol error; protocol errors (bad params, unknown resource/prompt) use JSON-RPC error responses.
  • To require OAuth on the endpoint, put jwt-validate in front; see MCP auth.
- type: mcp-router
  name: weather-mcp
  serverName: weather-tools
  tools:
    - name: forecast
      description: Return a short weather forecast for a city.
      inputSchema: |
        {"type": "object", "required": ["city"],
         "properties": {"city": {"type": "string"}}}
      process:
        - type: set-payload
          settings:
            value: '{"city": body.city, "summary": "Sunny, 24C"}'
  resources:
    - uri: octo://guide
      name: guide
      description: Operator guide for the weather tools.
      mimeType: text/markdown
      resource: guide
  prompts:
    - name: assist
      description: Prime an assistant to help a user in a given city.
      arguments:
        - name: city
          description: The user's city.
          required: true
      resource: greet

Naming a conversation

The runtime records a conversation but will not name one: naming is a judgement, and a judgement is a model call. nameThread is where that call goes.

- type: ai-agent
  agentId: support
  memoryThreadId: body.threadId
  nameThread:
    process:
      - type: ai-mapping
        settings:
          connector: llm-fast
          prompt: >-
            You are given one exchange: the question a person asked and the answer
            they got. Name the conversation it opens, in at most six words, as a
            topic rather than a sentence. If there is nothing to name, return an
            empty title.
          outputSchema:
            type: object
            properties:
              title: { type: string }
            required: [title]
  tools: [...]

ai-mapping hands the model the whole body and replaces it with the JSON that comes back, so declaring an outputSchema is how you get a shape you can rely on. A chain built on a block that answers with a string works equally well.

It runs once per conversation, on the exchange that opens it, and only for an agent with an agentId. Without one there is no durable conversation to name, so declaring the slot is a build error.

The body it receives:

Field
questionWhat was asked, before the agent replaced the body with its answer
answerWhat the agent answered, rendered as prose even when it decoded as JSON. Empty when the run ended without one
threadKey, agentId, userIdWhich conversation this is. Context for the prompt; the chain does no writing

Two shapes are read back: the string a transform or a set-payload leaves in the body, or the {"title": "..."} map an ai-mapping produces. An empty title, in either shape, names nothing.

A chain that fails is treated differently from one that answered emptily: the failure is logged and the title falls back to the opening question truncated to 80 characters.

The engine does the writing. It already holds the conversation's reference, and the memory store records the title on whichever tier it is: standalone writes it into the thread's thread.json, the platform calls the orchestrator.

An agent with no nameThread still gets a title: the opening question, truncated, so a list has something to show.

On this page