Skip to content
MANTYX.IO

Wire protocol — messaging & data structures

This is the SDK-builder reference for the messaging layer that sits on top of the HTTP / SSE endpoints documented in agent-runs-protocol.md. It catalogs every message shape MANTYX and a client SDK exchange during an agent run, in the order they flow on the wire, and pins down the resolved data structures the SDK is expected to ship for client-resolved (*_local) tools.

If you’re just looking for HTTP routes, auth, body shapes, or session semantics, start with agent-runs-protocol.md. If you’re writing or maintaining an SDK and want to know exactly what a local_tool_call event looks like for mcp_local, you’re in the right place.

Authentication. Every example below uses Authorization: Bearer <api-key> for brevity. The same header also accepts a MANTYX OAuth 2.0 access token (mantyx_at_…) — the server resolves either kind by token-prefix, so SDKs only need a single credential code path. OAuth tokens additionally enforce per-route scopes (runs:read, runs:write, sessions:read, sessions:write, models:read, mantyx.identity:read); see §2 of agent-runs-protocol.md for the per-endpoint scope table and docs/oauth.md for the registration / Authorization Code

  • PKCE flow.

Stability. Field names listed in bold are part of the documented stable surface. Any other fields are passed through verbatim and survive round-trips, but their semantics are not contractually guaranteed. The server uses Zod with passthrough for all *_local resolved-content blobs (Agent Card, MCP Tool[], server Implementation) so future spec additions flow through without a server-side schema bump.


Term Meaning
MANTYX The agent operating system server (this repo). Owns LLM orchestration, tool execution for server-resolved tools, persistence.
SDK Anything calling the public agent-runs API — typically @mantyx/ts-sdk, but also other-language SDKs and direct HTTP clients.
Agent run A single LLM execution. Streams events; ends with a terminal result / error / cancelled.
Spec The JSON object describing what the run does — model, prompt, tools, budgets, optional reasoningLevel. Sent in the POST /agent-runs (or .../messages) body.
Tool ref One entry in spec.tools[]. A discriminated union keyed by kind.
Server-resolved A tool MANTYX executes itself (mantyx, mantyx_plugin, a2a, mcp). The SDK only sees informational tool_result events.
Client-resolved A tool the SDK executes (local, a2a_local, mcp_local). MANTYX emits local_tool_call, the SDK does the work, the SDK posts back to .../tool-results.
Resolution The act of turning an external resource (A2A peer, MCP server) into a self-contained JSON document the model can reason about. For *_local kinds, resolution is the SDK’s responsibility.

SDK MANTYX
│ │
│ ── (resolve A2A cards / MCP catalogs ──────▶ │ (offline, SDK-side)
│ locally; cache as needed) │
│ │
│ ── POST /agent-runs ─────────────────────▶ │
│ body: spec (model, prompt, tools, …) │
│ │
│ ◀────────────────────── 201 { runId, │
│ streamUrl } │
│ │
│ ── GET streamUrl (text/event-stream) ────▶ │
│ │
│ ┌─────────────────────────────────┤ ───┐
│ ◀ SSE event│ assistant_delta │ │
│ ◀ SSE event│ thinking_delta (iff reasoning) │ │ model loop
│ ◀ SSE event│ tool_result (server-resolved) │ │
│ ◀ SSE event│ local_tool_call ◀──┐ │ │
│ └────────────────────┼────────────┤ ───┘
│ │ │
│ ── POST .../tool-results ───────┘──────────▶ │
│ { toolUseId, result | error } │
│ │
│ ◀ SSE event result (terminal) │
│ │
│ (close stream) │

The lifecycle has three logical stages:

  1. Setup (SDK-only). For any *_local tool the SDK plans to expose, it pre-resolves the resource locally. For a2a_local that means fetching or constructing the peer’s Agent Card JSON. For mcp_local that means speaking MCP Initialize + tools/list and capturing the result. All resolved content is shipped inline inside the spec; nothing is fetched server-side.
  2. Run (MANTYX-driven). The SDK opens the SSE stream and listens. MANTYX runs the LLM loop, executing server-resolved tools itself and emitting local_tool_call for client-resolved ones. The SDK answers each local_tool_call by posting back a tool result.
  3. Termination. A terminal result (success), error (failure), or cancelled event closes the run. The SSE stream is then safe to close.

POST /api/v1/workspaces/{slug}/agent-runs accepts the spec body. Only the new wire-protocol additions are documented in detail here; for the full spec body shape (system prompt, tool budgets, sessions, agentId short-circuit, etc.) see agent-runs-protocol.md §4.

{
"modelId": "openai:gpt-5.5",
"systemPrompt": "...",
"prompt": "...", // OR "messages": [...]
"tools": [
/* tool refs — see §3 */
],
"reasoningLevel": "medium", // optional; see §6
"budgets": { "maxToolTurns": 32 },
"outputSchema": {
// optional; see §7
"name": "weather_report", // defaults to "output"
"schema": {
/* JSON Schema */
},
},
"loopDetection": {
// optional; see §8
"consecutiveThreshold": 3,
"hardCutoffThreshold": 6,
},
"toolBudgets": {
// optional; see §8
"recall": { "maxCalls": 4 },
"hive_consult_ontology": { "maxCalls": 4 },
},
"supervisor": {
// optional; see §8.4 — platform LLM judge on ephemeral runs
"interval": 5,
"modelId": "platform:demo",
},
"plan": true, // optional; see §8.5 — in-product task plan (opt-in)
"metadata": { "customer": "acme" }, // optional, free-form k/v
}

Instead of prompt, send a messages array for multi-role input. Each entry is { role: "user" | "assistant" | "system", content, attachments? }. system messages are merged after systemPrompt; the last non-system message must be a user turn. The last user message may carry up to 20 attachments:

{
"type": "input_file",
"mimeType": "application/pdf",
"filename": "report.pdf",
"data": "<base64>", // inline bytes; total inline ≤ 5 MB per run
}
// — or —
{
"type": "input_file_url",
"url": "https://example.com/image.png", // https only; provider fetches it
"mimeType": "image/png", // optional
}

See agent-runs-protocol.md §4.0.1 for the full rules (allowed MIME types, size caps, validation errors). On session-events replay, user_message frames include attachments metadata (no bytes).

Same body shape, posted to POST /agent-sessions/:id/messages (accepts either prompt or messages, including file attachments — the messages are the new turn(s) appended to the session). The session keeps the conversation history; per-message tools, reasoningLevel, outputSchema, loopDetection, toolBudgets, supervisor, and plan replace the session’s defaults for that single run only — the next run falls back to whatever the session was created with.


Every entry in spec.tools[] is one of the seven shapes below. The resolution column is the contract that drives everything else: server means MANTYX runs the tool itself and the SDK only ever sees a tool_result event; client means MANTYX is a transport and the SDK must answer local_tool_call events.

Kind Resolution Wire-payload contract
mantyx server { id } reference to a workspace Tool row.
mantyx_plugin server { name } reference to a platform plugin tool.
local client { name, description?, parameters?, outputSchema?, longRunning? }parameters is JSON Schema (object schema with properties/required); forwarded verbatim to the LLM provider and validated against incoming tool-call args before execution. outputSchema (optional) is JSON Schema for the tool’s structured return value, surfaced to providers that accept per-tool response schemas. longRunning (optional, default false) annotates the model-facing description with a “don’t double-call while pending” hint so every provider treats the tool as long-running.
a2a server { name, agentCardUrl, headers?, contextId?, description? }.
a2a_local client { name, agentCard }resolved A2A Agent Card JSON content.
mcp server { name, url, headers?, toolFilter? }.
mcp_local client { name, serverInfo?, tools[] }resolved MCP Tool[].

The remainder of this document focuses on local, a2a_local, and mcp_local, because they’re the ones that carry SDK-defined structured content. For the wire shapes of the four server-resolved kinds, see agent-runs-protocol.md §4.

The minimal client-resolved tool: the SDK declares a name + JSON Schema and implements the handler in its own process. Useful for any tool MANTYX shouldn’t (or can’t) execute itself — file system access, on-device APIs, caller-specific business logic.

Wire shape:

{
"kind": "local",
"name": "send_email", // model-facing; /^[a-zA-Z0-9_]{1,64}$/
"description": "Send a transactional email.",
"parameters": {
// OPTIONAL; JSON Schema for args
"type": "object",
"properties": {
"to": { "type": "string", "format": "email" },
"subject": { "type": "string" },
"body": { "type": "string" },
},
"required": ["to", "subject", "body"],
"additionalProperties": false,
},
"outputSchema": {
// OPTIONAL; JSON Schema for the return value
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"],
"additionalProperties": false,
},
"longRunning": false, // OPTIONAL; default false
}

Field reference:

Field Required Notes
kind yes Discriminator literal "local".
name yes Model-facing tool name. Must match /^[a-zA-Z0-9_]{1,64}$/.
description no Free-form. When omitted the model sees an empty description (acceptable but reduces tool selection accuracy).
parameters no JSON Schema for the tool’s input. Must be an object schema (type: "object" with properties); other shapes are coerced to an empty object schema server-side. Nested constraints (array.items, enum, anyOf, …) are preserved end-to-end. Args that fail server-side validation produce a structured tool_input_invalid tool result the model can recover from instead of crashing the call.
outputSchema no JSON Schema for the structured value the tool returns. Forwarded to providers with per-tool response schemas (Gemini’s responseJsonSchema on the FunctionDeclaration); other engines surface it through the description and rely on host-side validation. The model uses it to plan follow-up arguments more reliably. Must be an object schema; non-object roots are dropped server-side (engines reject non-object roots in this position).
longRunning no When true, MANTYX appends a stable hint to the description:
“NOTE: This is a long-running operation. Do not call this tool again if it has already returned an intermediate or pending status.”
Useful for tools where a single call may yield a pending / status response and the SDK polls on its own; without the hint, models routinely fire repeat calls and waste turns. Pure declarative — MANTYX does not change scheduling.

Tool call dispatch. When the model calls a local tool, the SSE stream emits local_tool_call with kind: "local" (or omitted, for backward compatibility). The SDK runs the handler and POSTs back to .../tool-results. See §4.3.1 for the event shape.

The defining feature of a2a_local is that the SDK ships a fully-resolved A2A Agent Card as the agentCard field. MANTYX never reaches out to discover it.

Wire shape:

{
"kind": "a2a_local",
"name": "intranet_hr_agent", // model-facing; /^[a-zA-Z0-9_]{1,64}$/
"description": "...", // OPTIONAL; overrides the synthesized one
"agentCard": {
// REQUIRED; A2A Agent Card content
"protocolVersion": "0.3.0",
"name": "Acme HR",
"description": "Answers questions about HR policies and benefits.",
"url": "https://hr.intranet.acme/a2a",
"version": "1.4.0",
"provider": { "organization": "Acme Co.", "url": "https://acme.example/" },
"documentationUrl": "https://hr.intranet.acme/docs",
"iconUrl": "https://hr.intranet.acme/icon.png",
"capabilities": { "streaming": false, "pushNotifications": false },
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [
{
"id": "pto_lookup",
"name": "PTO lookup",
"description": "Find a teammate's remaining PTO days for the year.",
"tags": ["hr", "pto"],
"examples": ["How many PTO days does Alice have left?"],
},
],
"securitySchemes": {
/* spec-shaped, never read by MANTYX */
},
"security": [
/* spec-shaped, never read by MANTYX */
],
/* …any other A2A spec field passes through unchanged. */
},
}

Where the SDK obtains agentCard:

  • Well-known URL. Most peers expose the card at <peer>/.well-known/agent-card.json. The SDK can simply fetch it (with whatever auth applies on the local network).
  • Static config. For peers that don’t publish a card, hand-craft one — the spec only requires a couple of fields and the rest is all metadata.
  • Registry / cache. Cache cards locally and refresh periodically. MANTYX treats every spec submission as a fresh snapshot, so new cards take effect on the next run / message.

What MANTYX does with agentCard:

Field Used for Notes
name, description Tool description for the model Used to compose "Delegate a task to <name>: <description>" if no description override is supplied at the ref level.
skills[] (first 12) Tool description for the model Bulleted into the description so the model can choose a peer based on capability.
All other fields Echo only Forwarded back to the SDK in every local_tool_call event so the SDK can dispatch by url, by provider.organization, by protocolVersion, or whatever it indexed on.

3.3 mcp_local — SDK-resolved Tool catalog

Section titled “3.3 mcp_local — SDK-resolved Tool catalog”

The defining feature of mcp_local is that the SDK ships the verbatim output of MCP tools/list as tools[], with field names matching the MCP spec (inputSchema, not parameters). Optionally, the SDK can also ship the Implementation block from MCP Initialize as serverInfo.

Wire shape:

{
"kind": "mcp_local",
"name": "fs", // SDK-side server label; not a name prefix
"serverInfo": {
// OPTIONAL; from MCP Initialize
"name": "mcp-server-filesystem",
"version": "0.4.1",
/* …any other Implementation field passes through unchanged. */
},
"tools": [
// REQUIRED; verbatim MCP tools/list output
{
"name": "fs_read_file", // model-facing; /^[a-zA-Z0-9_]{1,64}$/; SDK owns naming
"description": "Read a file under /workspace.",
"inputSchema": {
// MCP's term for the JSON Schema
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"],
},
"annotations": {
// OPTIONAL; spec-defined hints
"readOnlyHint": true,
"openWorldHint": false,
},
/* …any other MCP Tool field passes through unchanged. */
},
],
}

Where the SDK obtains tools[]:

// pseudo-code, MCP-SDK-flavoured
const client = new McpClient(stdio("./fs-server"));
const init = await client.initialize(); // → { name, version, … }
const list = await client.listTools(); // → { tools: [...] }
// drop straight into the spec
const ref = {
kind: "mcp_local" as const,
name: "fs",
serverInfo: init,
tools: list.tools,
};

What MANTYX does with the catalog:

Field Used for Notes
tools[].name Model-facing tool name Used as-is. MANTYX does not prefix with the ref’s name. The SDK is responsible for any naming convention (e.g. emit fs_read_file instead of read_file if you have multiple servers).
tools[].description Model-facing description Used as-is.
tools[].inputSchema LLM tool-call schema Forwarded verbatim to the LLM provider as the tool’s JSON Schema, then validated against incoming tool-call args (Ajv) before execution. Nested constraints (array.items, enum, anyOf, …) are preserved end-to-end. Empty / missing schema → no-arg tool. Args that violate the schema produce a structured tool_input_invalid tool result the model can recover from instead of crashing the tool.
tools[].annotations Echo only Forwarded to the SDK in local_tool_call events (as part of the call envelope) for observability.
serverInfo Echo only Forwarded to the SDK in local_tool_call.mcpServerInfo.

Naming convention reminder. Because MANTYX doesn’t prefix names for mcp_local, two refs that both expose a tool called read_file will collide. Either give the second one a different name in the catalog or drop it via SDK-side filtering. (For mcpremote MCP — MANTYX does auto-prefix with the ref’s name, so collisions are impossible.)


The SSE stream is opened with GET /agent-runs/:runId/stream. Standard SSE rules apply: each frame is data: <json>\n\n, with an id: <seq> line so reconnects can use Last-Event-ID.

Every event payload has the same envelope:

{
"seq": 7,
"type": "<event-type>",
"data": {
/* type-specific */
},
}

The vocabulary (EphemeralEventType in bus.ts):

Type Direction Frequency Purpose
assistant_delta M → SDK Many Streamed assistant text token / chunk.
thinking_delta M → SDK Many (iff reasoningLevel > 0) Streamed extended-thinking text (provider redacts when policy requires).
tool_result M → SDK Per server-resolved tool call Informational — tells the SDK that MANTYX ran a server-resolved tool (mantyx, mantyx_plugin, a2a, mcp) and got a result. The SDK does not need to act on it.
local_tool_call M → SDK Per client-resolved tool call Action required. SDK must POST a tool-result.
local_tool_result_in M → SDK Per client-resolved tool call Informational mirror of the tool-result the SDK just posted, persisted for observability. Re-emitted to late subscribers so they can replay the conversation.
loop_detected M → SDK 0–2× per run (soft nudge + optional hard cutoff) Observability for the loop-detection guard (see §8). The server already substituted the synthetic skip + steering nudge — SDK clients render a status note (looping — nudged / looping — gave up) and otherwise leave the run alone.
tool_budget_exceeded M → SDK Per intercepted tool call Observability for per-tool call budgets (see §8). The synthetic tool_result carrying the “budget exceeded — pivot or finalize” body lands on the normal tool-result channel; this event is purely so SDK clients can surface a UI banner.
supervisor M → SDK 0–N× per run (every interval LLM calls) Run-supervisor check (see §4.7 / §8.4). Fired on every review — including on_track — so SDK clients can render supervisor activity. When the judge steers the run (redirect / finalize), the pipeline has already injected the steering message or forced a tools-disabled finalize turn.
task_plan M → SDK 0–N× per run (iff plan set) Observability for the in-product task plan (see §4.9 / §8.5). Emitted once after classify / caller-supplied plan, then again whenever the tracker advances step statuses. Non-terminal. For planOnly runs the final plan also rides on the terminal result under data.plan.
assistant_message M → SDK 1× per turn Final assistant message for the turn (concatenated, persistence-ready).
result M → SDK 1× terminal Successful completion. Carries the final assistant text and run summary.
error M → SDK 1× terminal Failure. Carries error (message), code / errorClass (category), finishReason, and an optional partialText salvage payload. See §4.7.
cancelled M → SDK 1× terminal Cancellation. Run was aborted via POST /cancel.

result, error, and cancelled are the terminal events — the SDK should close the SSE stream after one of them arrives.

{ "seq": 3, "type": "assistant_delta", "data": { "text": "Hello" } }
{ "seq": 4, "type": "thinking_delta", "data": { "text": "Considering options..." } }

thinking_delta only fires when reasoningLevel > 0 and the provider exposes reasoning (OpenAI o-series / GPT-5.x, Anthropic extended thinking, Gemini ≥ 3 with thinkingConfig.includeThoughts). Treat it as opaque progress text — it’s not part of the canonical assistant response.

{
"seq": 5,
"type": "tool_result",
"data": {
"toolUseId": "tu_a",
"name": "github_search_repos",
"result": "...", // truncated for display; never JSON-parsed by SDK
},
}

Purely informational. The SDK does not respond.

4.3 local_tool_call (client-resolved tools)

Section titled “4.3 local_tool_call (client-resolved tools)”

This is the workhorse event for SDK-implemented tools. Payload shape varies slightly by kind, but the envelope is always:

{
"seq": <int>,
"type": "local_tool_call",
"data": {
"toolUseId": "<opaque-id>", // round-trip back in the tool-result POST
"name": "<model-facing tool name>",
"args": { /* model-supplied args */ },
"kind": "<local | a2a_local | mcp_local>",
/* …kind-specific extras below… */
}
}

Older SDKs that ignore the kind discriminator can still match on name and dispatch correctly — the kind field is additive metadata.

4.3.1 kind: "local" — generic local tools

Section titled “4.3.1 kind: "local" — generic local tools”

No extras. Dispatch by name.

{
"seq": 6,
"type": "local_tool_call",
"data": {
"toolUseId": "tu_x",
"name": "compute_total",
"args": { "amount": 42, "currency": "USD" },
"kind": "local", // OR omitted (legacy)
},
}

4.3.2 kind: "a2a_local" — local A2A delegations

Section titled “4.3.2 kind: "a2a_local" — local A2A delegations”

Carries the full Agent Card echoed back from the spec, so the SDK can dispatch to the right A2A client when it manages multiple peers.

{
"seq": 7,
"type": "local_tool_call",
"data": {
"toolUseId": "tu_y",
"name": "intranet_hr_agent",
"args": { "message": "When does PTO reset?" },
"kind": "a2a_local",
"agentCard": {
// full Agent Card from the spec
"name": "Acme HR",
"url": "https://hr.intranet.acme/a2a",
"skills": [
/* ... */
],
/* ...all other fields the SDK shipped... */
},
},
}

args.message is always { "message": string } for a2a_local — the LLM’s task is reduced to “what do I want to ask the peer in plain text?” so the SDK doesn’t have to re-derive an A2A message envelope from a tool-specific schema.

4.3.3 kind: "mcp_local" — local MCP tool calls

Section titled “4.3.3 kind: "mcp_local" — local MCP tool calls”

Carries dispatch hints so the SDK can route to the right MCP client without parsing the tool name back into pieces.

{
"seq": 8,
"type": "local_tool_call",
"data": {
"toolUseId": "tu_z",
"name": "fs_read_file", // identical to what the SDK declared
"args": { "path": "/etc/hosts" },
"kind": "mcp_local",
"mcpServer": "fs", // ref's `name` — SDK's MCP-client key
"mcpToolName": "fs_read_file", // duplicates `name` for the SDK's convenience
"mcpServerInfo": {
// present iff the spec carried `serverInfo`
"name": "mcp-server-filesystem",
"version": "0.4.1",
},
},
}

The SDK’s typical dispatch path is:

const client = mcpClients.get(call.mcpServer); // by SDK label
if (!client) throw new Error(`unknown MCP server ${call.mcpServer}`);
const result = await client.callTool({
name: call.mcpToolName,
arguments: call.args,
});
const text = result.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("\n");
await fetch(`${baseUrl}/agent-runs/${runId}/tool-results`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ toolUseId: call.toolUseId, result: text }),
});
{
"seq": 12,
"type": "assistant_message",
"data": {
"text": "Here's what I found...",
"turn": 0,
"finishReason": "tool_use", // optional; canonical lowercase token
"toolCalls": [
// optional; absent when the turn was text-only
{
"id": "call_abc",
"name": "search",
"input": {
/* JSON Schema-matching args */
},
},
],
},
}
Field Type Required Notes
text string yes Full assistant text for this turn (concatenation of every preceding assistant_delta for this turn, plus any non-streaming snapshot the engine appended at close). May be empty when the turn was tool-only.
turn integer yes 0-based tool-turn index this assistant message closes. Useful for SDK clients pairing the message with the subsequent tool_result rows.
finishReason string|null no Canonical lowercase stop reason normalized across providers ("end_turn", "tool_use", "max_tokens", "refusal", "malformed_function_call", …). Pulled from the engine’s per-turn stopReason after normalization — Gemini’s MAX_TOKENS lands as "max_tokens", OpenAI’s length lands as "max_tokens", etc. null / omitted when the provider did not report one.
toolCalls array no Tool calls the model emitted on this turn (id, sanitized pipeline-side name, JSON-matching input). Omitted when the model did not call any tools.

Emission frequency. Exactly one assistant_message per completed assistant turn — including the last turn before a terminal error. SDK clients should treat this as the canonical “the model said something” anchor and avoid stitching a turn out of assistant_delta chunks themselves (deltas may be split arbitrarily for transport).

Truncation behaviour. When the run terminates with error (e.g. Gemini MAX_TOKENS while emitting outputSchema JSON), the last assistant_message preceding the error carries the partial text plus finishReason: "max_tokens". The terminal error event then carries the same text on data.partialText so reconnect / replay sees both pieces without depending on event ordering.

// soft nudge — pipeline injected a "finalize OR change strategy" user message
{ "seq": 13, "type": "loop_detected",
"data": { "consecutiveCount": 3, "hardCutoff": false, "tools": ["recall"] } }
// hard cutoff — pipeline forced a tools-disabled finalise turn
{ "seq": 27, "type": "loop_detected",
"data": { "consecutiveCount": 6, "hardCutoff": true, "tools": ["recall"] } }
Field Type Notes
consecutiveCount integer Length of the identical-batch streak that just tripped the threshold (>= consecutiveThreshold).
hardCutoff boolean false for the soft nudge round; true once the pipeline forces finalisation. The SDK may see one of each in a single run.
tools array Names of the tool calls in the looping batch (no args — those are persisted on the matching tool_result events).

Observability only: the synthetic skip + steering nudge are emitted on the normal tool_result and assistant-message channels by the time this event fires. SDK clients should render a status note (looping — nudged / looping — gave up) and otherwise leave the run alone — the run still continues to its terminal result / error / cancelled.

See §8 for the wire-spec field that controls thresholds.

{
"seq": 14,
"type": "tool_budget_exceeded",
"data": { "tool": "recall", "maxCalls": 4, "callIndex": 5 },
}
Field Type Notes
tool string Logical tool name as the model saw it (matches the key in spec.toolBudgets).
maxCalls integer Configured cap.
callIndex integer 1-based count of attempts to call this tool over the run lifetime; always strictly greater than maxCalls.

Observability only: the synthetic “budget exceeded — pivot or finalize” tool-result lands on the normal tool_result channel before this event fires, so the model already has the directive to pivot. SDK clients use this event to render UI banners (memory budget exhausted, etc.) without re-parsing tool-result bodies.

See §8 for the wire-spec field that defines budgets.

// on_track — the judge reviewed the run and decided not to intervene
{ "seq": 15, "type": "supervisor",
"data": { "action": "on_track", "reason": "Agent is gathering context via search before answering.", "llmCalls": 5 } }
// redirect — a steering user message was injected; the agent keeps its tools
{ "seq": 20, "type": "supervisor",
"data": { "action": "redirect", "reason": "Repeating the same search with identical args.", "redirect": "Stop re-querying; synthesize an answer from the results you already have.", "llmCalls": 10 } }
// finalize — the run was forced to wrap up on a tools-disabled turn
{ "seq": 25, "type": "supervisor",
"data": { "action": "finalize", "reason": "Enough evidence to answer; further tool use is unlikely to help.", "llmCalls": 15 } }
// reasoning — a mid-turn review fired while the agent was still thinking
{ "seq": 18, "type": "supervisor",
"data": { "action": "redirect", "phase": "reasoning", "reason": "Overthinking; looping on the same edge case.", "redirect": "Commit to the straightforward approach and answer.", "llmCalls": 7 } }
Field Type Notes
action string One of "on_track", "redirect", "finalize".
reason string One- or two-sentence explanation from the judge.
redirect string Present when action === "redirect": the steering message injected into the conversation (same text the agent sees as a user message). Omitted for on_track / finalize.
llmCalls integer Number of LLM calls (completeTurn invocations) completed when this review fired. Matches the pipeline’s modelInvocations counter at the check boundary.
phase string Optional. "turn_boundary" (default cadence review) or "reasoning" (mid-turn review fired while a long reasoning span was still streaming). Absent is equivalent to "turn_boundary".
model object Optional. Resolved judge model ({ id, provider, vendorModelId }) — same shape as terminal result.model (§4.7.1). Present on platform-hosted runs for cost attribution and debug UIs.

Observability for the run-supervisor guard (see §8.4). The event fires on every check, not only when the judge intervenes — on_track reviews are included so SDK clients can show “supervisor reviewed” activity without inferring it from missing events.

When action is redirect or finalize, the pipeline has already applied the verdict by the time this event arrives: a steering user message was appended (redirect) or the next turn was forced tools-disabled (finalize). SDK clients should render a status note and not try to steer the run themselves.

Pass "supervisor": true or "supervisor": {} in the spec (§8.4) to enable the platform judge for a run. Omission (or "supervisor": false) keeps the supervisor off on ephemeral API runs.

// Every terminal `result` and `error` event also carries `tokens`, `turns`,
// and `model` for cost attribution and dashboards — see §4.7.1.
{ "seq": 14, "type": "result", "data": {
"ok": true,
"text": "...",
"tokens": { "inputTokens": 1283, "cachedTokens": 512, "reasoningTokens": 96, "outputTokens": 240 },
"turns": 3,
"model": { "id": "platform:demo", "provider": "openai", "vendorModelId": "gpt-5.4-mini", "reasoningEffort": "low" }
} }
{ "seq": 14, "type": "error", "data": {
"error": "Model output was truncated (stop_reason=max_tokens). …",
"code": "truncation", // mirrors `errorClass`; legacy alias
"errorClass": "truncation", // canonical category (see below)
"finishReason": "max_tokens", // canonical lowercase stop reason
"partialText": "{\n \"answer\":… (truncated JSON) …",
"retryable": false, // optional; per-class retry hint
"tokens": { "inputTokens": 8190, "cachedTokens": 0, "reasoningTokens": 0, "outputTokens": 1024 },
"turns": 1,
"model": { "id": "provider:cmf…", "provider": "google", "vendorModelId": "gemini-2.5-pro" }
} }
{ "seq": 14, "type": "cancelled", "data": { "reason": "user" } }

After one of these arrives, no further events will be emitted; close the SSE stream.

error event payload fields. The runner enriches the error event with structured triage attributes when the failure carried a salvage path (typically truncation, upstream deadline, or max-budget-with-text):

Field Type Required Notes
error string yes Human-readable message (also persisted on EphemeralAgentRun.error).
code string yes Legacy alias for errorClass. Equals errorClass when present; otherwise a small lowercase token ("error", "invalid_spec", "worker_error", …) the SDK can switch on.
errorClass string no Canonical category. One of "rate_limit", "overloaded", "server", "context_window" (input too big), "truncation" (output budget exhausted), "invalid_request", "auth", "timeout", "local_timeout", "upstream_deadline", "unknown". New categories may land additively.
finishReason string|null no Canonical lowercase stop reason normalized across providers ("max_tokens", "refusal", "malformed_function_call", …). When present, mirrors the value on the last assistant_message.
partialText string no Best-effort raw bytes the model emitted before the failure. For outputSchema runs this is likely incomplete JSON that will fail JSON.parse — see §7 below. Also persisted on EphemeralAgentRun.finalText so the Calls UI can render it alongside a truncation banner.
retryable boolean no Coarse retry hint inherited from the pipeline’s error classifier. Informational; the SDK still owns the actual retry decision.

When errorClass is "truncation", the EphemeralAgentRun row that the SDK can re-fetch via GET /agent-runs/:runId will have:

Field Value
status "failed"
finalText Same string as data.partialText (so SDKs can ignore the SSE stream and still recover the salvage).
error Same string as data.error.
failureReason { "errorClass": "truncation", "finishReason": "max_tokens" } (JSON object, future-proof for additional triage fields).

4.8.1 Cost-attribution fields (tokens, turns, model)

Section titled “4.8.1 Cost-attribution fields (tokens, turns, model)”

Every terminal result and error event carries three additional fields so callers can drive cost dashboards, per-turn budgets, and provider/model spend reports without a follow-up GET /agent-runs/:runId round trip. The same fields are persisted on the EphemeralAgentRun row (columns tokens / turns / model) and surfaced by that endpoint.

Field Type Notes
tokens object Per-run token totals aggregated across every model invocation. Schema below.
turns int Total engine.completeTurn(...) invocations for the run, including the failing call when a run errors out mid-loop. A single-shot run reports 1; a tool loop is >= 2. Tracked by the pipeline as modelInvocations in PipelineLoopState and emitted on the terminal PipelineEvent (see packages/agent-pipeline/src/types.ts). Distinct from “tool turns” — turns counts model invocations, regardless of whether the model called any tools.
model object Resolved model that actually executed the run. Schema below.

Always present on terminal events for runs created against MANTYX ≥ 2026-09 servers. Older servers omit these fields entirely; SDK clients (TS/Go/Python) detect “no usage data” by checking that model.provider is empty / falsy. JSON keys follow MANTYX’s standard camelCase wire convention.

tokens schema — mirrors the wire shape produced by tokenUsageToWireTokens in packages/ts-sdk/src/usage-wire.ts, which is the single source of truth across the TS SDK return value, REST/SSE, and A2A surfaces:

Field Type Notes
inputTokens int Total billable input — fresh prompt tokens plus the cached-read slice the provider still bills (at a discount) plus any cache-creation tokens plus tool-prompt tokens. Equal to the sum of every provider-reported input bucket for the run.
cachedTokens int The discounted slice of inputTokens that came from a prompt cache hit (Anthropic prompt caching, OpenAI cached prompt, Gemini implicit cache). 0 when the provider doesn’t report cache reads or the run didn’t hit cache.
reasoningTokens int Non-visible thinking tokens. Already counted inside outputTokens — surfaced separately so dashboards can break out “thinking cost” vs visible output. 0 when the model didn’t reason or didn’t report it.
outputTokens int All tokens the model emitted for this run, visible + reasoning. Matches the provider’s “completion tokens” / “output tokens” billing line.

inputTokens and outputTokens together cover every billable token the run consumed; cachedTokens and reasoningTokens are diagnostic breakdowns inside those two totals (not separate buckets to be added). All four are clamped to non-negative integers — a misbehaving engine emitting NaN or negatives cannot poison the JSON snapshot or Prisma write.

model schema — fields the platform stamps onto every successful or failed run via services/agent-runs/resolve-model.ts:

Field Type Notes
id string Catalog id — the same string a caller would pass back as modelId (in §2.1) to re-select this exact entry (e.g. "platform:demo", "provider:cmf…"). Empty string against legacy fallbacks that didn’t synthesise a catalog id.
provider string Lowercase provider id: "openai", "anthropic", "google", "azure-openai".
vendorModelId string The model id the platform actually sent to the provider (e.g. "gpt-5.4-mini", "claude-opus-4-7", "gemini-2.5-pro"). Carried through from the model field on AgentSpec after resolution.
reasoningEffort string Optional. "off", "low", "medium", "high". Computed via resolveReasoningEffortForOptions (packages/ts-sdk/src/usage-wire.ts) from the unified 0–100 reasoningLevel knob: 0 → "off", 1–35 → "low", 36–65 → "medium", 66–100 → "high". Omitted when the provider doesn’t expose a reasoning-level knob or the run didn’t request one.

Per-provider token mapping. Provider responses vary in how they report token usage. MANTYX normalises them into the wire shape above as follows (see packages/agent-pipeline/src/engines/* for the engine- side aggregation that feeds tokenUsageToWireTokens):

Provider inputTokens cachedTokens reasoningTokens outputTokens
OpenAI usage.prompt_tokens (already includes cached read tokens) usage.prompt_tokens_details.cached_tokens usage.completion_tokens_details.reasoning_tokens usage.completion_tokens
Anthropic usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens usage.cache_read_input_tokens (extended-thinking tokens; folded into output_tokens by the provider) usage.output_tokens
Google usageMetadata.promptTokenCount + usageMetadata.cachedContentTokenCount + tool-prompt tokens usageMetadata.cachedContentTokenCount usageMetadata.thoughtsTokenCount usageMetadata.candidatesTokenCount (or totalTokenCount - promptTokenCount for older Gemini SDKs)

If a provider doesn’t report a given bucket the corresponding field is 0, never null.

Tool-loop accounting. When the run executes tool turns, every engine.completeTurn(...) invocation contributes its usage to the aggregated tokens object — so a run with one tool round (model → tool → model) reports turns: 2 and the sum of both model calls’ token usage. The counter is incremented in a try/finally around the engine call inside runMainPipelineLoop (packages/agent-pipeline/src/pipeline.ts), so the failing call still counts toward turns even when the engine throws. The terminal event carries cumulative totals only; per-turn observability lives on assistant_message events.

A2A exposure. The MANTYX-hosted A2A endpoint (POST /api/a2a/{workspaceSlug}/agents/{agentSlug}) returns the same triple under result.metadata.mantyx. The block is omitted entirely against legacy runners that haven’t implemented the optional runWithUsage method on AgentRunner (see packages/ts-sdk/src/a2a/adapter.ts); cross-platform A2A clients should treat its absence as “no usage data” rather than as zero usage.

SDK return-value exposure. The TS SDK exposes the same triple via the opt-in runAgentWithUsage (returning a RunAgentResult with text, tokens, turns, model). The legacy runAgent still returns just string for backward compatibility — see packages/ts-sdk/src/run.ts. Go and Python SDKs surface the fields directly on the existing RunResult struct/dataclass (additive, non-breaking since those return types were already objects).

// First emission — right after the pre-flight classifier (or a caller-supplied
// plan). The first step is marked in_progress for executed runs.
{ "seq": 4, "type": "task_plan",
"data": { "brief": "Compare Q3 vs Q4 revenue and summarize drivers.",
"steps": [ { "title": "Pull Q3 and Q4 revenue", "status": "in_progress" },
{ "title": "Compute deltas by segment", "status": "pending" },
{ "title": "Summarize the top drivers", "status": "pending" } ] } }
// Tracker advance — emitted whenever step statuses change during the run.
{ "seq": 18, "type": "task_plan",
"data": { "brief": "Compare Q3 vs Q4 revenue and summarize drivers.",
"steps": [ { "title": "Pull Q3 and Q4 revenue", "status": "done" },
{ "title": "Compute deltas by segment", "status": "in_progress" },
{ "title": "Summarize the top drivers", "status": "pending" } ] } }
Field Type Notes
brief string Optional one-line summary of the overall objective.
steps array Ordered checklist. Each { title, status } where status is "pending" | "in_progress" | "done".

Observability for the in-product task plan (see §8.5). Emitted only when the run carries a plan spec field. Non-terminal — SDK clients render a live checklist that advances as the run progresses; the terminal result stays text-only for executed runs. For planOnly runs the run terminates right after the plan and the final checklist also rides on the terminal result under data.plan (§8.5).


When the SDK sees a local_tool_call, it owes MANTYX exactly one tool-result POST (success or failure):

POST /api/v1/workspaces/{slug}/agent-runs/{runId}/tool-results
Content-Type: application/json
Authorization: Bearer <api-key>
{
"toolUseId": "tu_z", // copied from local_tool_call
"result": "<file contents>", // OR "error": "..." (mutually exclusive)
"files": [ // optional; only with `result`
{ "filename": "chart.png", "mimeType": "image/png", "data": "<base64>" }
]
}
Field Type Required Notes
toolUseId string yes Must match a pending local_tool_call’s id.
result string one-of Successful textual result (≤ 2 MB). For MCP tools, flatten content blocks to text. For A2A delegations, the peer’s reply text.
error string one-of Human-readable failure message (≤ 8 KB). Surfaced to the model so it can recover.
files array no Files the client-resolved tool produced, surfaced to the model on the next turn as native file parts (Anthropic / Gemini / Bedrock inside tool_result; OpenAI as a synthetic follow-up user turn). Each entry is { filename, mimeType, data } with data base64 (no data-URL prefix). Only honored alongside result; ignored when error is set. mimeType must be an allowed attachment type. Up to 20 files; combined decoded bytes are capped (currently 5 MB), and large files are persisted to object storage and forwarded by reference. For bigger artifacts, upload out of band and reference a URL in result.

Server response codes:

Code When
204 Accepted; the runner was woken and will resume the model loop.
400 Body failed Zod validation (missing toolUseId, both/neither of result/error, etc.).
404 unknown_tool_usetoolUseId doesn’t match any pending call (already answered or unknown id).
409 run_terminal — the run already finished (success, failure, cancel, or local-tool timeout). The result is dropped.

The runner enforces a per-call localToolTimeoutMs (default 5 minutes). After timeout the model loop unblocks with a synthetic “Timed out waiting for local tool result” error — which is also why a 409 run_terminal for a tool-result POST is a normal occurrence.


spec.reasoningLevel controls the LLM’s extended-thinking effort. Two input shapes are accepted; both map to a numeric 0–100 internally.

Form Values Notes
String "off", "low", "medium", "high" Snaps to 0, 30, 50, 80 (matches the web composer).
Number integer 0100 Pass-through. 0 explicitly disables provider thinking.

Per provider:

Provider Knob driven by reasoningLevel
OpenAI Responses (o-series, GPT-5.x) reasoning.effort
Gemini ≥ 3 thinkingConfig.thinkingLevel
Gemini ≤ 2.5 thinkingConfig.thinkingBudget (token budget; scaled)
Anthropic / Bedrock-Anthropic extended thinking budget (≈ 512 tokens at low → ≈ 8 000 at high)
xAI Grok, others ignored

When reasoningLevel > 0 and the provider supports it, the SSE stream will include thinking_delta events alongside assistant_delta.


outputSchema constrains the final assistant message to a JSON document conforming to a JSON Schema. When set, the run’s terminal result event still carries the reply as data.text: string, but that string is guaranteed-parseable JSON matching the supplied schema.

"outputSchema": {
"name": "weather_report", // optional; default "output"; /^[a-zA-Z0-9_-]{1,64}$/
"schema": { /* JSON Schema */ } // required, root must be a JSON object
}
Field Type Required Notes
name string no Stable identifier passed to providers (OpenAI text.format.name, Anthropic synthetic-tool name). Defaults to "output".
schema object yes JSON Schema for the assistant text. Root must be a JSON object — most providers reject array/scalar roots in structured-output mode. Passed through verbatim; MANTYX does not validate the schema’s contents.

Per provider:

Provider How the schema is enforced
OpenAI Responses (o-series, GPT-5.x, …) text.format = { type: "json_schema", strict: true, name, schema } on every completeTurn (compatible with tool calls).
Gemini 3+ (any turn) responseMimeType: "application/json" + responseJsonSchema on every completeTurn. Gemini 3 accepts the schema alongside functionDeclarations.
Gemini ≤ 2.5 with no tools Same as Gemini 3+: responseMimeType: "application/json" + responseJsonSchema.
Gemini ≤ 2.5 with tools Synthetic set_model_response function declaration is injected; its parametersJsonSchema is the supplied schema. The system instruction is augmented to direct the model to call this tool with the final answer. The engine intercepts the call, hides it from the SDK, and surfaces the call’s arguments as the assistant text (JSON-stringified). Sidesteps the API rejection (“Function calling with a response mime type: ‘application/json’ is unsupported”) without round-tripping a 4xx.
Anthropic / Bedrock-Anthropic Synthetic final_report tool whose input_schema is the supplied schema; tool_choice is forced on the no-tools finishing turn. The tool’s input is surfaced as the assistant text.
xAI Grok, others Ignored — the model returns plain text.

The synthetic-tool paths (Gemini 2.5 + tools, Anthropic) are entirely internal: the SDK still receives data.text: string on the terminal result event and never sees a local_tool_call for set_model_response or final_report. They never appear in the tools array the SDK declared.

Validation (server-side, 400 invalid_request on violation):

Constraint Limit
Serialized JSON size of outputSchema ≤ 32 KB
name regex /^[a-zA-Z0-9_-]{1,64}$/
schema shape non-null, non-array JSON object

SDK guidance. Even though the server enforces JSON shape via the provider, transient model errors (refusal text, truncation under max_tokens pressure, exotic Unicode normalisation) can still produce a string that fails to JSON.parse in rare cases. Reference SDKs should:

  1. Pass the schema through unchanged from the developer’s API.
  2. JSON.parse the terminal result.data.text.
  3. Re-validate against their source-of-truth Zod / Pydantic / JSON Schema validator and surface a typed parse error instead of crashing.

Truncation contract. When the model is mid-JSON and Gemini / Anthropic / OpenAI hit the output budget, MANTYX does not discard the bytes that already streamed. Instead:

  1. The last assistant_message for the turn (§4.4) carries the partial text plus finishReason: "max_tokens".
  2. The terminal SSE event is an error (not result) with errorClass: "truncation" and data.partialText set to the same bytes (§4.7).
  3. The run row exposes the salvage on GET /agent-runs/:runId as { status: "failed", finalText: "<partial JSON>", error: "Model output was truncated …", failureReason: { errorClass: "truncation", finishReason: "max_tokens" } }.

partialText is a best-effort raw byte sequence — for outputSchema runs it will almost always fail JSON.parse because the JSON object was not closed. SDKs should treat it as diagnostic data, never as a schema-conformant reply. Surfacing it (as a “truncated reply — JSON likely incomplete” status note) is the recommended pattern; silently falling back to it as the answer is not.

outputSchema works for both ephemeral runs (systemPrompt-defined) and agentId-backed runs — the runner applies the schema to whichever AgentSpec it built. outputSchema is independent of reasoningLevel: the model can think extensively and emit JSON.


8. Run guards (loopDetection, toolBudgets)

Section titled “8. Run guards (loopDetection, toolBudgets)”

Two opt-in (default-on) fields on the spec body govern how MANTYX guards against tight tool loops and runaway research-tool usage. Both are additive over the wire — older SDKs that don’t ship them keep working, and the runtime defaults still apply server-side.

The pipeline tracks an order-invariant canonical signature for every assistant turn that emits one or more tool calls. When the same signature repeats consecutively the guard intervenes:

Trigger Server action
consecutiveThreshold identical batches in a row Skip the duplicate batch with a synthetic “you’ve made this exact call before” tool result, prepend a user-style steering nudge (“either deliver a final answer or change strategy”) before the next model turn.
hardCutoffThreshold identical batches in a row Force a tools-disabled finalise turn (same path as budgets.maxToolTurnsExceeded: "finalize") so the run lands cleanly.
"loopDetection": {
"consecutiveThreshold": 3, // optional, default 3 — fires the steering nudge
"hardCutoffThreshold": 6 // optional, default 6 — forces finalisation
}
// or:
"loopDetection": false // explicitly disable for this run
Field Type Notes
consecutiveThreshold integer ≥ 2 Default 3. Single batch = single tool call, not a loop, so the floor is 2.
hardCutoffThreshold integer ≥ 3 Default 6. Must be strictly greater than consecutiveThreshold (otherwise the soft nudge never gets a chance).
(top-level false) literal false Disables the guard. budgets.maxToolTurns still applies.

Validation (server-side, 400 invalid_request on violation): both thresholds capped at 100; hardCutoffThreshold must exceed consecutiveThreshold.

The runtime default — applied when the field is omitted — is { consecutiveThreshold: 3, hardCutoffThreshold: 6 }. SDK-driven runs and platform-driven runs inherit identical defaults.

Per-tool call caps enforced over the lifetime of the run (across every LLM turn). Calls under the cap run normally; calls past the cap are intercepted before execution and the model receives a synthetic “budget exceeded — pivot or finalize” tool result. The model stays in the loop and either changes strategy or finalises.

"toolBudgets": {
"recall": { "maxCalls": 4 },
"hive_consult_ontology": { "maxCalls": 4 },
"traverse": { "maxCalls": 3 },
"scary_tool": { "maxCalls": 0 } // disables the tool for this run
}
Field Type Notes
<key> string (1–120 chars) Logical tool name as the model sees it (ResolvedTool.name). The SDK + pipeline handle internal sanitisation.
maxCalls integer ≥ 0 Hard cap. 0 disables the tool entirely (the first attempt returns the synthetic body).

Budgets are per-tool, not pooledhive_search_deals: { maxCalls: 5 } and hive_search_meetings: { maxCalls: 5 } give the agent five of each, not five between them.

Validation (server-side, 400 invalid_request on violation):

Constraint Limit
Max entries 32
<key> length 1..120
maxCalls upper bound 1000 (functionally unlimited; maxToolTurns: 100 fires first)

Default budgets (applied when the field is omitted; caller-provided entries are layered on top so per-run overrides win):

Tool Default maxCalls
recall (workspace memory hybrid search) 4
traverse (memory graph BFS) 3
hive_consult_ontology (per-hive ontology read; same name across all three hives) 4
hive_search_deals / _meetings / _companies / _people (Sales Hive general search) 5
hive_search_tickets / _conversations / _accounts (Customer Hive general search) 5
hive_search_releases / _issues (Product Hive general search) 5

Pass "toolBudgets": {} to start from a clean slate (no defaults applied on top — useful for runs that intentionally want unbounded research). When both the caller and the runtime defaults specify a budget for the same tool, the caller’s value wins.

Each intervention emits a SSE event so SDK clients can render UI status banners without re-parsing tool-result bodies:

  • loop_detected — fired on the soft nudge and again on the hard cutoff if reached. See §4.5.
  • tool_budget_exceeded — fired each time a call is intercepted. See §4.6.
  • supervisor — fired on every run-supervisor review (on_track, redirect, or finalize). See §4.7.
  • task_plan — fired when a run carries a plan spec field: once after classify / caller-supplied plan, then on each tracker advance. See §4.9 / §8.5.

Both guard events (loop_detected, tool_budget_exceeded) are observability-only: the server has already substituted the synthetic tool-result / steering nudge by the time the SDK sees the event. The supervisor event is also observability-only when action is redirect / finalize — the pipeline already applied the verdict. The run continues to its terminal result / error / cancelled as usual.

An optional LLM run supervisor periodically reviews the agent’s transcript (reasoning, tool calls, tool results, visible text) and may steer the run:

Verdict Server action
on_track No-op — the run continues unchanged.
redirect A steering user message is injected; tools stay available on the next turn.
finalize The next turn is forced tools-disabled so the run lands a clean final answer (optionally prefaced by the supervisor’s message).

Reviews fire on two triggers:

  • Cadence — every interval LLM calls (completeTurn invocations), measured at the bottom of tool-emitting rounds (phase: "turn_boundary"). Default interval is 5 when the field is omitted.
  • Mid-turn reasoning — while a single turn is still streaming reasoning, once the current reasoning span crosses 3000 characters or 30s (whichever first), a phase: "reasoning" review runs on the in-progress reasoning. A redirect / finalize verdict aborts the in-flight turn and steers the next one before the model commits. The aborted turn’s spent reasoning tokens are still attributed to usage. Enabled by default; tune or disable via reasoningTrigger.
"supervisor": true // enable with platform defaults (interval 5, reasoning 3000 chars / 30s)
"supervisor": {
"interval": 5, // optional — LLM calls between reviews; default 5
"modelId": "platform:demo", // optional — judge model; see resolution below
"reasoningTrigger": { // optional — mid-turn reasoning trigger
"chars": 3000, // optional — reasoning chars per span; default 3000
"ms": 30000 // optional — ms per reasoning span; default 30000
}
// or "reasoningTrigger": false to disable mid-turn reasoning reviews
}
// or:
"supervisor": false // explicitly disable (same as omitting the field)
Field Type Notes
(literal true) true Enables the run supervisor with platform defaults (interval 5, reasoning trigger 3000 chars / 30s, workspace judge model).
interval integer ≥ 1 Optional. Default 5 when omitted. Capped at 100 server-side.
modelId string Optional. Same selector grammar as top-level modelId (§2). When omitted, the platform resolves the judge model from the workspace default supervisor model, then the workspace default model.
reasoningTrigger false | { chars?, ms? } Optional. Mid-turn reasoning trigger. Defaults to { chars: 3000, ms: 30000 }. chars capped at 50000, ms at 600000. Pass false to only review at tool-round boundaries.
(literal false) false Disables the run supervisor for this run. Loop detection and tool budgets still apply.

Model resolution. When the supervisor is enabled, the judge model is chosen in order:

  1. supervisor.modelId on this run (if set)
  2. Workspace defaultSupervisorModelId (Settings → Workspace → General)
  3. Workspace default model (same as omitting top-level modelId)

Defaults. When supervisor is omitted (or false), MANTYX does not run the platform LLM judge on ephemeral API runs — same opt-in semantics as plan (§8.5). Pass "supervisor": true, "supervisor": {}, or a config object to enable. Web chat enables the supervisor separately via the chat runner.

SDK-only runs. When a caller uses @mantyx/ts-sdk directly (not via POST /agent-runs), the supervisor is off unless explicitly configured: pass a RunAgentSupervisor object with a review callback to enable it, or omit the field to keep it disabled. The wire field above controls the platform-hosted judge on ephemeral API runs only.

Each review emits a SSE supervisor event (§4.7). Supervisor LLM usage is recorded under the supervisor usage surface for cost attribution.

The optional plan field turns on the in-product task plan — the same engine that drives the live checklist in MANTYX chat / Hive Mind. It is opt-in on ephemeral runs, same as supervisor (§8.4), because both features add extra LLM calls.

"plan": true // auto: pre-flight classify, emit task_plan, track during the run
"plan": { // caller-provided checklist — skips the classifier
"brief": "Migrate the billing tables and backfill", // optional
"steps": ["Snapshot current schema", "Apply migration", "Backfill rows", "Verify counts"]
}
"plan": { "planOnly": true } // produce the plan, do NOT run the agent
"plan": { "planOnly": true, "steps": ["", ""] } // plan-only with a caller-provided checklist
"plan": false // (or omit) no planning — a plain run
Form Behavior
omitted / false No planning. Default.
true Pre-flight classifier decides whether a multi-step plan is warranted. If so, a task_plan event is emitted, the checklist is injected into the user turn, and step statuses are tracked (advancing on tool activity and on each supervisor review) until the run ends. If the classifier declines, the run proceeds normally with no plan.
{ steps, brief? } Caller-provided checklist used verbatim — skips the classifier (and its MIN_STEPS gate). Injected + tracked like the auto case.
{ planOnly: true, steps? } Produce the plan (classifier when steps omitted, otherwise the provided checklist) and terminate without executing the agent loop.
Field Type Notes
planOnly boolean Optional. When true, the run stops after producing the plan; the terminal result carries it under data.plan.
brief string Optional one-line objective for a caller-provided plan. Clamped server-side.
steps string[] Optional caller-provided checklist titles. Empty/omitted ⇒ auto-classify. Count and per-step length clamped server-side.
(literal true) true Auto pre-flight classifier + live tracking.

Plan-only terminal shape. A planOnly run terminates with the normal terminal result event (§4.8), plus a structured plan:

{ "seq": 5, "type": "result",
"data": { "subtype": "success",
"text": "Migrate the billing tables and backfill\n\n1. Snapshot current schema\n2. Apply migration\n3. Backfill rows\n4. Verify counts",
"plan": { "brief": "Migrate the billing tables and backfill",
"steps": [ { "title": "Snapshot current schema", "status": "pending" }, /* … */ ] },
"tokens": { /* zeroed — classifier usage is metered separately */ },
"turns": 0, "model": { "id": "", "provider": "", "vendorModelId": "" } } }

When the classifier declines in plan-only mode, data.plan.steps is [] and data.text explains that no multi-step plan was warranted. Plan-only runs do not append an assistant turn to a session (the plan is not the answer).

Planner LLM usage (the classifier and the per-run tracker) is metered under the task_planning usage surface for cost attribution.

Like reasoningLevel and outputSchema, the run-guard and plan fields support session-default + per-message override:

  • POST /agent-sessions { loopDetection, toolBudgets, supervisor, plan } — sets the session-default applied to every subsequent message run.
  • POST /agent-sessions/:id/messages { loopDetection, toolBudgets, supervisor, plan } — optional per-message override. Applies to that one run only and does not mutate the session’s stored value.

POST /api/v1/workspaces/{slug}/agent-runs/{runId}/cancel
Authorization: Bearer <api-key>

Best-effort: publishes a Valkey signal the runner observes between LLM turns. The runner aborts cleanly and emits a terminal cancelled event. In-flight local_tool_calls are still fulfilled (or time out) before the final event lands, so SDKs should keep the stream open until they see a terminal event.


  • Every event has a monotonically-increasing seq per run, persisted to EphemeralAgentRunEvent. Reopen with Last-Event-ID: <seq> to resume.
  • The Valkey pub/sub is best-effort; the persisted log is the source of truth. The server occasionally polls the DB during long waits (see bus.ts → waitForLocalToolResult) so missed publishes still wake the runner.
  • local_tool_result_in is persisted in addition to the live publish, so late-joining viewers can replay the SDK’s response.
  • Tool-result POSTs are idempotent on toolUseId: a second POST for the same toolUseId returns 404 unknown_tool_use (or 409 if the run already ended), it does not double-execute the tool.

11. Full worked example: a2a_local round-trip

Section titled “11. Full worked example: a2a_local round-trip”
import { fetch } from "undici";
// ── 1. Resolve the Agent Card locally ───────────────────────────────────
const cardResp = await fetch(
"https://hr.intranet.acme/.well-known/agent-card.json",
{
headers: { Authorization: `Bearer ${INTRANET_TOKEN}` },
},
);
const agentCard = await cardResp.json(); // ← whole document, passed through
// ── 2. Submit the spec ──────────────────────────────────────────────────
const create = await fetch(`${MANTYX}/api/v1/workspaces/${slug}/agent-runs`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
modelId: "openai:gpt-5.5",
systemPrompt: "You can delegate HR questions to the Acme HR agent.",
prompt: "How many PTO days does Alice have left this year?",
reasoningLevel: "low",
tools: [{ kind: "a2a_local", name: "intranet_hr_agent", agentCard }],
}),
});
const { runId, streamUrl } = await create.json();
// ── 3. Open the SSE stream and dispatch local_tool_calls ────────────────
const stream = await fetch(streamUrl, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: "text/event-stream" },
});
for await (const ev of parseSSE(stream)) {
if (ev.type !== "local_tool_call") continue;
if (ev.data.kind !== "a2a_local") continue;
const peer = a2aClients.get(ev.data.agentCard.url); // ← dispatch by URL
const reply = await peer.send({ message: ev.data.args.message });
await fetch(
`${MANTYX}/api/v1/workspaces/${slug}/agent-runs/${runId}/tool-results`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
toolUseId: ev.data.toolUseId,
result: reply.text,
}),
},
);
}

12. Full worked example: mcp_local round-trip

Section titled “12. Full worked example: mcp_local round-trip”
// ── 1. Connect + resolve catalog locally ────────────────────────────────
const mcp = new McpClient(stdio("./mcp-server-filesystem"));
const initImpl = await mcp.initialize(); // → { name, version, ... }
const { tools } = await mcp.listTools(); // → MCP Tool[]
// ── 2. Submit the spec ──────────────────────────────────────────────────
const create = await fetch(`${MANTYX}/api/v1/workspaces/${slug}/agent-runs`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
modelId: "openai:gpt-5.5",
prompt: "Tell me what's at /etc/hosts.",
tools: [
{
kind: "mcp_local",
name: "fs",
serverInfo: initImpl,
tools, // ← verbatim from listTools()
},
],
}),
});
const { runId, streamUrl } = await create.json();
// ── 3. Open SSE and dispatch ────────────────────────────────────────────
for await (const ev of parseSSE(streamFromUrl(streamUrl, apiKey))) {
if (ev.type !== "local_tool_call") continue;
if (ev.data.kind !== "mcp_local") continue;
const result = await mcp.callTool({
name: ev.data.mcpToolName, // identical to ev.data.name
arguments: ev.data.args,
});
const text = result.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("\n");
await fetch(
`${MANTYX}/api/v1/workspaces/${slug}/agent-runs/${runId}/tool-results`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ toolUseId: ev.data.toolUseId, result: text }),
},
);
}

13. Compliance checklist for SDK implementers

Section titled “13. Compliance checklist for SDK implementers”

A reference SDK should:

  • Accept reasoningLevel from the caller in either string or number form and pass it through unchanged. Do not translate it to a vendor-specific knob — the server owns that mapping.
  • Accept outputSchema from the caller as { name?, schema } and pass it through unchanged. After the run terminates, JSON.parse the result.data.text and re-validate against the caller’s source-of-truth schema (Zod / Pydantic / etc.) — the server enforces JSON shape via the provider, but transient model errors can still produce strings that fail to parse in rare cases.
  • Accept loopDetection, toolBudgets, and supervisor from the caller and pass them through unchanged (see §8). loopDetection and toolBudgets are additive — omitting them keeps the runtime defaults; passing loopDetection: false opts out; passing toolBudgets: {} clears the defaults. supervisor and plan are opt-in — omitting them keeps both off; pass supervisor: true / {} / { interval?, modelId? } or plan: true / { … } to enable. Do not translate to vendor-specific knobs.
  • Treat loop_detected, tool_budget_exceeded, and supervisor SSE events as observability-only (see §4.5 / §4.6 / §4.7). Surface them as status notes / log lines / telemetry — the server already substituted synthetic tool-results / steering nudges / supervisor verdicts, so the SDK should keep consuming the stream until the terminal event lands.
  • Accept plan from the caller (true | { steps?, brief?, planOnly? } | false) and pass it through unchanged (see §8.5). It is opt-in — omitting it (or false) means no planning. Render task_plan events (§4.9) as a live, non-terminal checklist; keep consuming the stream until the terminal event. For planOnly runs, read the final checklist from the terminal result.data.plan.
  • Maintain three local-callback registries (or one tagged-union registry), keyed by name: - generic local tools (kind: "local"), - local A2A peers (kind: "a2a_local", indexed by some Agent Card field — typically agentCard.url), - local MCP servers (kind: "mcp_local", indexed by the SDK-side server label that matches local_tool_call.mcpServer).
  • For kind: "local", accept developer-supplied parameters (Zod / JSON Schema) and serialize to JSON Schema before submission. When the caller declares an output schema, forward it as outputSchema (same JSON Schema shape) so providers with per-tool response schemas can enforce it. Surface a longRunning flag on the tool builder so the caller can opt into the model-side “don’t double-call” hint without hand-editing the description.
  • For a2a_local, resolve the Agent Card locally and ship it as agentCard. Don’t expect MANTYX to fetch anything.
  • For mcp_local, speak Initialize + tools/list locally and ship the verbatim result as serverInfo + tools[]. Don’t expect MANTYX to discover anything.
  • On local_tool_call, dispatch by the event’s kind discriminator (defaulting to "local" when omitted). Validate args against the tool’s schema, run it, POST the result back to .../tool-results.
  • On the terminal result / error / cancelled event, close the SSE stream.
  • Idempotency: only POST one tool-result per toolUseId. Treat 409 run_terminal as a normal late-arrival outcome (the runner timed out).
  • Reconnects: send Last-Event-ID: <last seq> to resume, and rely on the persisted event log to backfill missed events.