Skip to main content
The Agent Conversations API lets you ask a language model a question, have it call tools to gather more information, and get back a structured answer — all through a few HTTP calls. The model can use MCP-resolved tools (anything exposed by a Model Context Protocol server, like the Narrative docs search) or caller-declared tools (where it asks you a question and waits for your reply). This page is a complete reference: the moving pieces, the parameters, three end-to-end examples, and the most common things that go wrong.

What it does

A typical request goes like this:
  1. You create a conversation with a system prompt, a model choice, and a list of tools the model is allowed to use.
  2. You start a run by sending the model a user message.
  3. The model decides whether to answer directly or call one of the tools.
    • If it picks an MCP-resolved tool, the platform calls it for you and feeds the result back into the next round of reasoning. This can repeat several times.
    • If it picks a caller-declared tool, the run pauses and asks you to provide the answer. You start a new run with the answer; the model continues from there.
  4. Eventually the model produces a final answer matching the structured schema you provided. The run reaches completed and you fetch the result.
The platform keeps the full conversation in a database, so you can resume a paused run later, inspect every message the model saw, and start follow-up runs on the same conversation.
The shape mirrors the OpenAI Assistants API: a thread is a conversation, a run is one user-initiated turn, a step is one inference iteration inside a run. If you already know that mental model, the only new ideas here are tool aliases and optimistic-concurrency versioning (covered below).

API endpoints

All endpoints live under /agents and require a Bearer token with agent_conversations read/write permission. Authentication and malformed-input failures come back before any agent logic runs:
  • 401 — the token lacks the agent_conversations permission the endpoint needs, or has expired.
  • 403 — no bearer token, or one the platform does not recognize. The body is empty. A token with no user attached, such as a company API key, also gets a 403, with an {error, error_description} body. Conversations belong to a user, so these endpoints need a user token.
  • 404 with an empty body — a malformed id in the path, or a malformed since, page, or per_page query parameter.
  • 400 with an {error, error_description} body — a request body that isn’t valid JSON or doesn’t decode, for example an unknown payload.kind. Validation failures past that point use the RFC 7807 body described in Run status lifecycle.
Scope: per-user, not per-company. Conversations and runs are keyed on the bearer token’s (company_id, user_id) pair. Peers in the same company cannot see each other’s conversations, runs, or messages — every endpoint returns 404 for cross-user access, identical to cross-company access. The API does not distinguish “doesn’t exist” from “owned by another user”.
The first request (POST /agents/conversations) returns the conversation id you need for all the others. The fourth (POST .../runs) returns the run id you can poll with the fifth.

Listing your conversations

GET /agents/conversations returns the calling user’s conversations in created_at descending order, wrapped in the standard pagination envelope:
Default per_page is 50. Results are filtered by both company_id and user_id — peers in the same company do not see each other’s conversations (see the scope callout above).

Core concepts in one paragraph each

Conversation

The long-lived container. The system_prompt is pinned for life — once set at creation time, it applies to every run that follows and cannot be changed. Everything else in defaults (model, tools, max iterations, temperature, output schema, etc.) is exactly that — a default: it applies unless a particular run explicitly replaces it. The conversation also carries a monotonic version counter that every successful run bumps. The counter doubles as both a delta cursor (for GET .../messages?since=N) and a compare-and-swap token (more on that under Troubleshooting).

Run

One user-initiated turn of conversation. Each run kicks off a workflow on the platform that performs zero or more inference rounds plus tool calls. A run is asynchronous: the POST returns immediately with status: "pending", and you poll GET /agents/runs/{id} until it reaches a terminal state (completed, requires_action, or failed). A run may override the conversation’s defaults for that single turn via config_override — pick a different model, raise max_iterations for a hard question, swap in a different tool catalog, tighten temperature for a deterministic answer, etc. The only thing a run cannot change is the system_prompt (that’s permanently set on the conversation). Two run-only settings have no conversation-level counterpart: tool_choice (which biases the model toward a particular tool on the first iteration) and the run’s payload itself.

Tools

A run’s tool catalog comes from two sources, listed side by side in the conversation defaults:
  • mcp_servers[] — each entry has an alias (1–8 letters/digits, must start with a letter), a URL, and an optional description. Each url is validated up front at conversation/run create — it must be an absolute https URL with a host, and the host must be publicly reachable (loopback, private, link-local, IPv6 unique-local, unspecified, and multicast addresses are refused by the outbound egress guard). See Invalid MCP Server URL for the syntax check and MCP Discovery Failed for the reachability check. You do not declare individual tools — the platform discovers each server’s catalog at run start via the JSON-RPC tools/list method and stitches the wire names as {alias}-{tool_name} before handing them to the model. When the model emits a call to {alias}-{tool_name}, the workflow makes the tools/call request, gets the result, and feeds it back into the next inference round inside the same run.
  • tools[] — caller-declared tools you answer yourself. No alias — the model sees the bare name. When the model calls one of these, the run terminates with status: "requires_action"; you read the question from pending_tool_calls, decide on an answer, and start a new run with payload.kind: "tool_outputs" carrying your reply.
Every run also gets the platform’s waiting tools — job_monitor, wait_for and sleep. You do not declare them, and a caller-declared tool of the same name cannot replace them. They are what let a run pause for a long-running job instead of asking whether it has finished: see Waiting for Work to Finish.
Discovery is per-run, not cached. Every run re-fetches tools/list from each registered MCP server. There is no inter-run cache — if the server adds, removes, or renames a tool, the next run picks it up automatically. The trade-off: each new run pays one HTTP roundtrip per registered server before the first inference. For 1–3 typical servers this is in the noise (10–100ms total); if you ever register a slow-discovering server, expect it to delay every run’s first iteration. Discovery failures terminate the run with MCP Discovery Failed.
Narrative-owned MCP servers are authenticated automatically. When an mcp_servers[].url matches the Data Collaboration MCP Server (https://mcp.narrative.io/mcp in prod), the platform mints a Default-scoped API token for the conversation’s user and company and attaches it as Authorization: Bearer ... on every tools/list and tools/call request to that server. The token is created server-side, lives only for the duration of the request chain, and is never persisted to agent_runs.effective_config, Temporal event history, or the GET /agents/runs/{id} echo. Public or third-party MCP servers (any URL outside the allowlist) continue to be called without auth — unless you attach a connection_id (see next note).
External OAuth-protected servers use connection_id. For a third-party MCP server that requires the end user’s own OAuth authorization, first connect it via POST /mcp-connections — the platform performs Dynamic Client Registration (RFC 7591) and PKCE against the external authorization server, then persists the tokens KMS-encrypted. Reference the resulting id from the matching entry in mcp_servers[]:
At every tools/list and tools/call the platform resolves the bearer server-side — refreshing it if the access token has expired — and attaches it to the outbound request. The token never appears in the request payload, effective_config, run history, or GET /agents/runs/{id}. The connection must belong to the calling user and be in connected status; a connection owned by another user is treated as missing. See the MCP Connections reference for the full flow.If you register a server with no connection_id and it actually requires authorization, run-start discovery gets a 401 on tools/list and the run fails with MCP Discovery Failed telling you to connect it via POST /mcp-connections — and naming your existing connection_id for that URL if you already have one, so you can drop it into mcp_servers[] without re-running the OAuth flow.
Tool input schemas are normalized at discovery time. MCP servers can declare inputSchema shapes that Bedrock Converse and Anthropic Messages don’t accept verbatim — oneOf, $ref, format validators, additionalProperties: true, and similar JSON Schema features. The platform runs every discovered tool’s schema through a converter that translates oneOf to anyOf, inlines non-recursive $ref, replaces recursive $ref with {} to break cycles, forces additionalProperties: false, and lifts unsupported keywords (format, pattern, range validators) into the property’s description. Tools that previously had to be dropped wholesale because they carried $ref now load successfully; nothing is rejected at the discovery layer. The converter emits strict: false because Bedrock caps strict-tool counts per request — the model still gets the schema, and each MCP server validates arguments on its own side at call time.
The dash is the routing discriminator. MCP wire names always carry the {alias}- prefix; caller-declared tool names must not contain a dash. This is what lets the workflow classify a hallucinated or unknown call without ambiguity: dash + unknown alias → AgentLoopUnknownToolAlias; dash-free + unknown name → same error. The validation rule “no dash in tools[].name” is the cornerstone.

Harness tools

On top of mcp_servers[] and tools[], every run gets a set of harness tools the platform always provides. You do not declare them and cannot disable them. Their names are reserved — a caller-declared tool that takes one is refused with Reserved Tool Name. A sandboxed Linux computer, one per conversation: Waiting primitives, so a run does not spend its max_iterations re-checking whether work has finished: sleep and wait_for park the run as a durable Temporal timer, not a worker thread, so a waiting run survives a worker restart. While a run is paused, live.messages on GET /agents/runs/{id} shows what it is waiting for, and a second run posted on the same conversation gets a 409 Run Already In Progress naming the wait. Every one of these spends an iteration, like any other tool call: a run that starts a job, registers it with job_monitor, waits with wait_for, then answers uses four iterations. That is why max_iterations defaults to 10. job_monitor is scoped to the caller’s company — a job that does not exist and one owned by another company both come back without a handle, so the tool cannot be used to probe. Handles expire after 24 hours; the wait_for ceiling is well under that TTL so a handle cannot expire beneath a run still waiting on it. A wait_for timeout leaves the underlying tasks running — the model can call wait_for again on the same handles to keep waiting. sleep clamps arguments outside its 1 second – 1 hour range rather than refusing them; the result tells the model the value was clamped. A malformed argument comes back as an isError tool result the model can correct, never a loop failure.

Tool choice

Per-run policy that nudges the model toward (or away from) using tools on the first iteration. Three options:
  • {"kind": "auto"} — model decides. The default.
  • {"kind": "any"} — model must call some tool (no plain-text answer allowed).
  • {"kind": "specific_tool", "name": "confirm_booking"} — model must call exactly this caller-declared tool. To pin an MCP-resolved tool instead, include the explicit mcp_alias: {"kind": "specific_tool", "mcp_alias": "docs", "name": "search_kb"}. The platform validates the target against the catalog synchronously and returns 400 with Unknown Tool Choice Name or Unknown Tool Choice MCP Alias on a miss.
Both forcing options — any and specific_tool — are mutually exclusive with thinking. A run that asks for both is a 400; a run that forces a call without mentioning thinking runs without reasoning. See One thing you cannot combine it with. The choice only applies to iteration 1 of the current run. From iteration 2 onward inside that same run the model is back on {"kind": "auto"}. There is no carry-over to subsequent runs — every new run picks its own tool_choice afresh. That means you can absolutely re-impose a forced tool call on a follow-up run (for example, push the model toward a specific caller-declared tool every time you need to ask the user something), so long as you set tool_choice on the run’s request body.

Configuration parameters

When you create a conversation, you pass a defaults object that pins how every run on that conversation behaves. Each field can be overridden per-run via config_override, except where noted.
mcp_servers and tools are wholesale-replaced, not merged, when overridden in a run. If you set them in config_override, you replace the whole list. This keeps tool namespacing predictable.
output_format_schema is a JSON Schema subset. The platform accepts the same subset of Draft 2020-12 that Bedrock structured-output accepts. Common features that do not work: union types (oneOf, anyOf, allOf, not), schema composition ($ref, $defs), conditional shape (if/then/else), pattern regexes on strings, and format validators (date-time, email, etc.). Supported: type, properties, required, enum, additionalProperties: false, minimum/maximum on numbers, minLength/maxLength on strings, minItems/maxItems on arrays. See JSON Schema Reference for the full list with examples.

Thinking

Claude models can reason before they answer. Every Claude model does so by default — omit thinking and the model thinks as hard as it can. Send "disabled" for the cheap, direct answer. Thinking costs output tokens. On a five-constraint logic puzzle a model spent ~550 completion tokens reasoning its way to the right answer, against ~70 answering the same prompt with thinking off (and getting it wrong). That is the trade: better answers on hard problems, more tokens on every problem.

What each model accepts

Models take one of two vocabularies, and it goes by generation. Newer models are told how hard to think; older ones are given a token budget to spend on it. Reading the table:
  • Accepts — the shapes that model will take. Sending the other one is a 400, not a silent downgrade: an effort level on Haiku 4.5 is rejected, as is a token budget on Opus 5. Models that take either accept both.
  • Default when you say nothing — what the platform sends for you. The newer models get the top of the effort scale. The budget-only models get the floor, 1024 tokens, because a budget is a hard ceiling and a large default would silently multiply the cost of every call.
  • Reasoning text — whether the reasoning arrives as words you can show a user. The newest models return a signature only: proof they reasoned, with the words withheld. That is the model’s behaviour, not a platform choice, and no flag we know of changes it. Build for text being absent.
The OpenAI models cannot reason. Asking one to think is a 400; "disabled" is accepted, because it asks for what they already do. So a client that turns thinking off across the board needs no per-model special casing.

Choosing a setting

  • Leave it out for anything where a better answer is worth the tokens. This is the default.
  • "disabled" for short, mechanical, or high-volume work — classification, extraction, titles. A model that thinks by default is otherwise billed for reasoning nobody reads.
  • {"type":"effort","level":"low"} to keep reasoning on but cheap, where the model takes an effort scale. Note that low and medium may produce no reasoning at all on an easy prompt: how hard a model thinks is decided by the prompt, and effort only sets the ceiling.
  • {"type":"budget","budget_tokens":N} where the model takes a budget and you want a firm cap. N must be at least 1024 and smaller than max_tokens — reasoning is spent from the same output budget as the answer, so a budget that fills max_tokens leaves nothing to answer with (400).
Per-run config_override.thinking shadows the conversation’s setting, so one expensive turn in a cheap conversation (or the reverse) needs no new conversation.

One thing you cannot combine it with

Thinking and a forced tool call are mutually exclusive — both forcing forms, tool_choice: {"kind":"any"} as much as {"kind":"specific_tool"}. With a call forced, models either reject the request or quietly stop reasoning: no reasoning block, a fraction of the tokens, and nothing in the response to say thinking didn’t happen. {"kind":"auto"} is unaffected. Which of the two you get depends on who asked for what:
  • You asked for both — thinking on the run or the conversation, plus a forcing tool_choice — is a 400. Two explicit instructions that cannot both be honoured is worth telling you about rather than resolving on your behalf.
  • You forced a call and said nothing about thinking runs normally, without reasoning. Thinking by default is our choice and your tool_choice is yours, so the default gives way. Code that pins a tool needs no change.
  • The model doesn’t accept a forced call at all (Claude Sonnet 5.5 and Claude Opus 5.5) is a 400, with or without thinking.
To get reasoning and a particular tool call in one turn, use {"kind":"auto"} and name the tool in the message. A reasoning model that has been told what to call generally calls it, and you keep the reasoning. Structured output (output_format_schema) does not conflict, even though it uses a tool internally. That tool is not a choice of yours, and the platform handles the interaction for you.

What comes back

Reasoning arrives as reasoning content blocks on the assistant turn, ahead of the answer, and is persisted with the conversation. A turn may hold the model’s working and its reply as separate blocks — render the reply from final_answer_from, not from every text block.

Content blocks

Every message carries an ordered list of blocks, discriminated by type. Six variants exist: Blocks the platform produced also carry an id (b_<job>_<ordinal>), which is what final_answer_from points at. Blocks you sent have none. The last two appear when the run asked for thinking. A reasoning block comes first in the turn, ahead of the answer:
Render the reply from final_answer_from, not from every text block. That turn has two text blocks: the model narrating its working, and the answer. Both are real output and both are persisted — the working is kept so the whole lineage of a turn is inspectable — but only the one named in final_answer_from is the reply. A reader that renders every text block shows the working and the answer as two separate messages. final_answer_from always names text blocks that are present in the same message, and is empty on a turn that answered nothing: a tool-call turn, a user’s message, or anything stored before ids existed. On the live view it is empty while a turn is still streaming and fills in when the run finalizes. Two things to know before you build against it:
  • text is optional on a reasoning block. Some models return their working as words; others return only a signature and keep the words to themselves. A block with no text still means the model reasoned — and the tokens were billed — so don’t treat its absence as “nothing happened”.
  • signature is opaque, and it’s yours to use or ignore. It is not a hash you can verify or a token you can decode. Send the block back unchanged if you want the model to continue a turn exactly as it left it; drop it if you only want to display the answer. A continuation without reasoning blocks is accepted. Don’t edit one — the signature covers what the model produced.

Run payload shapes

POST /agents/conversations/{id}/runs accepts one of two payload kinds:
The full request body also includes:
  • client_op_id — a UUID you generate, unique per request. Used as an idempotency key — re-sending the same client_op_id returns the original run unchanged, with a 202 like the first call, whichever conversation that run is on. This lets you retry safely across network blips. The version check runs first, so a retry only gets the original run back while expected_version still matches. Once that run’s messages land, the conversation’s version moves on and the same retry gets a 409 Version Conflict. Read the original run with GET /agents/runs/{id} instead.
  • expected_version — the conversation’s current version, which you got from the most recent GET /agents/conversations/{id}. The platform rejects the run if the version has moved on since you read it (see Version conflicts).
  • tool_choice — optional, per-run only.
  • config_override — optional, sparse — only the fields you want to change.

Images

A run can send the model images, in the same shape as an Anthropic Messages API image block. Put them in a user message’s content, alongside text blocks, or in a tool output’s content:
Send either text (a string) or content (a string, or a list of text and image blocks). A message that is a single text block is read back as text. An MCP tool can return images too: they arrive as image blocks inside its tool_result. What the platform accepts, checked when you start the run: Every Claude model takes images on an AWS data plane. The OpenAI models (openai.gpt-oss-120b, openai.gpt-4.1, openai.o4-mini) take none. On a Snowflake data plane, or when an earlier turn’s images reach a model that can’t read them, the model gets a short note in place of each image and the run goes on. A run carries at most 1 MB of images in all, counting earlier turns and what MCP tools return. When a conversation holds more, the model sees the newest images that fit and a note in place of each older one. An image an MCP tool returns that a model can’t read, of an unsupported type or over the size limit, is replaced with a text block saying so. Messages read back from the conversation keep every image you sent. A first message with only images, and no text, leaves the conversation untitled: automatic titling works from the text.

Run status lifecycle

Every run starts at pending and eventually reaches one of three terminal states:
Poll GET /agents/runs/{id} every few seconds (start with 2–4 seconds; back off if you don’t care about latency). When you see one of the terminal states you can stop polling.

One active run per conversation

A conversation runs one turn at a time. While a run is pending or running, posting another run on the same conversation returns a 409 Run Already In Progress. The detail names the active run’s id — and, when the run has produced a live turn, what it is currently doing (for example, “waiting on a Narrative job” for a run parked on wait_for). Recovery: poll GET /agents/runs/{active_run_id} until it reaches a terminal state, re-read the conversation’s version, then post your run. A run parked on sleep or wait_for can stay non-terminal for a while; live.messages on the run response shows what it is waiting for. The three terminal states do not block a new run — in particular a requires_action run is done, so the run that resumes it with tool_outputs is accepted as normal. Re-posting a client_op_id that already has a run on this conversation is not refused: you get that run’s row back, even while a different run is active. The bypass is per-conversation while idempotency is per-owner, so a key whose run lives on another conversation gets the 409 — let the active run finish, then re-post. The pre-flight guard is best-effort. Under a tight race the finalize-time CAS (Concurrent Conversation Append) stays the authority for simultaneous posts. completed runs populate exactly one of two fields, depending on whether you supplied an output_format_schema:
  • No schema (text mode) → final_text carries the model’s reply as a plain string; final_structured_output is null.
  • Schema supplied (structured mode) → final_structured_output carries the parsed JSON object conforming verbatim to your schema; final_text is null. Your schema does not need a top-level text field — whatever shape you declare is what you get back.
requires_action runs populate pending_tool_calls — an array of caller-declared tool calls waiting for you to answer. See Example 3 for the resume flow. failed runs populate error.type (an opaque incident code) and error.message (a human-readable detail). The response always includes error.title and error.docs_url pointing at the relevant error catalog page. An incident code with no catalog entry gets the Unexpected entry’s title and URL. A run that was deliberately cancelled also lands in failed, but carries the reserved error.type: "AgentLoopCancelled" so you can tell an intentional stop from a genuine error. The run’s in-flight inference job is cancelled too, so the run and its data-plane job end up consistent. See /errors/cancelled.
One error vocabulary for both surfaces. The agent API exposes errors in two physical shapes — HTTP 4xx/5xx with an RFC 7807 body for synchronous failures (request validation, conversation not found, version conflict, etc.), and HTTP 200 with status: "failed" and an error object for failures that happen inside the workflow after the run has been accepted (max iterations exceeded, MCP server unreachable, invalid effective config, etc.).The two shapes carry the same caller-facing fields:One synchronous error has no catalog page. A company over its credit limit, or with no credit account, gets a 403 on POST .../runs with an RFC 7807 body whose type is null. No run is created.Both shapes link into the same error catalog, which means a single playbook covers both. Whether your client got a 409 on POST .../runs or a 200 with error.type: "AgentLoopMaxIterationsExceeded" on GET /agents/runs/{id}, the docs URL in the response is the canonical “how do I recover” entry point.The workflow side itself never knows about caller-facing presentation — it only emits opaque incident codes ("AgentLoopMaxIterationsExceeded", "UnknownTool", etc.). The translation to title + docs_url happens at the API boundary against a single catalog kept in sync with the docs pages.

The live view

Every run response (POST .../runs and GET /agents/runs/{id}) carries a live object. Unlike the status-dependent fields, live is orthogonal to status and is populated on every read — it reflects the conversation’s current, still-mutating state, so a run you polled minutes ago can surface newer values on the next read without a separate GET /agents/conversations/{id} call.
live holds two fields:
  • current_name — the conversation’s display name, or null if it has none yet. This is the same value as name on GET /agents/conversations/{id}; it’s mirrored onto the run so a client that’s already polling a run sees title changes for free.
  • messages — the run’s produced turns so far, streamed while the run is still in flight so you can show tool execution live. See Streaming tool execution below.

Streaming tool execution

While a run is non-terminal (pending / running), live.messages carries the turns the loop has produced so far — each tool-use assistant turn appears before the platform executes the call (so you can render “calling docs-search_kb…”), and the matching tool_result turn appears once it returns. This lets a chat UI animate a multi-iteration tool loop from the same run poll you’re already doing, with no extra endpoint. Each entry has a turn_index (per-run ordering, independent of sequence_no), a role, and the same content_blocks shape as committed messages. The handoff to the committed log is lossless:
  • Render committed messages (GET .../messages) ++ live.messages while the run is non-terminal.
  • Once the run reaches a terminal status, live.messages is empty — the same turns are now committed and authoritative via GET .../messages?since=…. Drop your live tail and keep the committed rows.
  • De-dupe is exact: the live tool_use / tool_result blocks carry the same tool_use_ids that land in the committed content_blocks.
Live streaming is best-effort: it never blocks or fails the run, and it never touches the conversation version or the committed message stream (live turns aren’t messages — they carry no sequence_no and don’t advance your expected_version). A run with no server-side tool calls (a direct answer) simply shows an empty live.messages and you get the answer via final_text / final_structured_output at completion.

Automatic titling

When you start the first run on a conversation that has no name, the platform kicks off a small asynchronous job that generates a short title from your first user message and writes it to the conversation’s name. Because it’s asynchronous and independent of your run, the timing is best-effort:
  • current_name is usually null on the first read right after the run is created, then becomes the generated title a beat later — keep reading the run (or the conversation) and it appears.
  • A name you set explicitly at conversation-creation time is never overwritten — auto titling only fills an empty name.
  • Titling never touches the conversation version or the message stream: it’s not a message, doesn’t advance your expected_version, and never shows up in GET .../messages.
live is a growth point. It accumulates state that changes after a run was written, so a single run poll can stand in for several reads (current_name, messages, more over time). Treat it as “render whatever keys are present” rather than assuming a fixed set.

Example 1 — hello world (no tools)

The simplest possible run: one user message, one assistant reply, no tools.

Create the conversation

No output_format_schema here — this is text mode. The final answer comes back as final_text. See Example 4 for the structured-mode flow. Response (abbreviated):

Start the run

Response:

Poll until terminal

After a few seconds:

Read the message stream

That’s the whole loop: one user turn (sequence 1) → one assistant turn (sequence 2) → current_version: 2. Future runs on this conversation start with expected_version: 2.

Example 2 — searching docs via an MCP server

Now the model has tools. It searches the Narrative docs for “rate limiting”, makes a few attempts to locate the right page, and produces a grounded summary.

Create the conversation with mcp_servers

Key points:
  • alias: "docs" — the platform discovers the server’s tools at run start, then prefixes each with the alias before showing them to the model. An MCP-side tool named search_narrative_i_o_knowledge_base becomes docs-search_narrative_i_o_knowledge_base on the wire.
  • No tools[] here — discovery is automatic. To inspect what the server exposes, call its tools/list directly (e.g. curl https://docs.narrative.io/mcp ...).
  • max_iterations: 8 — gives the model room to: search, read, refine, then answer.

Start the run

Same shape as Example 1 — just a user message. The model decides what tools to call.

What you see while polling

status cycles pending → running → running → … — each running you see corresponds roughly to one inference iteration. Be patient: MCP roundtrips can take 30–60 seconds total for multi-step research like this. Final state:
The message stream now has 12 entries — the user message, plus alternating assistant (with tool_use blocks) and tool (with tool_result blocks) turns, and a final assistant turn carrying the answer.
Prompt tokens grow with each iteration because each round re-sends the full conversation history to the model. Six iterations on a conversation with several search hits can easily reach 15,000 prompt tokens. Tighter system prompts and shorter tool descriptions help.

Example 3 — asking the user a question (caller-declared tool)

Sometimes the model needs information only the caller has. Configure a tools[] entry, force the model to use it with tool_choice, and the run will pause at requires_action until you answer.

Create the conversation with tools[]

Start the run and force the tool call

Poll until requires_action

The run is paused. pending_tool_calls[].tool_use_id is the handle you’ll need to resume.

Resume with tool_outputs

Re-read the conversation to get the new version (it has advanced because the assistant turn is now persisted):
Now start a follow-up run with the answer:
The model picks up where it left off, sees the tool result, and produces a final answer:
You can answer with is_error: true to tell the model the tool failed (e.g. the user declined). The model decides whether to try another approach, ask a different question, or fall through to a final answer.

Example 4 — structured output

When you need the model’s answer as a typed object rather than free text, supply an output_format_schema describing the shape you want. The model is grammar-constrained to produce JSON matching it, and the parsed value comes back on final_structured_output. This example records a login event using a discriminated union (anyOf) of login / logout variants. Note that the schema has no top-level text field — you declare whatever shape your application needs.

Create the conversation with a structured schema

Start the run

Final state

final_text is null because the run is in structured mode; the answer is on final_structured_output, conforming verbatim to your schema. The two fields are mutually exclusive — text-mode runs (Examples 1–3) populate final_text; structured-mode runs populate final_structured_output.
Your schema can be anything Bedrock’s structured-output sampler accepts — see the supported subset in the warning at the top of Configuration fields. Common shapes that work well: a single typed object, a discriminated union via anyOf with const discriminators (as above), an enum-tagged sum type, an array of typed records.

Troubleshooting

Version conflicts (409)

You get a 409 from POST .../runs with error.type pointing at /errors/version-conflict. What happened: between the moment you read version: N and the moment you posted a run with expected_version: N, something else added messages to the conversation (a previous run’s finalize, or a concurrent caller). The platform refuses to start a run that would conflict at finalize time. How to recover:
If you keep hitting this without obvious cause, check whether a previous run that you thought was completed is actually requires_action — the latest assistant turn might be a tool-call prompt that needs your reply, not a finished answer.

”Bad request” with a tool-alias message (400)

The defaults.mcp_servers[].alias you sent isn’t in the allowed shape. MCP aliases must:
  • be 1–8 characters total,
  • start with a letter,
  • contain only ASCII letters and digits (no underscores, no dashes, no other punctuation),
  • be unique across mcp_servers for the conversation.
Caller-declared tools (under tools[]) have no alias — their name must be non-empty and dash-free. See /errors/invalid-caller-tool-name. Full alias rules and examples: /errors/invalid-tool-alias.

”Tool wire name too long” (400)

The combined {alias}-{tool_name} exceeds 64 characters (an underlying Bedrock limit). Shorten the alias or the underlying tool name. See /errors/tool-name-too-long.

requires_action and you don’t know what to answer

pending_tool_calls[] lists every tool call awaiting your reply, with the tool name and the arguments the model produced. When you post tool_outputs, every entry in pending_tool_calls must have a matching outputs[] entry — no missing, no extras. If you have nothing useful to say for a particular call (e.g. the user dismissed the prompt), still post an entry with is_error: true and a short reason. Common mistakes around tool outputs:
  • Unknown tool_use_id — you sent an id the latest assistant turn never produced. Almost always a typo or a stale resume payload.
  • Not a client tool call — you sent a tool_use_id whose name has the {alias}-{tool} shape (an MCP-resolved call). The platform already answered those; only dash-free names belong in tool_outputs.
  • Incomplete tool outputs — you missed one of the pending ids, or sent extras the model didn’t ask for.

Run failed with AgentLoopMaxIterationsExceeded

The model used up its max_iterations budget without producing a final answer. Either:
  • raise max_iterations in defaults (or config_override per run),
  • tighten the system prompt so the model is steered toward an answer sooner,
  • inspect the message stream — repeated identical tool calls suggest the model is stuck in a loop because the tool isn’t giving it useful new information.
Details: /errors/max-iterations-exceeded.

Run failed with AgentLoopSchemaDecodeFailed

In text mode (no output_format_schema) this is almost always a truncation issue: the model’s reply got cut off before the platform could read it. Raise max_tokens. In structured mode this means the model produced JSON that didn’t match the shape your output_format_schema declared, or returned prose instead of JSON. Fixes:
  • be explicit in the system prompt: “respond with a JSON object matching this schema; no other text.”
  • consider widening max_tokens — sometimes the model is truncated mid-JSON.
  • simplify the schema. Bedrock’s structured-output sampler enforces a subset of Draft 2020-12; features outside that subset can silently fall through to free-text generation. See the warning under Configuration fields.
  • inspect the last assistant turn to see what the model actually said.
Details: /errors/schema-decode-failed.

Run failed with AgentLoopStructuredOutputRetriesExhausted

A structured-output validation failure no longer fails the run on the spot — the platform feeds the non-conforming output and the validator’s message back to the model and asks it to correct itself, up to 10 times (a budget separate from max_iterations). This error means every retry still failed validation. The fixes are the same as for a structured-mode AgentLoopSchemaDecodeFailed — simplify the schema, reinforce it in the system prompt — and the last inference job’s failed_structured_output holds the raw output plus validator message from the final attempt. Details: /errors/structured-output-retries-exhausted.

Run failed with AgentLoopCancelled

Not an error — the run was deliberately cancelled before it finished. The run lands in failed with this reserved code (so it’s distinguishable from a genuine failure), and its in-flight inference job is cancelled too. Any partial work is preserved on the run row and the message stream. If you didn’t expect the cancellation, find out who issued it (operations tooling, a worker drain on deploy, a client abort); the conversation is intact, so start a fresh run to continue. Details: /errors/cancelled.

Anything else

Every other failure class has its own page. Start at the error catalog and use the page name that matches the error.docs_url or type in the response.

Where to go next