Agent-runs protocol
This document specifies the public wire protocol that the MANTYX agent-runs API speaks with SDKs. It is the source of truth for anyone implementing a new client (Python, Rust, Java…) and is shipped with each first-party SDK so the SDK repository can stand on its own when it is extracted from this monorepo.
Companion documents:
docs/wire-protocol.md— the messaging-layer reference: every SSE event payload, the SDK-side dispatcher pattern, and the resolved data structures (a2a_localAgent Card,mcp_localTool[]) the SDK is expected to ship.docs/agent-runs.md— server-side overview, internals, deployment notes.
1. Concepts
Section titled “1. Concepts”Ephemeral agent. A run-time agent that is defined by the request rather
than persisted as a row in MANTYX’s Agent table. The full spec (system
prompt, model, tools) is stored as part of each session/run for observability
but is not editable from the dashboard.
Tool refs. Seven flavours, all carried inside the agent spec’s tools
array:
kind |
Resolved by | Notes |
|---|---|---|
mantyx |
server | A workspace Tool row referenced by id (HTTP / Code / Plugin). |
mantyx_plugin |
server | A platform plugin tool referenced by name. |
local |
client | A custom tool defined and executed in the SDK’s process. Carries parameters (input JSON Schema) plus optional outputSchema (return-value JSON Schema), longRunning, readOnly (parallel-batch), and retain (cross-turn replay) flags — see §4.1.1. |
a2a |
server | A remote Agent2Agent peer MANTYX can reach; invoked via message/send and the reply is surfaced as the tool result. |
a2a_local |
client | An A2A peer MANTYX cannot reach. SDK resolves the Agent Card locally and ships it inline; MANTYX uses it for the model description and routes calls back to the SDK over SSE. |
mcp |
server | A remote MCP server (Streamable HTTP). At run start MANTYX lists the catalog and exposes every tool as <server>_<tool> (subject to toolFilter). |
mcp_local |
client | An MCP server MANTYX cannot reach. SDK runs Initialize + tools/list locally and ships the resolved Tool[] (with inputSchema); MANTYX exposes them to the model with the SDK-declared names and routes calls back over SSE. |
The split is deliberate:
- Server-resolved (
mantyx,mantyx_plugin,a2a,mcp) — MANTYX has network access to the resource. The worker runs the tool itself and the SDK only sees an informationaltool_resultevent in the SSE stream. For MCP/A2A this also means MANTYX does discovery (listTools, agent-card fetch). - Client-resolved / “local” (
local,a2a_local,mcp_local) — MANTYX has no access to the resource. The SDK does all of the work: connection, discovery, listing, expansion, arg validation, auth, execution, retries. MANTYX is a thin LLM-routing layer that emits alocal_tool_callevent and blocks until the SDK POSTs back to.../tool-results. The event payload carries akinddiscriminator ("local"implied when absent,"a2a_local"and"mcp_local"explicit) so SDKs can dispatch to the right local handler.
One-shot run vs. session. A run is an LLM execution. Runs may be:
- one-shot (
POST /agent-runs) — fire-and-stream, no persistent state apart from observability. - session-scoped (
POST /agent-sessions/:id/messages) — the run inherits the session’s full message history, and the new user/assistant turns are appended back to the session on success.
2. Authentication
Section titled “2. Authentication”All SDK-facing endpoints sit under
/api/v1/workspaces/{workspaceSlug}/...and accept either of two bearer credentials interchangeably. The same header carries either, so SDKs only need one code path:
Authorization: Bearer <credential># or, equivalently:X-API-Key: <credential>| Credential | Token format | Identifies | Bound to | Use when |
|---|---|---|---|---|
| Workspace API key | mantyx_… |
The workspace | One workspace, no end-user | Personal scripts, internal automations, anything the SDK caller owns end-to-end. |
| OAuth 2.0 access token | mantyx_at_… |
An end user and the workspace they consented for | One workspace, one user (or one app for client_credentials) |
“Sign in with MANTYX” apps, third-party integrations, anywhere consent + scopes matter. |
The server resolves whichever it sees by token-prefix sniffing (see
packages/api/src/services/bearer-credential.ts) — SDKs do not need
separate code paths or env variables for the two flavours.
The workspace slug in the URL must match the credential’s tenant.
Mismatches return 404 not_found with a hint field pointing at the
correct slug. Missing/invalid credentials return 401 unauthorized.
Rate limits follow the workspace’s existing developer-API sliding-window
policy and are tracked per-credential.
2.1 Workspace API keys (machine credentials)
Section titled “2.1 Workspace API keys (machine credentials)”A workspace admin issues an API key under Settings → API keys with Usage = Developer API. The key inherits two optional restrictions:
- Agent allowlist (
ApiKey.agentIds) — empty list = “every non-system agent in the workspace”; otherwise only the listed agents are visible tospec.agentIdand ephemeral runs created from the key. - Plan gate — the workspace tier must include the
apiKeysfeature.
API keys carry no granular scopes; possession of a Developer-API key is enough to call every route in this document.
2.2 OAuth 2.0 access tokens
Section titled “2.2 OAuth 2.0 access tokens”OAuth tokens are a drop-in alternative for the same set of routes, with two differences:
-
Scopes are required. Each route checks the token carries the right scope via
requireScope(...)and returns403 { "error": "insufficient_scope", "required": "runs:write" }(the value is a string for single-scope routes, an array for multi-scope ones — see §2.3). The SDK is expected to surface this verbatim. The agent-runs surface uses these scopes:Endpoint Required scope GET .../modelsmodels:readPOST .../agent-runsruns:writeGET .../agent-runs/{runId}runs:readGET .../agent-runs/{runId}/streamruns:readPOST .../agent-runs/{runId}/cancelruns:writePOST .../agent-runs/{runId}/tool-resultsruns:writePOST .../agent-runs/{runId}/feedbackfeedback:writePOST .../agent-sessionssessions:writeGET .../agent-sessionssessions:readGET .../agent-sessions/{sessionId}sessions:readGET .../agent-sessions/{sessionId}/eventssessions:readDELETE .../agent-sessions/{sessionId}sessions:writePOST .../agent-sessions/{sessionId}/messagessessions:writeGET /api/oauth/userinfomantyx.identity:readFor an SDK that exposes one-shot runs and sessions end-to-end, request at minimum
models:read runs:read runs:write sessions:read sessions:write, and addmantyx.identity:readif the SDK calls/api/oauth/userinfoto discover the workspace slug after sign-in. -
Tokens are workspace-scoped. An access token is minted for one workspace (chosen by the user at consent time for public apps, or the registering workspace for private apps). Calling
/api/v1/workspaces/{otherSlug}/...with such a token returns404 not_foundplus ahintwith the correct slug.
OAuth tokens also honor the per-token agent allow-list
(OAuthAccessToken.agentIds) the user picked at consent time — see
docs/oauth.md for the full registration / authorization-code
- PKCE flow. PKCE (
S256) is mandatory and every MANTYX OAuth app is a confidential client, so the token endpoint requires bothclient_secretandcode_verifier.
Token lifetimes. Access tokens live 1 hour (expires_in: 3600).
Refresh tokens are persistent and non-rotating: they have no
time-based expiry and grant_type=refresh_token returns the same
refresh token the SDK already holds while minting a brand-new short-lived
access token. Multiple processes may refresh concurrently using the same
refresh token without invalidating each other. Refresh tokens stop
working only when the application access is revoked (/oauth/revoke,
DELETE /api/oauth/grants/:id, or app deletion).
SDK guidance. Persist the refresh token at first sign-in, treat it as long-lived, and keep refreshing the access token off it on demand (e.g. ~5 minutes before
expires_inruns out, or lazily on the first401). Do not rotate or replace the refresh token after each refresh — the value is stable.
A single SDK call site looks identical regardless of credential:
POST /api/v1/workspaces/acme/agent-runs HTTP/1.1Authorization: Bearer mantyx_at_… # OAuth access token# — or —Authorization: Bearer mantyx_… # workspace API keyContent-Type: application/json
{ "modelId": "openai:gpt-5.5", "prompt": "...", "tools": [...] }2.3 Error model for credentials
Section titled “2.3 Error model for credentials”| Status | Body shape | When |
|---|---|---|
401 |
{ "error": "Unauthorized", "message": "API key or OAuth access token required..." } |
No Authorization / X-API-Key header. |
401 |
{ "error": "Invalid API key or OAuth access token" } |
Token doesn’t match a row, expired, or revoked. |
403 |
{ "error": "This API key is not for the Developer API", "hint": "..." } |
API key has wrong usage. |
403 |
{ "error": "Workspace API keys are not available on this plan.", "code": "api_keys_plan" } { "error": "OAuth applications are not available on this plan.", "code": "oauth_apps_plan" } |
Workspace tier lacks the apiKeys / oauthApps feature. |
403 |
{ "error": "insufficient_scope", "required": "runs:write" } (or an array if a route needs multiple) |
OAuth token is missing a scope a route demands. The response also sets WWW-Authenticate: Bearer error="insufficient_scope", scope="...". |
404 |
{ "error": "Workspace path does not match this credential", "hint": "..." } |
URL slug ≠ token’s workspace. |
3. Models
Section titled “3. Models”GET /api/v1/workspaces/{workspaceSlug}/modelsReturns models the API key’s workspace can run, including BYOK providers and platform-hosted offerings visible to the workspace’s tier.
{ "models": [ { "id": "anthropic/claude-sonnet-4-5", "label": "anthropic · claude-sonnet-4-5", "provider": "anthropic", "vendorModelId": "claude-sonnet-4-5", "source": "platform_offering", "contextWindowTokens": 200000, "pricing": { "inputPer1MUsd": 3.0, "outputPer1MUsd": 15.0, "cacheReadPer1MUsd": 0.3, }, }, { "id": "openai/gpt-5.5", "label": "openai · gpt-5.5", "provider": "openai", "vendorModelId": "gpt-5.5", "source": "workspace_provider", "contextWindowTokens": 200000, "pricing": null, }, ], "defaultModelId": "openai/gpt-5.5",}The id is the canonical value the SDK passes back as RunSpec.modelId /
SessionSpec.modelId. Catalog ids use the {provider}/{vendorModelId} slug
(for example openai/gpt-5.5 or openrouter/anthropic/claude-sonnet-4).
Each workspace may have at most one connection per provider + vendor model pair.
The server also accepts these forms:
{provider}/{vendorModelId}— same slug as the catalogid(preferred).platform:<offeringId>— hosted catalog entry by internal id.provider:<id>/provider:<id>:<vendor>— BYOK provider by row id, with optional vendor model override.<vendorModelId>— bare vendor id; only succeeds if exactly one workspace provider can run it.undefined/omitted — falls back to the workspace default provider’s default model.
Invalid modelId values return 400 invalid_model with a candidate list
in the body when applicable.
4. Agent spec
Section titled “4. Agent spec”The agent spec is the body shape used by POST /agent-runs and POST /agent-sessions:
{ "name": "ephemeral", // optional, observability only "agentId": "agent_cm6abc123", // optional — see §4.1 "systemPrompt": "You are helpful.", // required unless agentId is set "modelId": "platform:cm6abc123", // optional, see §3 "reasoningLevel": "medium", // optional, see §4.4 "tools": [ { "kind": "mantyx", "id": "tool_cm6..." }, { "kind": "mantyx_plugin", "name": "web_search" }, { "kind": "local", "name": "read_file", "description": "Read a file from the user's machine", "parameters": { // JSON Schema for the args object "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"], "additionalProperties": false, }, "outputSchema": { // optional — JSON Schema for the return value "type": "object", "properties": { "bytes": { "type": "string", "description": "UTF-8 file contents" }, }, "required": ["bytes"], }, "longRunning": false, // optional — default false "readOnly": false, // optional — default false; true ⇒ may run in parallel with other reads "retain": false, // optional — default false; true ⇒ result replayed on later session turns }, { "kind": "a2a", "name": "billing_agent", "description": "Delegate billing questions to the Acme billing agent.", "agentCardUrl": "https://billing.acme.com/.well-known/agent-card.json", "headers": { "Authorization": "Bearer ${BILLING_TOKEN}" }, "contextId": "ctx_abc", // optional A2A context to thread turns }, { "kind": "a2a_local", "name": "intranet_hr_agent", "agentCard": { // SDK-resolved 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", "capabilities": { "streaming": false }, "skills": [ { "id": "pto_lookup", "name": "PTO lookup", "description": "Find a teammate's remaining PTO days for the year.", }, { "id": "benefits_qa", "name": "Benefits Q&A", "description": "Answer questions about insurance, 401k, and parental leave.", }, ], }, }, { "kind": "mcp", "name": "github", // → tools become github_<tool> "url": "https://mcp.github.com/v1", "headers": { "Authorization": "Bearer ${GH_PAT}" }, "toolFilter": ["search_repos", "read_file"], // optional allowlist }, { "kind": "mcp_local", "name": "fs", // SDK-side server label only — NOT a prefix "serverInfo": { // optional; from MCP Initialize "name": "mcp-server-filesystem", "version": "0.4.1", }, "tools": [ // verbatim MCP tools/list response { "name": "fs_read_file", // model-facing name, exactly as declared "description": "Read a file from the user's workstation", "inputSchema": { // MCP's term — JSON Schema "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"], }, }, ], }, ], "budgets": { "maxToolTurns": 32 }, // optional safety cap "outputSchema": { // optional, see §4.5 "name": "weather_report", "schema": { "type": "object", "properties": { "city": { "type": "string" }, "temperature_c": { "type": "number" }, }, "required": ["city", "temperature_c"], }, }, "loopDetection": { // optional, see §4.6 "consecutiveThreshold": 3, "hardCutoffThreshold": 6, }, "toolBudgets": { // optional, see §4.7 "recall": { "maxCalls": 4 }, "hive_consult_ontology": { "maxCalls": 4 }, "scary_tool": { "maxCalls": 0 }, }, "supervisor": { // optional, see §4.8 — platform LLM judge; pass false to disable "interval": 5, }, "plan": true, // optional, see §4.9 — in-product task plan (opt-in) "metadata": { // optional, see §4.10 "customer": "acme", "env": "prod", },}POST /agent-runs additionally accepts prompt or messages. Sending both,
or neither, is a 400 invalid_request. See §4.0.1
for the messages shape and file attachments.
4.0.1 Multi-role messages and file inputs
Section titled “4.0.1 Multi-role messages and file inputs”Instead of a single prompt string, supply a messages array to send a
multi-role conversation in one request. The same shape is accepted on
POST /agent-sessions/:id/messages (where it represents the new turn(s) to
append to the session).
{ "modelId": "openai:gpt-5.5", "messages": [ { "role": "system", "content": "You are a terse assistant." }, { "role": "user", "content": "Earlier question" }, { "role": "assistant", "content": "Earlier answer" }, { "role": "user", "content": "What's in this file?", "attachments": [ { "type": "input_file", "mimeType": "application/pdf", "filename": "report.pdf", "data": "<base64>" }, { "type": "input_file_url", "url": "https://example.com/image.png", "mimeType": "image/png" } ] } ]}Rules:
roleis one ofuser,assistant,system.systemmessages are applied as additional system context, appended after the spec’ssystemPrompt(both apply). A run is valid when it has anagentId, asystemPrompt, or at least one non-emptysystemmessage inmessages.- The last non-system message must be
role: "user"(400otherwise) — it is the current turn the model responds to, and the only message whoseattachmentsare sent to the model. - Max 200 messages; each
content≤ 200,000 characters.
File attachments (attachments on the last user message, max 20):
type |
Fields | Notes |
|---|---|---|
input_file |
mimeType, filename, data (base64, no data-URL prefix) |
Inline bytes. Total inline bytes per run are capped (currently 5 MB); larger files must use a URL. |
input_file_url |
url (https only), mimeType?, filename? |
Publicly fetchable HTTPS URL. The provider fetches it directly. |
Allowed MIME types match the workspace chat allowlist (images, PDF, DOCX,
and text/* subtypes). Disallowed types, non-HTTPS URLs, malformed base64, or
oversized inline payloads return 400 invalid_request. Attachments on older
history turns are not re-sent to the model (text only); only the current
turn’s files reach the LLM.
4.1 Triggering a persisted MANTYX agent (agentId)
Section titled “4.1 Triggering a persisted MANTYX agent (agentId)”Set agentId to the id of a workspace Agent to run that agent instead of
defining an ephemeral one inline. When agentId is set:
systemPromptbecomes optional. If omitted, the server uses the agent’s stored system prompt at run time.modelIdbecomes optional. If omitted, the server uses the agent’s configured LLM provider (or the workspace automation provider if the agent has Use workspace default model turned on).- The agent’s own tools are loaded from its workspace configuration —
including memory, skills, and plugin tools — and your
toolsarray is merged on top. This is typically used to attachlocaltools so the agent can call back into your process for this run, without needing to edit the agent’s stored tool list. - The API key must be authorized for this
agentId. Keys created with an emptyagentIdsallowlist (= “all agents”) work for any agent in the workspace; otherwise the agent must be in the key’s allowlist or the call returns403 forbidden. - An unknown / cross-workspace
agentIdreturns403(the API key check fires first); a malformedagentIdreturns400.
Both agentId and systemPrompt may be supplied. The agent’s stored prompt
wins; the inline systemPrompt is ignored.
4.1.1 kind: "local" — generic local tools
Section titled “4.1.1 kind: "local" — generic local tools”The minimal client-resolved tool: the SDK declares the contract and runs
the handler in its own process. MANTYX never executes the body — it
emits a local_tool_call event when the model picks the tool and waits
for the SDK to POST a tool-result.
| 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. Empty when omitted (acceptable, but reduces tool-selection accuracy). |
parameters |
no | JSON Schema for the tool’s input. Must be a type: "object" schema with properties; non-object roots are coerced to an empty object schema server-side. Forwarded verbatim to the LLM provider so nested constraints (array.items, enum, anyOf, numeric formats, …) survive. 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. When present, forwarded to providers that accept per-tool response schemas (Gemini’s responseJsonSchema on the FunctionDeclaration); other engines surface it through the description and rely on host-side validation. Helps the model emit follow-up arguments that round-trip cleanly. Must be an object schema; non-object roots are dropped server-side. |
longRunning |
no | When true, MANTYX appends a stable hint to the model-facing description so every provider treats the tool as long-running:“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 that return pending and rely on SDK-side polling — without the hint the model routinely fires repeat calls and burns turns. Pure declarative — MANTYX does not change scheduling. |
readOnly |
no | When true, the tool may run in parallel with other read-only tools the model emits in the same turn — MANTYX publishes every such local_tool_call concurrently and resolves them together, instead of one-at-a-time in model-emit order. Set this only for side-effect-free tools whose results don’t depend on each other, and make sure your SDK can service several outstanding local_tool_call events at once (each carries its own toolUseId; post a tool-result per id in any order). Mutating tools (the default, false) stay strictly sequential. |
retain |
no | When true, the tool’s result is persisted with the session and replayed to the model on later turns of the same session — reconstructed as a tool_use + tool_result pair in its original place in the transcript. Ordinary tool results only live for the current run (session history keeps user/assistant text only); a retained result survives so a follow-up turn can reference a value it can no longer recompute (a freshly minted id, a one-shot lookup, etc.). Only meaningful for session-scoped runs. Keep outputs small — they are stored verbatim (text only; file parts dropped) and capped (~8 KB). Defaults to false. |
The outputSchema, longRunning, readOnly, and retain fields are
additive since wire protocol v1: SDKs that don’t ship them keep working
unchanged. Providers without per-tool response-schema support (OpenAI,
Anthropic, Bedrock, Grok) accept the new fields silently — the schema is
treated as a description hint and host-side validation still runs. readOnly
and retain both default to false, so omitting them preserves the original
behavior.
4.2 A2A tool refs
Section titled “4.2 A2A tool refs”A2A delegation lets the agent hand a task to another Agent2Agent peer. The wire protocol exposes two kinds depending on who can reach the peer:
kind: "a2a"— remote (server-resolved). MANTYX dialsagentCardUrldirectly. Pick this when the peer is on the public internet or in the same VPC as MANTYX.kind: "a2a_local"— local (client-resolved). The SDK invokes the peer on its side and posts back the reply. Pick this when the peer lives on an intranet, behind a VPN, or on the user’s device — anywhere MANTYX can’t reach but the SDK can.
Both kinds present the same { "message": string } argument shape to
the model, so an agent prompt that uses one transparently works with the
other. (This also matches MANTYX’s internal delegate_to_<name> tools, so
models trained on one pattern carry across.)
kind: "a2a" — remote A2A
Section titled “kind: "a2a" — remote A2A”MANTYX resolves the tool server-side: when the model calls it, the worker
POSTs the model’s message argument to agentCardUrl over A2A’s standard
message/send RPC (Google ADK JSON-RPC root, A2A /rpc, /message:send,
and /message/send endpoints are probed in order) and forwards the remote
agent’s text reply back as the tool result.
| Field | Required | Notes |
|---|---|---|
kind |
yes | Discriminator literal "a2a". |
name |
yes | Tool name surfaced to the model — must match /^[a-zA-Z0-9_]{1,64}$/. |
description |
no | Model-facing description. Defaults to "Delegate a task to the <name> agent over A2A. Pass the full task as a single message.". Mention the remote agent’s purpose so the model picks it for the right turn. |
agentCardUrl |
yes | URL of the remote Agent Card (/.well-known/agent-card.json) or the JSON-RPC root the peer accepts. |
headers |
no | Flat string→string HTTP headers sent on every A2A request — typically Authorization. Each value is capped at 8 KB. |
contextId |
no | A2A contextId to thread multiple delegations into the same remote conversation. Omit for fresh per-call context. |
Secret handling.
headersare forwarded as-is by the SDK API. If you need long-lived credentials (refresh tokens, rotating API keys), register the peer as a workspaceExternalAgentinstead — those headers support{{secret:NAME}}resolution against the workspace secrets store (seeruntime/a2a-client.ts). The wire-protocola2aref is best for short-lived per-run tokens minted by your application.
kind: "a2a_local" — local A2A
Section titled “kind: "a2a_local" — local A2A”MANTYX does no A2A work for this kind. It does not fetch the agent card, validate transport, manage credentials, or speak
message/send. The SDK owns the entire A2A relationship; MANTYX merely translates the model’sdelegate_to_<name>call into alocal_tool_callevent and waits for the SDK to POST back the reply text.
Per-run lifecycle:
- Resolution (SDK). Before submitting the spec, the SDK obtains the
peer’s A2A Agent Card
— typically by fetching
/.well-known/agent-card.jsonfrom the local peer, or by reading it from a config file / registry / inline constant. - Submission (SDK → MANTYX). SDK posts the spec with the resolved
card embedded as
agentCard. MANTYX uses the card’sname,description, andskills[]to compose the model-facing tool description so the LLM understands what the peer can do. - Tool call (MANTYX → SDK). When the model calls the tool, MANTYX
emits a
local_tool_callevent withkind: "a2a_local",args: { message: string }, and the fullagentCardechoed back so the SDK can route to the right local A2A handler (matching by URL, name, skill set, or any other field). - Execution (SDK). SDK invokes the A2A peer (its own client, its own
credentials, its own retries) and POSTs the reply text to
POST /agent-runs/:runId/tool-results. - Continuation (MANTYX). MANTYX feeds the reply back into the model loop as the tool result.
| Field | Required | Notes |
|---|---|---|
kind |
yes | Discriminator literal "a2a_local". |
name |
yes | Tool name surfaced to the model — must match /^[a-zA-Z0-9_]{1,64}$/. |
description |
no | Model-facing description override. When omitted, MANTYX synthesizes one from agentCard.name, agentCard.description, and the first 12 skills. |
agentCard |
yes | The resolved A2A Agent Card (JSON content). Schema follows the A2A Agent Card spec — passthrough for unknown fields, so any spec-compliant card works. See the Agent Card shape table below for the fields MANTYX actually reads. |
Agent Card shape (only the fields MANTYX inspects; everything else is forwarded verbatim back to the SDK):
| Card field | Used by MANTYX | Notes |
|---|---|---|
protocolVersion |
echo only | A2A protocol version (e.g. "0.3.0"). |
name |
description | Used when synthesizing the tool description ("Delegate a task to the <name> agent ..."). |
description |
description | One-paragraph summary of what the peer does — surfaced to the model. |
url |
echo only | Peer’s A2A endpoint. Forwarded back to the SDK in the local_tool_call event so the SDK can dispatch by URL. Never fetched server-side. |
version |
echo only | Peer agent version. |
provider |
echo only | Vendor info. |
capabilities |
echo only | A2A capability flags (streaming, push notifications, …). |
defaultInputModes |
echo only | Modalities the peer accepts. |
defaultOutputModes |
echo only | Modalities the peer returns. |
skills[] |
description | First 12 skills (name, description) are bulleted into the tool description so the model knows what to ask for. |
securitySchemes, security |
echo only | Forwarded to the SDK; MANTYX does no auth. |
| anything else | echo only | Passthrough — survives round-trip unchanged. |
Local A2A respects the same localToolTimeoutMs budget (default 5 minutes)
as kind: "local". Tool-result POSTs after timeout return 409 run_terminal.
4.3 MCP tool refs
Section titled “4.3 MCP tool refs”Model Context Protocol connectors expose every tool published by an MCP server to the agent loop in one go. Like A2A, the protocol distinguishes by where the server lives:
kind: "mcp"— remote MCP (Streamable HTTP). MANTYX has network access to the server, dials it, lists the catalog at run start, and proxies each call server-side. MANTYX prefixes every discovered tool name with the ref’sname(e.g.github_search_repos) so multiple MCP servers can coexist without colliding.kind: "mcp_local"— local MCP (stdio, on-device, intranet). MANTYX has no access to the server; the SDK does discovery, validation, and execution. The SDK declares the tool catalog with the exact names it wants the model to see — MANTYX does not auto-prefix.
kind: "mcp" — remote MCP
Section titled “kind: "mcp" — remote MCP”| Field | Required | Notes |
|---|---|---|
kind |
yes | Discriminator literal "mcp". |
name |
yes | Server label — MANTYX prefixes every discovered tool name as <name>_<tool>. Must match /^[a-zA-Z0-9_]{1,64}$/. |
url |
yes | Streamable HTTP MCP endpoint. |
headers |
no | Flat string→string HTTP headers (e.g. Authorization). Each value capped at 8 KB. |
toolFilter |
no | Allowlist of MCP tool names (un-prefixed, as the server returns them). When set, tools not in the list are silently dropped. When omitted, every published tool is exposed. |
If the MCP server is unreachable when the run starts, MANTYX still exposes
a single stub tool named <server>_unavailable so the model can report the
failure to the user instead of silently going without the catalog.
kind: "mcp_local" — local MCP
Section titled “kind: "mcp_local" — local MCP”MANTYX does no MCP work for this kind. It does not speak
Initialize,tools/list, ortools/call, does not validate args, and does not interpret result content blocks. The SDK owns the entire MCP relationship — including discovery — and gives MANTYX the resolved tool catalog so the model can be told what’s available. MANTYX is purely a transport.
Per-run lifecycle:
-
Discovery (SDK). Before submitting the spec, the SDK connects to its local MCP server, speaks
Initialize(capturing theImplementationblock as optionalserverInfo), then callstools/list. The resultingTool[]array is shipped verbatim astools[]. -
Submission (SDK → MANTYX). SDK posts the spec with the resolved catalog. Field names match the MCP spec exactly —
inputSchema, notparameters— so a TypeScript SDK can pass through what its MCP client already decoded. Thetools[].namevalues are exactly what the model will see; MANTYX does not auto-prefix or rename anything. Sanitize them to[a-zA-Z0-9_]{1,64}yourself (if you wantfs/read_fileto surface asfs_read_file, declare it that way). -
Tool call (MANTYX → SDK). When the model calls a tool, MANTYX emits a
local_tool_callevent withkind: "mcp_local"and these extra hints so the SDK can dispatch to the right MCP client:{"seq": 9,"type": "local_tool_call","data": {"toolUseId": "tu_x","name": "fs_read_file", // SDK-declared name; same string the model called"args": { "path": "/etc/hosts" },"kind": "mcp_local","mcpServer": "fs", // the SDK-side label from the ref's `name`"mcpToolName": "fs_read_file", // duplicates `name` for the SDK's convenience"mcpServerInfo": {// present iff the ref carried `serverInfo`"name": "mcp-server-filesystem","version": "0.4.1",},},} -
Execution (SDK). SDK validates args against the locally-known
inputSchema, speaks MCPtools/call, flattens the response content blocks (typically the joinedtextblocks), and POSTs the result back to.../tool-results. -
Refresh (optional). To pick up new tools mid-session, send the updated
mcp_localref insidePOST /agent-sessions/:id/messages’stoolsfield; the catalog snapshot lives on the run, not the session.
| Field | Required | Notes |
|---|---|---|
kind |
yes | Discriminator literal "mcp_local". |
name |
yes | SDK-side server label (e.g. "fs", "jira"). Echoed back unchanged as mcpServer on every local_tool_call. Not used to prefix tool names. Match /^[a-zA-Z0-9_]{1,64}$/. |
serverInfo |
no | The MCP Implementation block the SDK got from Initialize ({ name, version? }, plus any extra fields the server returned). Forwarded to the SDK in local_tool_call.mcpServerInfo for observability; not used to drive behavior. |
tools |
yes | Verbatim MCP tools/list output (1–64 entries). Each item is the standard MCP Tool shape: { name, description?, inputSchema?, annotations?, … }. name is the model-facing tool name (SDK owns naming). inputSchema is the MCP-spec JSON Schema for the tool’s arguments — used to constrain the LLM’s tool call. Empty inputSchema means a no-arg tool. |
Older SDKs that ignore the kind discriminator still see a normal
local_tool_call and can match on name alone.
4.4 reasoningLevel (provider thinking strength)
Section titled “4.4 reasoningLevel (provider thinking strength)”reasoningLevel controls how much extended-thinking / reasoning effort the
model spends per turn. MANTYX maps the same value onto every supported
provider:
- OpenAI Responses —
reasoning.efforton reasoning models (o-series, GPT-5.x, …; ignored on non-reasoning models and on xAI Grok). - Gemini 3+ —
thinkingConfig.thinkingLevel; pre-Gemini-3 models consume the equivalentthinkingBudgettoken count. - Anthropic / Bedrock-Anthropic — extended thinking with a budget that
scales with strength (≈512 tokens at
low→ ≈8000 athigh).
Two equivalent input shapes are accepted:
| Form | Values | Notes |
|---|---|---|
| String | "off", "low", "medium", "high" |
Snaps to the same anchors the web composer uses (Fast=30, Moderate=50, Smart=80; off=0). |
| Number | integer 0–100 |
Pass-through to RunAgentOptions.reasoningLevel. 0 explicitly disables provider thinking even on reasoning models. |
When omitted, MANTYX falls back to the agent’s default — for ephemeral
specs, that means thinking is off; for agentId-backed specs, it follows
the persisted Agent configuration.
For session-scoped runs the inheritance rules are:
POST /agent-sessions { reasoningLevel }— sets the session-default applied to every subsequent message run.POST /agent-sessions/:id/messages { reasoningLevel }— optional per-message override; applies to that one run only and does not mutate the session’s stored value.
4.5 outputSchema (structured final reply)
Section titled “4.5 outputSchema (structured final reply)”outputSchema constrains the model’s final assistant text to a JSON
document conforming to a JSON Schema. Useful when the SDK needs to feed the
reply directly into downstream code without LLM-flavoured prose to parse out.
"outputSchema": { "name": "weather_report", // optional; default "output" "schema": { // required, root must be a JSON object "type": "object", "properties": { "city": { "type": "string" }, "temperature_c": { "type": "number" } }, "required": ["city", "temperature_c"] }}| Field | Required | Notes |
|---|---|---|
name |
no | Stable identifier passed to providers (OpenAI text.format.name, Anthropic synthetic-tool name). Defaults to "output". Must match /^[a-zA-Z0-9_-]{1,64}$/. |
schema |
yes | JSON Schema describing the final assistant text. Root must be a JSON object (most providers reject array / scalar roots in structured-output mode). The schema is passed through verbatim — MANTYX does not validate its contents; the provider does. |
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 |
Per-provider behaviour (mirrors the SDK’s RunAgentOptions.finalResponseSchema):
| Provider | How the schema is enforced |
|---|---|
| OpenAI Responses (o-series, GPT-5.x, …) | text.format = { type: "json_schema", strict: true, name, schema } on every turn (works alongside tool calls). |
| Gemini 3+ (any turn) | responseMimeType: "application/json" + responseJsonSchema on every completeTurn. Gemini 3 accepts the schema alongside functionDeclarations. |
| Gemini ≤ 2.5 (no-tools turn) | 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 never receives a local_tool_call for
set_model_response or final_report, and these names never appear in
the tools array the SDK declared. The terminal result event still
carries the reply as data.text: string.
The terminal result event still carries the reply as
data.text: string — the SDK is expected to JSON.parse and validate
against its own source-of-truth schema (Zod, Pydantic, …) so it keeps
control of error handling on malformed-but-rare provider outputs.
Inheritance for sessions:
POST /agent-sessions { outputSchema }— sets the session-default, applied to every subsequent message run.POST /agent-sessions/:id/messages { outputSchema }— optional per-message override; applies to that one run only and does not mutate the session’s stored value.
outputSchema works for both ephemeral runs (systemPrompt-defined) and
agentId-backed runs — the runner applies the schema to whatever
AgentSpec it built for the run. When the field is omitted, runs return
unconstrained plain text as before.
4.6 loopDetection (steering nudge + hard cutoff)
Section titled “4.6 loopDetection (steering nudge + hard cutoff)”loopDetection is the wire-protocol projection of the SDK’s
RunAgentOptions.loopDetection. The pipeline tracks a canonical
order-invariant (toolName, args) signature for every assistant turn that
makes one or more tool calls; when the same signature repeats consecutively,
the guard fires.
consecutiveThresholdrounds in a row (default3) — the pipeline skips the duplicate batch with a synthetic “you’ve made this exact call before” tool result and prepends a user-style steering nudge (“either deliver a final answer or change strategy”). The model gets the nudge before its next turn and either finalises or pivots.hardCutoffThresholdrounds in a row (default6) — the pipeline forces a tools-disabled finalise turn (maxToolTurnsExceeded: "finalize"semantics) so the run lands cleanly instead of churning forever.
"loopDetection": { "consecutiveThreshold": 3, // optional, default 3 — fires the steering nudge "hardCutoffThreshold": 6 // optional, default 6 — forces finalisation}The wire shape also accepts the literal false:
"loopDetection": false // explicitly disable the guard for this run| Field | Type | Required | Notes |
|---|---|---|---|
consecutiveThreshold |
integer ≥ 2 | no | Defaults to 3 when the field is omitted. Must be >= 2 (one identical batch is just a single tool call, not a loop). |
hardCutoffThreshold |
integer ≥ 3 | no | Defaults to 6 when the field is omitted. Must be > consecutiveThreshold; otherwise the soft nudge would never get a chance to land. |
(top-level false) |
literal false |
no | Disables the guard entirely for this run. The pipeline still enforces budgets.maxToolTurns. |
Validation (server-side, 400 invalid_request on violation):
| Constraint | Limit |
|---|---|
consecutiveThreshold / hardCutoffThreshold upper bound |
100 |
hardCutoffThreshold strictly greater than consecutiveThreshold |
enforced |
Defaults. When loopDetection is omitted entirely, MANTYX applies the
runtime defaults from runtime/default-run-guards.ts:
{ consecutiveThreshold: 3, hardCutoffThreshold: 6 }. This is the same
configuration used by every in-process runner (chat, schedule, inbound) so
SDK-driven runs and platform-driven runs behave identically.
Inheritance for sessions.
POST /agent-sessions { loopDetection }— sets the session-default, applied to every subsequent message run.POST /agent-sessions/:id/messages { loopDetection }— optional per-message override; applies to that one run only and does not mutate the session’s stored value.
Observability. Each intervention emits a SSE loop_detected event
(see §7) so SDK clients can render looping — nudged / looping — gave up
status notes. The actual mechanism (skip + nudge or forced finalise) is
fully handled server-side; the SDK only needs to surface the event.
4.7 toolBudgets (per-tool call caps)
Section titled “4.7 toolBudgets (per-tool call caps)”toolBudgets caps how many times a specific tool may execute 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
returned to the model as a synthetic “budget exceeded — pivot or finalize”
tool result.
"toolBudgets": { "recall": { "maxCalls": 4 }, "hive_consult_ontology": { "maxCalls": 4 }, "traverse": { "maxCalls": 3 }, "scary_tool": { "maxCalls": 0 } // disables the tool for this run}| Field | Type | Required | Notes |
|---|---|---|---|
<key> |
string | yes | Logical tool name as the model sees it (the same name on ResolvedTool.name; the SDK + pipeline handle sanitisation). 1–120 characters. |
maxCalls |
integer ≥ 0 | yes | Hard cap on executed calls per run. 0 disables the tool entirely (every attempt returns the synthetic body on the first try). Budgets are per-tool, not pooled: hive_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 chars |
maxCalls upper bound |
1000 (functionally unlimited; the SDK’s maxToolTurns: 100 fires first) |
Defaults. When toolBudgets is omitted, MANTYX layers the runtime
defaults from runtime/default-run-guards.ts on top of the spec. The
default research-tool surface is:
| 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.
Inheritance for sessions.
POST /agent-sessions { toolBudgets }— sets the session-default, applied to every subsequent message run.POST /agent-sessions/:id/messages { toolBudgets }— optional per-message override; applies to that one run only and does not mutate the session’s stored value.
Observability. Each interception emits a SSE tool_budget_exceeded
event (see §7) so SDK clients can render memory budget exhausted /
research cap reached status notes. The synthetic tool-result is emitted
on the normal tool_result channel just like any other server-resolved
result, so the run timeline stays linear.
Tools NOT capped by default. hive_list_* and hive_get_* are
intentionally not in the default budget map — agents legitimately call
them once per entity-of-interest, which can easily exceed any small cap
during normal multi-entity reads. The loop-detection guard catches the
pathological “same (name, args) batch over and over” case for that
family without needing per-tool caps.
4.8 supervisor (run judge)
Section titled “4.8 supervisor (run judge)”supervisor controls the optional run supervisor — an LLM judge that
periodically reviews the agent’s transcript (reasoning, tool calls, tool
results, visible text) and may steer the run:
on_track— no-op; the run continues.redirect— a steering user message is injected; tools stay available.finalize— the next turn is forced tools-disabled so the run lands a clean final answer.
Reviews fire on two triggers:
- Cadence — every
intervalLLM calls (completeTurninvocations) at the bottom of tool-emitting rounds (phase: "turn_boundary"). Default interval is 5 when enabled. - Mid-turn reasoning — while a single turn is still streaming reasoning,
once the current reasoning span crosses 3000 chars or 30s (whichever
first), a
phase: "reasoning"review runs on the in-progress reasoning. Aredirect/finalizeverdict aborts the in-flight turn and steers the next one. Enabled by default; tune or disable withreasoningTrigger. The aborted turn’s spent reasoning tokens are still attributed to usage.
"supervisor": true // enable with platform defaults
"supervisor": { "interval": 5, "modelId": "platform:demo", "reasoningTrigger": { "chars": 3000, "ms": 30000 } // or false to disable mid-turn reviews}
// or:"supervisor": false // explicitly disable (same as omitting the field)| Field | Type | Required | Notes |
|---|---|---|---|
(literal true) |
true |
no | Enables the run supervisor with platform defaults (interval 5, reasoning trigger 3000 chars / 30s, workspace judge model). |
interval |
integer ≥ 1 | no | Defaults to 5 when the supervisor is enabled and interval is omitted. Capped at 100 server-side. |
modelId |
string | no | Judge model selector (same grammar as modelId). Falls back to workspace defaultSupervisorModelId, then workspace default model. |
reasoningTrigger |
false | { chars?, ms? } |
no | 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 |
no | Disables the run supervisor for this run. loopDetection and toolBudgets still apply. |
Defaults. When supervisor is omitted (or false), MANTYX does not
run the platform LLM judge on ephemeral API runs. Pass "supervisor": true,
"supervisor": {}, or a config object to opt in.
SDK-only usage. When calling @mantyx/ts-sdk directly (not via
POST /agent-runs), the supervisor is off unless explicitly configured:
pass supervisor: { review, interval? } on RunAgentOptions to enable a
caller-supplied judge, or pass supervisor: false (or omit the field) to
keep it disabled. The wire field above controls the platform-hosted judge
on API/ephemeral runs only.
Validation (server-side, 400 invalid_request on violation):
| Constraint | Limit |
|---|---|
interval upper bound |
100 |
Inheritance for sessions.
POST /agent-sessions { supervisor }— sets the session-default, applied to every subsequent message run.POST /agent-sessions/:id/messages { supervisor }— optional per-message override; applies to that one run only and does not mutate the session’s stored value.
Observability. Each review emits a SSE supervisor event (see §7) —
including on_track checks — so SDK clients can render supervisor activity.
When action is redirect or finalize, the pipeline has already applied
the verdict by the time the event arrives.
4.9 plan (task plan / plan-only)
Section titled “4.9 plan (task plan / plan-only)”plan turns on the in-product task plan — the same live-checklist engine
that powers MANTYX chat / Hive Mind. It is opt-in on API/ephemeral runs,
same as supervisor (§4.8). Active plans are owned by the executing agent
through the host update_task_plan tool; there is no pre-flight classifier or
separate tracker LLM call.
"plan": true // compatibility alias for "auto""plan": "auto" // agent decides during its normal first turn"plan": "required" // agent must initialize a plan before substantive tools
"plan": { // caller-provided checklist — skips the classifier "mode": "required", "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 / "auto" |
Exposes update_task_plan; the executing agent decides during its normal first turn whether multi-step tracking is useful. No separate planning LLM call. |
"required" |
Requires update_task_plan before substantive tools and rejects premature final answers while required steps remain unfinished (bounded continuation guard). |
{ mode?, steps, brief? } |
Seeds an authoritative checklist. mode defaults to auto; use required to enforce initialization/execution. |
{ planOnly: true, steps? } |
Produce the plan (classifier when steps omitted, otherwise the provided checklist) and terminate without executing the agent loop. The terminal result carries data.plan (see §7). |
| Field | Type | Required | Notes |
|---|---|---|---|
planOnly |
boolean | no | When true, the run stops after producing the plan. |
mode |
string | no | off, auto, or required. Defaults to auto. |
brief |
string | no | One-line objective for a caller-provided plan. Clamped server-side. |
steps |
string[] | no | Caller-provided checklist titles. Empty/omitted ⇒ auto-classify. Count + per-step length clamped server-side. |
Plan-only runs do not append an assistant turn to a session (the plan is
not the answer). When no steps are supplied, this compatibility-only path
still uses the one-shot planner and meters it under task_planning; executed
runs do not use the classifier/tracker path.
Inheritance for sessions.
POST /agent-sessions { plan }— sets the session-default, applied to every subsequent message run.POST /agent-sessions/:id/messages { plan }— optional per-message override; applies to that one run only and does not mutate the session’s stored value.
4.10 metadata (developer-supplied KV for filtering)
Section titled “4.10 metadata (developer-supplied KV for filtering)”metadata is a flat string→string KV that is persisted alongside the run /
session and surfaced in the MANTYX dashboard. Use it to tag runs with your
own application identifiers (customer, env, workflow, trace_id, …) so
your team can filter the observability UI without reverse-engineering the
prompt.
Validation (server-side, 400 invalid_request on violation):
| Constraint | Limit |
|---|---|
| Max entries | 16 |
| Key pattern | ^[A-Za-z0-9._-]{1,64}$ |
| Value type / length | string ≤ 256 chars |
| Serialized JSON size | ≤ 4 KB |
For session-scoped runs the inheritance rules are:
POST /agent-sessions { metadata }— sets the session’s metadata; this is inherited by every run created throughPOST /agent-sessions/:id/messages.POST /agent-sessions/:id/messages { metadata }— optional per-message override. The server snapshotssession.metadata⊕ override (run-level keys win) onto the run row at creation time. Later edits to the session metadata do not retroactively rewrite past runs.
Metadata is returned on every read: GET /agent-runs/:id,
GET /agent-sessions/:id, the public session list (GET /agent-sessions,
see §6.1), and the admin list/detail endpoints. Filtering uses repeated
?metadata=key:value query params, AND-combined — supported on both the
public GET /agent-sessions endpoint and the admin list endpoints; see
docs/agent-runs.md §“Web UI” for the admin surface.
5. One-shot runs
Section titled “5. One-shot runs”POST /api/v1/workspaces/{slug}/agent-runsGET /api/v1/workspaces/{slug}/agent-runs/{runId}GET /api/v1/workspaces/{slug}/agent-runs/{runId}/streamPOST /api/v1/workspaces/{slug}/agent-runs/{runId}/tool-resultsPOST /api/v1/workspaces/{slug}/agent-runs/{runId}/cancelPOST /api/v1/workspaces/{slug}/agent-runs/{runId}/feedbackPOST /agent-runs returns 202 Accepted immediately:
{ "runId": "run_abc", "streamUrl": "/api/v1/workspaces/acme/agent-runs/run_abc/stream"}GET .../stream is the canonical event channel; see §7.
GET /agent-runs/{runId} returns the run snapshot (status, final text, error,
spec, plus the cost-attribution triple tokens / turns / model —
see §7.1) without subscribing to live events. Useful for polling long
runs or attributing spend after the SSE stream was already consumed.
6. Sessions
Section titled “6. Sessions”POST /api/v1/workspaces/{slug}/agent-sessionsGET /api/v1/workspaces/{slug}/agent-sessionsGET /api/v1/workspaces/{slug}/agent-sessions/{sessionId}GET /api/v1/workspaces/{slug}/agent-sessions/{sessionId}/eventsPOST /api/v1/workspaces/{slug}/agent-sessions/{sessionId}/messagesDELETE /api/v1/workspaces/{slug}/agent-sessions/{sessionId}POST /agent-sessions creates a session with the agent spec. The body shape
is the same as the one-shot agent spec (no prompt, no messages).
Returns:
{ "sessionId": "ses_abc" }6.1 Listing sessions (GET /agent-sessions)
Section titled “6.1 Listing sessions (GET /agent-sessions)”Lists the workspace’s sessions, most-recently-used first. Supports paging and metadata filtering so callers can find earlier sessions by the application identifiers they attached at create time (see §4.9).
| Query param | Notes |
|---|---|
metadata |
Repeatable key:value filter; AND-combined. Malformed entries return 400 invalid_request. |
status |
Optional exact match (active / ended). |
limit |
Default 50, max 200. |
offset |
Default 0. |
GET /api/v1/workspaces/acme/agent-sessions?metadata=customer:acme&metadata=env:prod&limit=20{ "total": 1, "limit": 20, "offset": 0, "sessions": [ { "sessionId": "ses_abc", "creationDate": "2026-06-08T17:00:00.000Z", // ISO 8601 — when created "lastInteractionDate": "2026-06-08T17:42:10.000Z", // ISO 8601 — last message run "summary": "How do I reset my password?", // derived from the first user prompt "metadata": { "customer": "acme", "env": "prod" }, "status": "active" } ]}summary is a best-effort label derived from the conversation’s first user
prompt (sessions have no title); when the opener is very short the following
messages are appended until it is informative.
6.2 Replaying a session as events (GET /agent-sessions/{sessionId}/events)
Section titled “6.2 Replaying a session as events (GET /agent-sessions/{sessionId}/events)”Returns the session’s stored conversation as realtime-style event frames so a
UI can restore the thread through the same handler it uses for the live SSE
stream (§7). Unknown session ids return 404 not_found.
| Query param | Notes |
|---|---|
full |
1 / true returns every message (the default when no paging param is given). |
lastMessages |
Return only the last N messages. Ignored when full is set. |
GET /api/v1/workspaces/acme/agent-sessions/ses_abc/events?lastMessages=2{ "sessionId": "ses_abc", "total": 4, // total messages in the session (not the page size) "events": [ { "seq": 3, "type": "user_message", "text": "three" }, { "seq": 4, "type": "assistant_message", "text": "four" } ]}Frames use the flattened wire shape clients already consume from SSE
({ seq, type, ...payload }). seq is the message’s position in the full
conversation (stable across paging). Reconstructed turns map as:
| Stored role | Frame type |
|---|---|
user |
user_message |
assistant |
assistant_message |
| other | message (carries a role field) |
user_message is a replay/restore-only frame — the live SSE stream never
emits it (the client already knows the prompt it sent), but history
restoration needs it to rebuild the user side of the conversation. This first
cut reconstructs from the durable transcript (role/content); tool
calls are not replayed here. When a user turn carried file inputs, the frame
also includes an attachments array of metadata (no bytes):
{ type: "input_file", mimeType, filename, size } or
{ type: "input_file_url", url, mimeType?, filename? }.
POST /agent-sessions/{id}/messages queues a new run scoped to the session
and returns { runId, streamUrl } just like a one-shot run. Body accepts a
single prompt or a messages array (same shape and file-attachment
rules as §4.0.1; the messages
represent the new turn(s) appended to the session):
{ "prompt": "What's in /etc/hosts?", "tools": [ /* optional refresh of tool definitions */ ],}The server prepends the session’s prior messages, runs the model, and on success appends the new user/assistant turns back to the session row (file attachments are stored as metadata only). Local tool handlers are not persisted: the session stores definitions (name, schema, description) so that a restarted SDK can re-bind handlers and keep going.
DELETE flips the session to ended and cancels any in-flight run.
7. SSE stream
Section titled “7. SSE stream”GET .../agent-runs/{runId}/stream returns text/event-stream. Reconnects
support both:
- the standard
Last-Event-IDrequest header (set automatically by browserEventSource), and - a
?lastSeq=<int>query param (preferred for bare HTTP clients).
The server replays missed events from the EphemeralAgentRunEvent table in
order before resuming the live tail.
Each frame:
id: 17event: <type>data: <utf-8 JSON><type> and <data> shapes:
// streamed assistant tokens (zero or more per turn){ "seq": 2, "type": "assistant_delta", "data": { "text": "Hello" } }
// streamed reasoning / extended-thinking tokens (only when reasoningLevel > 0// AND the active provider exposes thought parts: Anthropic extended thinking,// Gemini `includeThoughts`, OpenAI `reasoning_content` on reasoning models).{ "seq": 2, "type": "thinking_delta", "data": { "text": "First, I should…" } }
// completed assistant message (text + any tool calls about to execute){ "seq": 3, "type": "assistant_message", "data": { "text": "...", "toolCalls": [...] } }
// server-side tool call/result (informational; SDK does not act on these){ "seq": 4, "type": "tool_call", "data": { "toolUseId": "...", "name": "...", "input": {...} } }{ "seq": 5, "type": "tool_result", "data": { "toolUseId": "...", "name": "...", "ok": true, "summary": "..." } }
// files a server-resolved tool produced (observability; precedes the tool_result above and shares `name`).// `storageKey` → fetch via the admin download route; inline base64 `data` (dev fallback) is redacted from the stream.{ "seq": 5, "type": "tool_files", "data": { "name": "render_chart", "files": [ { "filename": "chart.png", "mimeType": "image/png", "sizeBytes": 20480, "storageKey": "chat-attachments/<tenant>/tool-results/<tool>/<uuid>.png" } ] } }
// LOCAL tool call — SDK MUST POST a tool-result for the same toolUseId.// `kind` carries the discriminator so the SDK can dispatch to the right// local handler (generic registry, A2A client, or MCP client). Older SDKs// that ignore `kind` still match on `name`.{ "seq": 6, "type": "local_tool_call", "data": { "toolUseId": "tu_x", "name": "read_file", "args": { "path": "/etc/hosts" } } }{ "seq": 6, "type": "local_tool_call", "data": { "toolUseId": "tu_y", "name": "intranet_hr_agent", "args": { "message": "When does PTO reset?" }, "kind": "a2a_local", "agentCard": { "name": "Acme HR", "url": "https://hr.intranet.acme/a2a", "skills": [ { "id": "pto_lookup", "name": "PTO lookup" } ] } } }{ "seq": 6, "type": "local_tool_call", "data": { "toolUseId": "tu_z", "name": "fs_read_file", "args": { "path": "/etc/hosts" }, "kind": "mcp_local", "mcpServer": "fs", "mcpToolName": "fs_read_file", "mcpServerInfo": { "name": "mcp-server-filesystem", "version": "0.4.1" } } }
// echo of the SDK's POSTed tool-result, persisted for replay. Any `files` carry only// metadata on the wire — `{ filename, mimeType, sizeBytes }` — with base64 `data` redacted// (bytes are served on demand by the admin download route).{ "seq": 7, "type": "local_tool_result_in", "data": { "toolUseId": "tu_x", "output": "127.0.0.1 ..." } }
// loop-detection guard fired (see §4.6). Soft nudge: hardCutoff=false. Hard cutoff: hardCutoff=true.// `tools` is the (toolName, …) batch the model just repeated; the synthetic skip + nudge are// emitted on the normal tool_result + assistant_delta channels — this event is observability only.{ "seq": 7, "type": "loop_detected", "data": { "consecutiveCount": 3, "hardCutoff": false, "tools": ["recall"] } }
// per-tool budget exceeded (see §4.7). The pipeline already surfaced the synthetic// "budget exceeded — pivot or finalize" body on the normal tool_result channel; this event// is observability so SDK clients can render "memory budget exhausted" status notes.{ "seq": 7, "type": "tool_budget_exceeded", "data": { "tool": "recall", "maxCalls": 4, "callIndex": 5 } }
// run-supervisor check (see §4.8). Fired on every review — on_track included.{ "seq": 7, "type": "supervisor", "data": { "action": "on_track", "reason": "Agent is making progress.", "llmCalls": 5, "model": { "id": "platform:demo", "provider": "openai", "vendorModelId": "gpt-4o-mini" } } }{ "seq": 8, "type": "supervisor", "data": { "action": "redirect", "reason": "Stuck re-querying.", "redirect": "Answer from the data you already have.", "llmCalls": 10 } }{ "seq": 9, "type": "supervisor", "data": { "action": "finalize", "reason": "Enough to answer.", "llmCalls": 15 } }// mid-turn reasoning review (phase: "reasoning") — fired while the agent was still thinking{ "seq": 6, "type": "supervisor", "data": { "action": "redirect", "phase": "reasoning", "reason": "Overthinking a simple case.", "redirect": "Commit and answer.", "llmCalls": 4 } }
// Agent-owned task plan (see §4.9). Every transition carries the canonical// post-transition snapshot. `revision` is monotonic per plan; run-event `seq`// orders multiple transitions in one accepted update. `brief` and `steps`// remain mirrored at the top level for pre-v2 SDK clients.// status ∈ pending | in_progress | done | blocked | skipped.{ "seq": 4, "type": "task_plan", "data": { "v": 2, "planId": "3b5f...", "revision": 2, "mode": "required", "source": "agent", "transition": { "kind": "step_completed", "stepId": "step-1" }, "transitionIndex": 0, "transitionCount": 2, "brief": "Compare Q3 vs Q4 revenue.", "steps": [ { "id": "step-1", "title": "Pull revenue", "status": "done" }, { "id": "step-2", "title": "Compute deltas", "status": "in_progress" } ], "plan": { "v": 2, "planId": "3b5f...", "revision": 2, "mode": "required", "brief": "Compare Q3 vs Q4 revenue.", "steps": [ { "id": "step-1", "title": "Pull revenue", "status": "done" }, { "id": "step-2", "title": "Compute deltas", "status": "in_progress" } ] }} }
// terminal event// Every terminal `result` event also carries `tokens`, `turns`, and `model`// for cost attribution and dashboards — see §7.1. Older platforms (pre-// 2026-09) omit these fields; SDK clients detect "no usage data" by// checking that `model.provider` is empty / falsy.{ "seq": 8, "type": "result", "data": { "subtype": "success", "text": "Final reply", "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": 8, "type": "result", "data": { "subtype": "error_local_tool_timeout", "error": "...", "tokens": { "inputTokens": 980, "cachedTokens": 0, "reasoningTokens": 0, "outputTokens": 14 }, "turns": 2, "model": { "id": "platform:demo", "provider": "anthropic", "vendorModelId": "claude-opus-4-7" }} }// plan-only terminal result (see §4.9): the agent loop did NOT run. `data.plan`// carries the structured checklist; `turns` is 0 and `tokens` are zeroed// (classifier usage is metered separately under the `task_planning` surface).{ "seq": 5, "type": "result", "data": { "subtype": "success", "text": "Compare Q3 vs Q4 revenue.\n\n1. Pull revenue\n2. Compute deltas", "plan": { "brief": "Compare Q3 vs Q4 revenue.", "steps": [ { "title": "Pull revenue", "status": "pending" }, { "title": "Compute deltas", "status": "pending" } ] }, "tokens": { "inputTokens": 0, "cachedTokens": 0, "reasoningTokens": 0, "outputTokens": 0 }, "turns": 0, "model": { "id": "platform:demo", "provider": "openai", "vendorModelId": "gpt-5.4-mini" }} }{ "seq": 8, "type": "cancelled", "data": {} }A run terminates with exactly one of result or cancelled. The connection
is closed by the server immediately after sending the terminal event. Clients
should not assume any particular ordering between the human-readable event:
field and the parsed type inside data — they are always equal, but
implementations should rely on data.type because some HTTP middleware
strips the event: line.
7.1 Cost-attribution fields (tokens, turns, model)
Section titled “7.1 Cost-attribution fields (tokens, turns, model)”Every terminal result SSE event (and every terminal error event on
platforms that emit it — see docs/wire-protocol.md §4.7) 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 and surfaced by that endpoint.
| Field | Type | Notes |
|---|---|---|
tokens |
object | Per-run token totals aggregated across every model invocation. See schema below. |
turns |
int | Total engine.completeTurn(...) invocations for the run. Counts the failing call too — so a single-shot run is 1, a tool loop is >= 2, and a run that errored on its first model call is 1. Distinct from “tool turns” — turns is model invocations, regardless of whether the model called any tools. |
model |
object | Resolved model that actually executed the run. See schema below. |
Always present on the terminal event 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:
| 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).
model schema — fields the platform stamps onto every successful
or failed run:
| Field | Type | Notes |
|---|---|---|
id |
string | Catalog id — the same string a caller would pass back as modelId 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". 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:
| 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 |
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 terminal event carries the cumulative totals; no
per-turn breakdown is in the terminal event (use the
assistant_message events for per-turn observability).
Snapshot exposure. GET /api/v1/workspaces/{slug}/agent-runs/{runId}
also returns tokens / turns / model on the run snapshot JSON, with
the same wire shape. The keys are always present (as null until the
worker writes the terminal event, and on legacy rows pre-rollout) so
SDK clients can probe server capability via "tokens" in body without
triggering an undefined-vs-null distinction across HTTP/JSON
serialization.
A2A exposure. The MANTYX-hosted A2A endpoint
(POST /api/a2a/{workspaceSlug}/agents/{agentSlug}) returns the same
triple on the JSON-RPC response under result.metadata.mantyx:
{ "result": { "kind": "message", "messageId": "msg_abc", "role": "agent", "parts": [{ "kind": "text", "text": "Final reply" }], "metadata": { "mantyx": { "tokens": { "inputTokens": 1283, "cachedTokens": 512, "reasoningTokens": 96, "outputTokens": 240, }, "turns": 3, "model": { "id": "platform:demo", "provider": "openai", "vendorModelId": "gpt-5.4-mini", "reasoningEffort": "low", }, }, }, },}The metadata.mantyx block is omitted entirely against legacy runners
that haven’t implemented runWithUsage on the A2A adapter (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.
8. Local tool result
Section titled “8. Local tool result”POST /api/v1/workspaces/{slug}/agent-runs/{runId}/tool-resultsContent-Type: application/json
{ "toolUseId": "tu_x", "result": "127.0.0.1 localhost", // OR "error": "ENOENT: no such file", "files": [ // optional; only with `result` { "filename": "hosts.txt", "mimeType": "text/plain", "data": "<base64>" } ]}200 OK on accept. 404 unknown_tool_use if the toolUseId is unknown,
already satisfied, or the run has terminated. 409 run_terminal if the run
has already produced a result event.
result MUST be a string; SDKs serialize structured outputs as JSON before
posting. Errors are surfaced to the model as a tool-error response.
A client-resolved tool may also return files by attaching a files array
alongside result. Each entry is { filename, mimeType, data } where data
is base64 (no data-URL prefix) and mimeType is an allowed attachment type.
The bytes are surfaced to the model on the next turn as native file parts
(Anthropic / Gemini / Bedrock inside the tool_result; OpenAI as a synthetic
follow-up user turn) — the same pipeline used by server-resolved tools that
return files. Up to 20 files per result; the combined decoded size is capped
(currently 5 MB) and files over the inline threshold are persisted to object
storage and forwarded by reference. files is ignored when error is set;
for large artifacts, upload them out of band and reference a URL in result.
9. Cancellation
Section titled “9. Cancellation”POST /api/v1/workspaces/{slug}/agent-runs/{runId}/cancelIdempotent. The run will produce a terminal cancelled event on the SSE
stream. In-flight tool-results posted after cancellation are accepted with
200 OK but ignored.
9a. Run feedback
Section titled “9a. Run feedback”POST /api/v1/workspaces/{slug}/agent-runs/{runId}/feedbackRecord thumbs up/down feedback on a completed (or in-flight) run — the same
ReplyFeedback signal the web product collects on chat replies and artifacts,
extended to ephemeral SDK runs. Requires the feedback:write scope.
Request body:
{ "verdict": "UP", // "UP" | "DOWN" (required) "explanation": "Nailed it", // optional free-text note (≤ 8000 chars) "contentSnapshot": "..." // optional; defaults to the run's finalText}The feedback is idempotent per run: one verdict per (workspace, run). A
repeated call updates the existing row (last-write-wins) and returns 200; the
first call returns 201. Response:
{ "id": "fb_…", "verdict": "UP", "targetKind": "agent_run", "agentRunId": "run_…" }The author is attributed to the calling workspace API key (apiKeyId); OAuth
tokens leave the author null. Recorded feedback surfaces in the workspace
API feedback dashboard (Developer area) and the read-only
GET .../reply-feedback listing (scope feedback:read; agent-run feedback
only — in-product chat and artifact feedback is workspace-admin only). Unknown
runs return 404 { "error": "Run not found" }.
10. Errors
Section titled “10. Errors”All non-2xx responses use this body shape:
{ "error": "invalid_model", // machine-readable code "message": "Model 'foo' is ambiguous; pick one of: provider:cm6...", "candidates": [ /* sometimes present */ ],}Common codes:
| Code | HTTP | Notes |
|---|---|---|
unauthorized |
401 | Missing/invalid API key |
not_found |
404 | Workspace, run, or session unknown |
invalid_request |
400 | Body failed Zod validation |
invalid_model |
400 | modelId couldn’t be resolved |
unknown_tool_use |
404 | Tool-result for an unknown toolUseId |
run_terminal |
409 | Tool-result after run finished |
rate_limited |
429 | Per-API-key sliding window |
11. Suggested client architecture
Section titled “11. Suggested client architecture”A reference SDK should:
-
Hold the API key + workspace slug and a small
fetch(or stdlib HTTP) client. -
Maintain three local-callback registries (or one tagged-union registry), keyed by tool
name:- Generic local tools (
kind: "local") — caller-supplied handler functions, dispatched byname. Accept developer-supplied input and output schemas (Zod, Pydantic, JSON Schema, …) and serialize to JSON Schema before submission asparameters/outputSchema. Surface alongRunningknob on the tool builder so callers can opt into the model-side “don’t double-call” hint without hand-editing the description. - Local A2A peers (
kind: "a2a_local") — caller-supplied A2A clients. Resolve the peer’s Agent Card first (e.g.fetch "<peer>/.well-known/agent-card.json"or read from a local registry), attach it to the spec asagentCard, and in the dispatcher look the client up byagentCard.url(or any other field you indexed on) when thelocal_tool_callarrives. - Local MCP servers (
kind: "mcp_local") — caller-supplied MCP client connections. SpeakInitializeandtools/listonce at setup, ship the verbatimtools[](withinputSchema) plus optionalserverInfo, and dispatch incoming calls by themcpServerfield in the event payload.
mantyx,mantyx_plugin,a2a, andmcprefs are server-resolved — no SDK-side registry needed. - Generic local tools (
-
On
runAgent/session.send:- Accept
reasoningLevelfrom the caller and pass it through unchanged (string"off" | "low" | "medium" | "high"or number0–100); do not translate to a vendor-specific knob — the server owns that mapping so all SDKs stay aligned with the web composer. - POST the run/message, get
{ runId, streamUrl }. - Open the SSE stream with
Last-Event-IDif reconnecting. - On
local_tool_call, dispatch by the event’skinddiscriminator (defaulting to"local"when omitted): generic registry / local A2A client / local MCP client. Validate args against the tool’s schema, run it, POST the result back to.../tool-results. - Treat
thinking_deltaevents as opt-in callback fodder; many UIs hide them by default. Their presence depends onreasoningLevel > 0and on the active model exposing thought parts. - Accept
loopDetection,toolBudgets, andsupervisorfrom the caller and pass them through unchanged (see §4.6 / §4.7 / §4.8).loopDetectionandtoolBudgetsare additive: omitting them keeps MANTYX’s runtime defaults; passingloopDetection: falseopts out; passingtoolBudgets: {}clears the defaults.supervisorandplanare opt-in — omitting them keeps both off; passsupervisor: true/{}/{ interval?, modelId? }orplan: "auto"/"required"/{ … }to enable. - Treat
loop_detected,tool_budget_exceeded, andsupervisorSSE events as observability-only — the server already substituted synthetic tool-results / steering nudges / supervisor verdicts where applicable, so the SDK’s job is just to surface the event to the caller (status banner, log line, telemetry). Do not abort the run on these events; the run continues throughresult/error/cancelledas usual. - Accept
planfrom the caller (off/auto/required, the boolean compatibility aliases, or{ mode?, steps?, brief?, planOnly? }) and pass it through unchanged (see §4.9). Rendertask_plan.data.planas the canonical checklist. Apply snapshots idempotently byplanId+revisionand use run-eventseqto order multiple transitions in one revision. ForplanOnlyruns read the final checklist fromresult.data.plan. - On terminal
result, resolve the call. Onerrorsubtype, throw.
- Accept
-
Re-emit assistant deltas/events as a stream/iterator for callers who care about live output.
-
Treat the protocol as the contract. Implementation details such as Valkey pub/sub or pgvector are server-side only.
-
Execution model (server-side, informational). Runs are executed out-of-process by the inbound worker off a dedicated
mantyx:agent-runsRabbitMQ queue; the API only persists the run row and enqueues. Nothing about this is observable on the wire — clients still see202 { runId, streamUrl }followed by the same SSE vocabulary — but it means thelocal_tool_call↔tool-resultsround-trip is valid across any API or worker replica, and transient broker failures surface as a terminalerrorevent on the stream, not as a 5xx on the initial POST.
The npm package @mantyx/sdk and the Go module
github.com/mantyx/mantyx-go-sdk are reference implementations of this protocol
(maintained in the official mantyx-sdk repositories).
