Runs & streaming
A run is one turn: input messages in, events while it works, an output record at the end. Runs are where every other feature meets: tools pause them, approvals gate them, usage meters them, traces time them.
Anatomy of a run
POST /v1/smiths/{sid}/runs
→ queued → running → completed
↘ paused_for_tool (an external-execution tool needs you)
↘ paused_for_approval (a human must approve)
↘ failed / cancelled
A paused run resumes when you submit the missing piece via /submit,
the same endpoint for tool results, approval decisions, and cancellation.
Every run carries an actor — who started it, resolved from the token at the
point of action: { "kind": "smith" | "tenant" | "operator", "id": …, "token_id": …, "email": … }. A run a tenant-admin token drove names the tenant
and that token; one a schedule or an inbound channel message drove names the
smith with an empty token_id (no token acted). email names the human when
there is one — a signed-in console user or an Ingram operator — and is empty for
a machine caller. Every event the run produces carries the same
actor.
Threads carry conversation history: pass a stable thread_id per
conversation (any string of yours, or omit it and one is minted with a
thr_ prefix and returned on the run) and the smith sees the recent turns of
that thread.
input is a list of messages: { "role": "user" | "assistant", "content": "<text>" }. content is a plain string for a text turn, or an array of typed
parts ({ "type": "text" | "image" | "file", … }) for a multimodal turn — when
you read a run back, a turn that carried an image or file comes back in the
parts form. Multiple messages are allowed (e.g. to replay context); system
behaviour comes from the smith's resolved instructions, not a system
message. For long turns prefer "stream": true. A synchronous call holds the
connection open for the whole turn.
Synchronous runs
# Authorization: tenant-admin token (server-side only), or a smith token
curl https://api.cloud.ingram.tech/v1/smiths/smt_…/runs \
-H "Authorization: Bearer $IC_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "input": [{ "role": "user", "content": "Summarize what we discussed yesterday." }],
"thread_id": "chat_42" }'
The response is the run record:
{ "id": "run_…", "smith_id": "smt_…", "thread_id": "chat_42",
"status": "queued | running | completed | paused_for_approval | failed | cancelled",
"output": { "content": "…", "tool_calls": [ { "id": "call_1", "type": "function",
"function": { "name": "get_weather", "arguments": "{…}" } } ] },
"stop_reason": "end_turn",
"warnings": [],
"usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0,
"cost": 0.0241 } }
When a prompt-cache slice was served or written, usage also carries
cache_read_tokens / cache_write_tokens — see
Models → Prompt caching.
Why a run ended
stop_reason is why the turn stopped. It is the same value on the run record, on
the event feed, and on every stream frame.
stop_reason | Meaning |
|---|---|
end_turn | the model finished its answer |
max_tokens | the answer was cut off at the model's output limit — the content is partial |
content_filter | the provider's safety filter stopped the turn |
tool_use | the turn ended on tool calls you must execute (the inline tools loop) |
paused | the run is waiting on an approval |
approval_rejected | an approval was rejected, so the tool never ran |
approval_expired | nobody decided the approval in time |
schema_error | the model did not answer in the response_format schema |
invalid_response_format | the response_format you sent is unusable |
model_key_missing | no model key is configured for the model the run asked for |
resume_error | the run failed while resuming from an approval |
lane_timeout | a queued delivery held the smith's lane too long |
error | the run failed; read error |
A cancelled run carries your own /submit kind=cancel reason instead (or
run_timeout / client_disconnected when the platform cancelled it).
Branch on max_tokens to retry or continue a cut-off answer: status is
completed and output.content holds the partial text.
output.tool_calls follows the OpenAI tool_calls shape ({ id, type, function })
for a model-driven call; an approval pause instead surfaces a
pending-call object naming the tool and its arguments. A structured run (one that
passed response_format) sets output.content_type (e.g. application/json) so a
reader knows how to parse output.content. When the agent offered
quick-reply chips on a chat channel,
output.suggested_replies lists the labels it presented. usage reports this run's own token
counts and, when the model has a price-book entry, its priced cost in your
account currency — the run's own line-item, no separate query. (A turn on an
unpriced model carries tokens but omits cost.) The usage API
is where those are aggregated across runs into your billing summary.
Reads: GET /v1/smiths/{sid}/runs/{rid} (one),
GET /v1/smiths/{sid}/runs (per smith),
GET /v1/runs?status=&smith_id=&agent_id= (project-wide feed; agent_id
pulls every run across one agent's smiths),
GET /v1/smiths/{sid}/runs/{rid}/events (SSE replay of the recorded log;
each frame's id: is its sequence number, so a reconnect with the
Last-Event-ID header resumes past the last frame you saw instead of
replaying the whole log).
What the run ran without
warnings is what went wrong during the run without stopping it. It is always
present — [] on a run that got everything it asked for — so absence never has
to be interpreted:
"warnings": [{ "code": "tool_source_unavailable",
"message": "MCP server \"librarian\" was skipped: runtime load failed. Its tools did not reach the model." }]
code | Meaning |
|---|---|
tool_source_unavailable | a registered MCP server failed to load; its tools never reached the model |
A run whose tools were unreachable still completes. The model answers from
what it had left, and that answer is shaped exactly like a real one — so a
consumer that branches only on status cannot tell the two apart. Branch on
warnings being non-empty when your program cannot be trusted to have answered
degraded: retry it, or fail it on your side. The same list rides the
run.completed event and stream frame, so a caller watching only
the stream sees it too. metadata.tools has the
full detail behind each one.
The trace for a run
GET /v1/runs/{rid}/trace returns the run's execution trace — the timed span
waterfall (model calls, tool calls, memory ops) with token counts and cost —
so "why was this run slow / what did it cost" is a direct lookup keyed by the
run, not a hunt through GET /v1/traces. Every trace also carries its
run_id, so GET /v1/traces?run_id={rid} filters the trace list the same way.
Every run gets one, whichever surface started it — native, Chat Completions,
Responses, with client tools or without, structured output included. A 404
here means the run predates tracing, not that its surface skips it.
Which tools the run resolved
Every run records the tool set it resolved under metadata.tools, so "did my
MCP servers actually reach the model?" is a one-read fact instead
of something you infer from the model behaving as if it had none:
"metadata": { "tools": {
"total": 4,
"mcp": [{ "server": "librarian", "tools": 3 }],
"hosted": ["web_search"], "deployment": 0, "memory": 0,
"errors": [{ "server": "acme", "error": "runtime load failed: stored secret could not be decoded" }]
} }
mcp lists each registered MCP server with the number of its tools that passed
the allow-list and reached the model — an empty list or a 0
count is the signal that a server contributed nothing to this run. errors names
any registered server the run skipped and why (e.g. a stored secret that can
no longer be decoded), so a missing tool reads as a recorded reason rather than a
guess — the same failure also flips that server to degraded on
GET /v1/tenant/mcp, and surfaces on the run's own
warnings.
Every run also records the config it resolved under metadata.config — the
agent version and the effective model/instructions/tools it ran with — so a run
is self-describing after the fact, and replay can pin it.
Streaming with SSE
"stream": true returns text/event-stream. Every event is one envelope:
event: carries the type, data: a JSON object that always includes
{ "v": 1, "run_id": "…" }. Prefer off-the-shelf SDKs for an in-app chat tab?
The OpenAI-compatible API projects this same loop onto
the OpenAI Chat Completions wire format, and the Vercel AI SDK
adapter wraps that in a streamText/useChat integration. Reach
for this native envelope when you want live tool.executing frames, structured
output, or server-to-server calls.
| Event | Payload | What to do |
|---|---|---|
run.started | { smith_id, thread_id } | capture run_id |
message.delta | { delta } | append the text chunk |
tool.executing | { tool } | informational: a tool is running |
tool.completed | { tool } | informational: a tool finished |
approval.required | { approval_id, tool, args, tool_call_id } | a human decides; submit via /submit |
run.paused | { reason, tool_calls } | the run is waiting on you |
run.completed | { stop_reason, warnings? } | done — check stop_reason (values) and warnings |
run.cancelled | { reason } | the run was cancelled (/submit kind=cancel); the stream ends |
run.restarted | { reason, attempt } | the server driving this run was lost before it invoked any tool, so the run went back on the queue under the same id (background responses) |
reasoning | { id, text } | one finished reasoning block, recorded where it happened (reasoning output); timeline only — never sent to webhooks |
run.failed | { error } | give up or retry |
run.duplicate | { run_id, reason, run } | a retried Idempotency-Key matched an existing run; reconnect/poll it instead (see below) |
Both hosted tools (e.g. web_search) and your MCP tools execute
server-side. Ingram Cloud calls them and the stream just keeps going, surfacing
the informational tool.executing / tool.completed frames as they happen. Those
frames are also mirrored to the run timeline and the event feed,
so a run's tool activity stays auditable after the fact. You only act on an
approval.required pause.
When a run pauses you first get an approval.required event per pending call
and then a single run.paused as the terminal marker. Act on the per-call
events; treat run.paused as the state change. The streamed run.completed
carries stop_reason but not always usage. Read the run record for
authoritative token counts and cost.
One run at a time per smith
A smith runs one turn at a time. Its memory is a single doc
shared by every one of its conversations, so two turns in flight would be two
writers of one document — the second would silently overwrite what the first
learned. Start a run while that smith already has one going and you get 400
with code conversation_locked:
{
"error": {
"message": "Another process is currently operating on this conversation. Please retry in a few seconds.",
"type": "invalid_request_error",
"code": "conversation_locked"
}
}
Retry in a few seconds — the running turn ends within its deadline. A different
thread_id does not avoid this: the lock is the smith, not the conversation.
A run paused awaiting an approval does not hold it, so an
unanswered approval never blocks the next message.
Two things are never locked, because neither touches memory: a smith with
auto_memory off, and the stateless structured one-shot
(response_format with no thread_id). Both stay fully parallel — use them when
you want a smith to serve many requests at once.
Autonomous turns — schedules, inbound channel messages — queue instead of failing, and run when the smith is free.
Idempotent run creation
Creating a run is not free. A retry after a network timeout would otherwise
start (and bill) a second run. Send an Idempotency-Key header on
POST /v1/smiths/{sid}/runs and a replay with the same key returns the original
run instead of starting a new one:
# Authorization: tenant-admin token (server-side only), or a smith token
curl https://api.cloud.ingram.tech/v1/smiths/smt_…/runs \
-H "Authorization: Bearer $IC_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Idempotency-Key: 8f3c…" \
-H "Content-Type: application/json" \
-d '{ "input": [{ "role": "user", "content": "…" }], "stream": true }'
This covers streaming runs too, exactly the case most likely to time out
mid-turn. The original token stream can't be replayed, so a retried key returns
a single run.duplicate event carrying the existing run's id and current state;
reconnect to that run's recorded event log
(GET /v1/smiths/{sid}/runs/{rid}/events) or poll GET …/runs/{rid}. A
synchronous retry simply returns the original run record. Keys are scoped to your
tenant and honoured for 24 hours; use a fresh key per distinct run.
Replay a run
Re-run a recorded run's input through the smith as it stands now:
# Authorization: tenant-admin token (server-side only), or a smith token
curl https://api.cloud.ingram.tech/v1/smiths/smt_…/runs/run_…/replay \
-H "Authorization: Bearer $IC_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "stream": false }'
The reply is a fresh run record, carrying the same input, on its own new
thread (the original conversation is untouched), with metadata.replay_of set
to the source run id. "stream": true streams the replay like any create.
Replay pins the config the original run recorded (metadata.config — the
resolved model, instructions, tools, and the agent version in effect then), so a
config change since — an agent version rolled forward, the smith's overrides
edited — doesn't make the replay diverge. The response says which config it used:
metadata.replay.deterministic (true when pinned) and
metadata.replay.agent_version. Runs created before config snapshotting shipped
carry no snapshot and fall back to the smith's current config
(deterministic: false). Memory and model-provider state are always live, not
pinned — replay reproduces the config, not the whole world, so output can still
differ. A file attachment on the original input is rehydrated from storage and
re-sent; if a referenced file is no longer retrievable, replay returns 422.
Pause and resume: the universal /submit
One endpoint resumes everything, discriminated by kind:
# Authorization: tenant-admin token (server-side only), or a smith token
curl https://api.cloud.ingram.tech/v1/smiths/smt_…/runs/run_…/submit \
-H "Authorization: Bearer $IC_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "kind": "tool_result", "tool_call_id": "tc_…",
"result": { "events": ["standup at 10:00"] }, "stream": true }'
kind: "approval_decision":{ approval_id, decision: "approve" | "reject", actor }. The common resume: onapprove, Ingram Cloud calls your MCP tool and continues; a rejection completes the run withstop_reason: "approval_rejected". Pass"stream": trueto pump the continuation back in the same envelope.kind: "tool_result":{ tool_call_id, result }, only for an external-execution tool that paused the run (MCP tools run in-process and never need this).kind: "cancel":{ reason }; the run ends ascancelledand its in-flight model stream is aborted, so token generation — and the billing for it — stops immediately. Allowed from any state, and final: a turn still finishing elsewhere cannot reopen the run ascompleted, and is not billed. A client that drops the connection mid-stream cancels the run the same way (stop_reason: "client_disconnected").
Generation is bounded either way: a model stream still running after 15
minutes is cut off, ending the run cancelled with stop_reason: "run_timeout".
A run left paused for approval expires after 24 hours: it completes with
stop_reason: "approval_expired" and its approval can no longer be submitted.
Decide within the window, or start a fresh run.
The whole loop with approvals: stream the run → on approval.required, get a
human decision → submit approval_decision with stream: true → keep pumping
→ run.completed.
Structured output
For server-side calls that must return schema-valid JSON (classify, extract,
route), pass response_format. On its own — no thread_id, against an agent
with no tools of its own — the run is a one-shot model call (no tools, no memory,
no streaming) using the smith's configured model:
# Authorization: tenant-admin token (server-side only)
curl https://api.cloud.ingram.tech/v1/smiths/smt_…/runs \
-H "Authorization: Bearer $IC_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "input": [{ "role": "user", "content": "<source text>" }],
"response_format": { "type": "json_schema", "name": "Ticket",
"strict": true, "schema": { "type": "object", "additionalProperties": false,
"properties": { "priority": { "enum": ["low", "high"] } },
"required": ["priority"] } } }'
# → output.content is a JSON *string* that parses and validates
Schema rules. Every object node must set "additionalProperties": false and
list all of its keys in "required" — the strict json_schema contract the model
provider enforces (strict: true or false makes no difference here). A schema
that breaks this is rejected fast with 422 schema_error, the error message carrying
the provider's reason; the run record is marked failed with stop_reason: schema_error. (Generating the schema with z.toJSONSchema satisfies this automatically; the
trap is hand-written schemas.) If the model can't produce valid output against a
valid schema, the API retries, then returns 500 structured_output_failed.
Use a dedicated smith with auto_memory: false for these utility calls.
With tools and memory. response_format never removes a capability. If the
agent has tools — MCP servers, hosted tools, an attached vector store — or you
send a thread_id, the turn stays a full agent run (its tools, its memory, as
many steps as it needs) and only the final message is typed. The intermediate tool-calling
steps are unconstrained, so an unattended run can do real work and still hand
back JSON your code can parse. output.content is the JSON string either way,
with output.content_type: "application/json"; a turn that can't produce valid
output against a valid schema fails with 500 structured_output_failed exactly
as the one-shot does. Streaming a typed turn delivers the answer whole — the
model's intermediate prose is not streamed, since it isn't the answer.
The same two shapes run on the OpenAI-compatible surfaces:
response_format (nested json_schema) on Chat Completions, text.format on
Responses.
Inspecting runs in the console
Observe → Runs lists every turn with status, tokens, and cost. A run's
detail page opens with the run's identity (status, smith, thread) and its vitals
(duration, tokens, cost, spans, model turns), then the timed span waterfall
(model calls, tool calls, retrievals, per-span cost) when the run has a trace.
Below it a detail band switches between Transcript (the conversation with
tool calls), Events (the lifecycle log), and Attributes (run metadata and
trace attributes). When a run is paused on an approval.required, the page
surfaces the pending tool call and its arguments inline with Approve /
Reject — resolving submits the decision on the run's standard tool-call
channel and resumes it. Replay (top right) re-runs the same input as a fresh
run and opens it. Runs fired from the Playground land here too.
Observe → Threads folds the recent run feed into threads (turns sharing one
thread_id) — smith, channel, run count, and last activity — so a thread is
browsable on its own; a row opens its latest run.