Skip to content
MANTYX.IO

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_local Agent Card, mcp_local Tool[]) the SDK is expected to ship.
  • docs/agent-runs.md — server-side overview, internals, deployment notes.

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 informational tool_result event 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 a local_tool_call event and blocks until the SDK POSTs back to .../tool-results. The event payload carries a kind discriminator ("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.

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 to spec.agentId and ephemeral runs created from the key.
  • Plan gate — the workspace tier must include the apiKeys feature.

API keys carry no granular scopes; possession of a Developer-API key is enough to call every route in this document.

OAuth tokens are a drop-in alternative for the same set of routes, with two differences:

  1. Scopes are required. Each route checks the token carries the right scope via requireScope(...) and returns 403 { "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 .../models models:read
    POST .../agent-runs runs:write
    GET .../agent-runs/{runId} runs:read
    GET .../agent-runs/{runId}/stream runs:read
    POST .../agent-runs/{runId}/cancel runs:write
    POST .../agent-runs/{runId}/tool-results runs:write
    POST .../agent-runs/{runId}/feedback feedback:write
    POST .../agent-sessions sessions:write
    GET .../agent-sessions sessions:read
    GET .../agent-sessions/{sessionId} sessions:read
    GET .../agent-sessions/{sessionId}/events sessions:read
    DELETE .../agent-sessions/{sessionId} sessions:write
    POST .../agent-sessions/{sessionId}/messages sessions:write
    GET /api/oauth/userinfo mantyx.identity:read

    For 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 add mantyx.identity:read if the SDK calls /api/oauth/userinfo to discover the workspace slug after sign-in.

  2. 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 returns 404 not_found plus a hint with 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 both client_secret and code_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_in runs out, or lazily on the first 401). 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.1
Authorization: Bearer mantyx_at_… # OAuth access token
# — or —
Authorization: Bearer mantyx_… # workspace API key
Content-Type: application/json
{ "modelId": "openai:gpt-5.5", "prompt": "...", "tools": [...] }
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.
GET /api/v1/workspaces/{workspaceSlug}/models

Returns 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 catalog id (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.

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.

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:

  • role is one of user, assistant, system. system messages are applied as additional system context, appended after the spec’s systemPrompt (both apply). A run is valid when it has an agentId, a systemPrompt, or at least one non-empty system message in messages.
  • The last non-system message must be role: "user" (400 otherwise) — it is the current turn the model responds to, and the only message whose attachments are 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:

  • systemPrompt becomes optional. If omitted, the server uses the agent’s stored system prompt at run time.
  • modelId becomes 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 tools array is merged on top. This is typically used to attach local tools 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 empty agentIds allowlist (= “all agents”) work for any agent in the workspace; otherwise the agent must be in the key’s allowlist or the call returns 403 forbidden.
  • An unknown / cross-workspace agentId returns 403 (the API key check fires first); a malformed agentId returns 400.

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.

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 dials agentCardUrl directly. 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.)

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. headers are forwarded as-is by the SDK API. If you need long-lived credentials (refresh tokens, rotating API keys), register the peer as a workspace ExternalAgent instead — those headers support {{secret:NAME}} resolution against the workspace secrets store (see runtime/a2a-client.ts). The wire-protocol a2a ref is best for short-lived per-run tokens minted by your application.

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’s delegate_to_<name> call into a local_tool_call event and waits for the SDK to POST back the reply text.

Per-run lifecycle:

  1. Resolution (SDK). Before submitting the spec, the SDK obtains the peer’s A2A Agent Card — typically by fetching /.well-known/agent-card.json from the local peer, or by reading it from a config file / registry / inline constant.
  2. Submission (SDK → MANTYX). SDK posts the spec with the resolved card embedded as agentCard. MANTYX uses the card’s name, description, and skills[] to compose the model-facing tool description so the LLM understands what the peer can do.
  3. Tool call (MANTYX → SDK). When the model calls the tool, MANTYX emits a local_tool_call event with kind: "a2a_local", args: { message: string }, and the full agentCard echoed back so the SDK can route to the right local A2A handler (matching by URL, name, skill set, or any other field).
  4. 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.
  5. 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.

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’s name (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.
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.

MANTYX does no MCP work for this kind. It does not speak Initialize, tools/list, or tools/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:

  1. Discovery (SDK). Before submitting the spec, the SDK connects to its local MCP server, speaks Initialize (capturing the Implementation block as optional serverInfo), then calls tools/list. The resulting Tool[] array is shipped verbatim as tools[].

  2. Submission (SDK → MANTYX). SDK posts the spec with the resolved catalog. Field names match the MCP spec exactly — inputSchema, not parameters — so a TypeScript SDK can pass through what its MCP client already decoded. The tools[].name values 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 want fs/read_file to surface as fs_read_file, declare it that way).

  3. Tool call (MANTYX → SDK). When the model calls a tool, MANTYX emits a local_tool_call event with kind: "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",
    },
    },
    }
  4. Execution (SDK). SDK validates args against the locally-known inputSchema, speaks MCP tools/call, flattens the response content blocks (typically the joined text blocks), and POSTs the result back to .../tool-results.

  5. Refresh (optional). To pick up new tools mid-session, send the updated mcp_local ref inside POST /agent-sessions/:id/messages’s tools field; 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 Responsesreasoning.effort on 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 equivalent thinkingBudget token count.
  • Anthropic / Bedrock-Anthropic — extended thinking with a budget that scales with strength (≈512 tokens at low → ≈8000 at high).

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

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.

  • consecutiveThreshold rounds in a row (default 3) — 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.
  • hardCutoffThreshold rounds in a row (default 6) — 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.

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.

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 interval LLM calls (completeTurn invocations) 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. A redirect / finalize verdict aborts the in-flight turn and steers the next one. Enabled by default; tune or disable with reasoningTrigger. 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.

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 through POST /agent-sessions/:id/messages.
  • POST /agent-sessions/:id/messages { metadata } — optional per-message override. The server snapshots session.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.

POST /api/v1/workspaces/{slug}/agent-runs
GET /api/v1/workspaces/{slug}/agent-runs/{runId}
GET /api/v1/workspaces/{slug}/agent-runs/{runId}/stream
POST /api/v1/workspaces/{slug}/agent-runs/{runId}/tool-results
POST /api/v1/workspaces/{slug}/agent-runs/{runId}/cancel
POST /api/v1/workspaces/{slug}/agent-runs/{runId}/feedback

POST /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.

POST /api/v1/workspaces/{slug}/agent-sessions
GET /api/v1/workspaces/{slug}/agent-sessions
GET /api/v1/workspaces/{slug}/agent-sessions/{sessionId}
GET /api/v1/workspaces/{slug}/agent-sessions/{sessionId}/events
POST /api/v1/workspaces/{slug}/agent-sessions/{sessionId}/messages
DELETE /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.

GET .../agent-runs/{runId}/stream returns text/event-stream. Reconnects support both:

  • the standard Last-Event-ID request header (set automatically by browser EventSource), 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: 17
event: <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
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 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.

POST /api/v1/workspaces/{slug}/agent-runs/{runId}/tool-results
Content-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.

POST /api/v1/workspaces/{slug}/agent-runs/{runId}/cancel

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

POST /api/v1/workspaces/{slug}/agent-runs/{runId}/feedback

Record 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" }.

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

A reference SDK should:

  1. Hold the API key + workspace slug and a small fetch (or stdlib HTTP) client.

  2. Maintain three local-callback registries (or one tagged-union registry), keyed by tool name:

    • Generic local tools (kind: "local") — caller-supplied handler functions, dispatched by name. Accept developer-supplied input and output schemas (Zod, Pydantic, JSON Schema, …) and serialize to JSON Schema before submission as parameters / outputSchema. Surface a longRunning knob 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 as agentCard, and in the dispatcher look the client up by agentCard.url (or any other field you indexed on) when the local_tool_call arrives.
    • Local MCP servers (kind: "mcp_local") — caller-supplied MCP client connections. Speak Initialize and tools/list once at setup, ship the verbatim tools[] (with inputSchema) plus optional serverInfo, and dispatch incoming calls by the mcpServer field in the event payload.

    mantyx, mantyx_plugin, a2a, and mcp refs are server-resolved — no SDK-side registry needed.

  3. On runAgent / session.send:

    • Accept reasoningLevel from the caller and pass it through unchanged (string "off" | "low" | "medium" | "high" or number 0–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-ID if reconnecting.
    • On local_tool_call, dispatch by the event’s kind discriminator (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_delta events as opt-in callback fodder; many UIs hide them by default. Their presence depends on reasoningLevel > 0 and on the active model exposing thought parts.
    • Accept loopDetection, toolBudgets, and supervisor from the caller and pass them through unchanged (see §4.6 / §4.7 / §4.8). loopDetection and toolBudgets are additive: omitting them keeps MANTYX’s 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: "auto" / "required" / { … } to enable.
    • Treat loop_detected, tool_budget_exceeded, and supervisor SSE 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 through result / error / cancelled as usual.
    • Accept plan from the caller (off / auto / required, the boolean compatibility aliases, or { mode?, steps?, brief?, planOnly? }) and pass it through unchanged (see §4.9). Render task_plan.data.plan as the canonical checklist. Apply snapshots idempotently by planId + revision and use run-event seq to order multiple transitions in one revision. For planOnly runs read the final checklist from result.data.plan.
    • On terminal result, resolve the call. On error subtype, throw.
  4. Re-emit assistant deltas/events as a stream/iterator for callers who care about live output.

  5. Treat the protocol as the contract. Implementation details such as Valkey pub/sub or pgvector are server-side only.

  6. Execution model (server-side, informational). Runs are executed out-of-process by the inbound worker off a dedicated mantyx:agent-runs RabbitMQ queue; the API only persists the run row and enqueues. Nothing about this is observable on the wire — clients still see 202 { runId, streamUrl } followed by the same SSE vocabulary — but it means the local_tool_calltool-results round-trip is valid across any API or worker replica, and transient broker failures surface as a terminal error event 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).