Skip to content

Connect a coding agent

A coding agent such as OpenCode or Claude Code calls a model API directly. It keeps the conversation itself, sends all of it with every request and runs its own tools. Core answers two of the APIs such agents speak, so you point the agent at Core by changing its address and its key:

The agent speaks Core's route Base URL to configure
OpenAI's Responses API POST /api/v1/responses https://<your-core>/api/v1
Anthropic's Messages API POST /api/v1/messages https://<your-core>/api — Anthropic's clients append /v1/messages themselves

This page describes what Core accepts, ignores and refuses on those two routes. It is based on Core's own tests of the requests these agents send. No complete session of OpenCode, Claude Code or Codex has been run against Core as part of them, so try your agent against your own deployment before you depend on it.

Give the agent a key

Register a key for the agent (Keys) and choose its capabilities with the agent in mind:

  • No capability is needed for the agent's own tools. A tool the agent defines and runs itself is a client-executed tool; tooling is only for tools Core runs.
  • Leave trust_envelope off unless you want Core to wrap the agent's messages. With it on, every user message and tool result the agent sends reaches the model inside an <untrusted> element under a preamble from Core. A new key has it off. See The trust_envelope capability for what you give up.
  • Leave threads off for an agent that sends its whole history each time. With threads on and store left at its default, every request is stored as a conversation of its own (Storage settings).
  • Leave subjects off for a key Claude Code uses. Claude Code fills metadata.user_id with an identifier of its own, which a key holding subjects refuses on every request (Claude Code).
  • Allow the models the agent will name in the key's policy. GET /models lists the ids your key may use (Choose a model).

What a key without threads answers

A key without threads stores nothing, so there is nothing for a later request to refer to:

The agent sends Core answers
store: true the turn runs and is not stored; the answer is 200
previous_response_id, conversation or thread_id 404, with the message below
GET /responses/{id} 404, with the same message
the same Idempotency-Key again, after the first call completed 409: Core recognises the retry but has no stored answer to return (Idempotency keys)

The 404 says why, in the vendor's error shape:

This key does not hold the `threads` capability, so no response or thread was kept for it to
continue or read back. Send the whole conversation in the request, or use a key that holds
`threads`.

So does the 409, also when it arrives as the idempotency_conflict event of a stream:

A request with this idempotency key was already processed and its response was not kept, because
this key does not hold the `threads` capability. Send the request under a new idempotency key to
run it again, or use a key that holds `threads` so a retry is answered with the original response.

Both sentences describe your own key and say nothing about the id you sent. A key that does hold threads gets a plain Not Found for an id that does not exist.

If your agent relies on any of these, enable threads on its key.

Send the key

Core authenticates a key's secret sent as a bearer token:

Authorization: Bearer ack_...

An OpenAI client sends its api_key this way, so an agent using the Responses API needs nothing else.

Anthropic's clients send their api_key in an x-api-key header instead. Core accepts that header on the two routes an Anthropic client calls, POST /messages and GET /models, and nowhere else:

The request carries On POST /messages and GET /models On every other route
x-api-key only accepted as the key refused 401: the header is not read
Authorization and x-api-key Authorization is used Authorization is used
Authorization only accepted accepted

With Anthropic's SDK both spellings work:

import anthropic

# sends x-api-key
client = anthropic.Anthropic(base_url="https://<your-core>/api", api_key="ack_...")

# sends Authorization: Bearer
client = anthropic.Anthropic(base_url="https://<your-core>/api", auth_token="ack_...")

OpenCode's Anthropic provider (@ai-sdk/anthropic) sends x-api-key, which works on those two routes for the same reason.

List the models

GET /models answers the list both vendors' SDKs read, so client.models.list() returns every chat model your key may use, in one page (Choose a model). The route takes the key either way, as a bearer token or as x-api-key, so an Anthropic client built with api_key lists models as well.

What Core does with the agent's fields

Agents send many fields their vendor's API declares. Core puts each in one of three classes. The API reference lists every field of both routes; an ignored field's description there says that it is ignored.

Ignored. A hint that does not change what the answer means is accepted and dropped. It is not forwarded to the model, not stored and not returned.

Route Ignored fields
POST /responses prompt_cache_key, client_metadata, safety_identifier, service_tier, include, text.verbosity
POST /messages cache_control wherever Anthropic allows it, eager_input_streaming on a tool, context_management, betas, service_tier, every metadata key other than user_id, and the anthropic-version and anthropic-beta headers

Core chooses its own prompt cache prefix, which is why the caching hints are in this class.

metadata.user_id on /messages is not a free label. A value shaped like a subject id — 1 to 128 characters from letters, digits and . _ : @ - — names the person the turn is for, exactly as user does on /responses (Build for people). What happens to any other value, up to 1,024 characters, depends on the key:

The key A metadata.user_id that is not a subject id
does not hold subjects ignored: the turn runs for nobody in particular, as if the field were absent, and the value is not stored or forwarded
holds subjects refused 400, naming metadata.user_id

A key that holds subjects separates what it reaches by person, so Core refuses there rather than run the turn for nobody. user on /responses is refused on every key when it is not a subject id.

If your own application separates its users through /messages, this matters to you, not only to Claude Code. Naming a person on a turn needs no capability, but on a key without subjects a mistyped or otherwise unfitting metadata.user_id is dropped without an error, and that turn runs for nobody: it reaches what a turn naming no one reaches, not that person's own conversations, files and memory. To make sure that never happens silently, do one of these:

  • give the key subjects, so an unfitting value is refused 400 instead;
  • set settings.subjects_required to true in the key's policy (What a request without a subject reaches), so a turn that ends up naming nobody is refused 400;
  • send only ids that fit the grammar above.

Honoured. A field a model provider can take is passed on: temperature, top_p, the output limit, a forced tool, parallel_tool_calls (tool_choice.disable_parallel_tool_use on /messages), reasoning effort and summaries, a JSON Schema for the output, and on /messages also top_k and stop_sequences. Where the model you named cannot take a field you set, the turn is refused 400 naming that field before any model call; Core does not drop the field to make the call succeed.

Refused by name. A field Core cannot honour, where answering 200 would suggest it had, is a 400 whose message names it:

Route Refused
POST /responses truncation: "auto" (Core never drops context to make a call fit), max_tool_calls, a tool_choice object other than a named function tool, every tool type Core does not run: file_search, code_interpreter, computer_use, image_generation, local_shell, mcp; a custom tool's grammar on a model that is not on OpenAI's Responses API; and on web_search a non-empty filters.allowed_domains or a search_content_types without text
POST /messages mcp_servers, container, speed, fallbacks, inference_geo, background, callback_url, output_config.task_budget, thinking.budget_tokens, thinking of type enabled or disabled (use adaptive), a text block with citations in it, a server tool other than web search (such as code execution), and on web search a non-empty allowed_domains or blocked_domains, or allowed_callers without direct

Core's own fields. /messages also takes fields of Core's own that Anthropic's API does not declare: agent, skills, tool_variables (values for the call variables of the turn's tools, never shown to the model), hosting, store, previous_response_id, thread_id, tool_search and max_tool_rounds. An agent that does not send them gets the vendor's behaviour.

Any other field neither vendor declares is a 400 naming it. Errors arrive in the vendor's own error shape, so the agent's SDK raises its usual exception (The model-call routes answer the vendor's error).

Replayed history

An agent sends back what it received as part of the next request. Core accepts its own output there:

  • An assistant message, a function_call (tool_use on /messages) and the agent's result for it are accepted with the extra fields the SDKs write out, such as id, status and null values. Those fields are ignored.
  • Several tool calls made in one round are sent to the model as one assistant message, with each result directly after it.
  • A reasoning item on /responses and a thinking block on /messages are accepted and not sent to the model.
  • A message with role: "system" in the middle of messages is accepted on /messages, with text blocks only.
  • A tool result may be text, or text and images (Images).
  • An mcp_call item — a tool Core ran — is not accepted back on /responses. This only matters when you also offer the agent tools Core runs.

Reasoning

A thinking model's reasoning comes back as a reasoning item on /responses and as a thinking block on /messages, with an empty signature: Core keeps what the provider needs to continue and does not need it back from the agent. Core asks for a summary unless the request, your key or your organisation says otherwise; on /messages, thinking.display: "omitted" is the request's way to say no.

Some models your deployment reaches through an OpenAI-compatible chat-completions API return their own thinking instead of a summary. Core shows it in the same place. See Response fields for how that text differs and for the servers that return no reasoning at all.

Limits

Limit Value When it is exceeded
Request body on POST /responses, POST /messages and POST /responses/{id}/tool_outputs 8 MiB, unless your deployment set another limit 413 in the vendor's error shape; the message names the limit in bytes. Shorten or compact the history, and send images beyond the first by file_id instead of inline.
Tools offered in one request 128 400
A tool's description 16,384 characters 400
One text part, or one tool output 1 MiB 400 naming the field
instructions, and input when it is one string 262,144 characters; 1 MiB 400 naming the field
Messages in one /messages request 1,024 400
An inline image 5 MiB; PNG, JPEG, GIF or WebP 400
metadata.user_id on /messages 1,024 characters 400
A synchronous turn five minutes (Timeouts) the turn fails

On /messages the limit of instructions is the limit of system: a system over 262,144 characters is refused 400 naming system.

A body over 1 MiB is only read for a valid key. With a missing or wrong key it is refused 401 without being read, and the agent may see the connection close instead of the 401.

The output limit and a cut-off answer

Agents often ask for the same large output limit of every model. Where your deployment's catalogue states a model's maximum, Core lowers a higher max_output_tokens (max_tokens on /messages) to that maximum instead of refusing the request. The response still shows the value the agent asked for. Where no maximum is stated, the value goes to the provider as sent, and a provider that rejects it fails the turn with the code model_request_rejected.

Which models have a stated maximum is decided by your deployment's own catalogue, which your operator maintains. If a model fails on the limit your agent asks for, ask your operator to state that model's maximum.

When the model stops because it reached the output limit, the turn is not a failure. The text written so far is returned, and no tool call is: a call cut off mid-way is dropped, because its arguments were never finished.

Route A cut-off answer
POST /responses status: "incomplete" with incomplete_details.reason: "max_output_tokens"; the message item has status: "incomplete". A stream ends with response.incomplete.
POST /messages stop_reason: "max_tokens", on the message and on a stream's message_delta

See A cut-off answer for how this differs from a turn that ran out of tool rounds.

Images

An image is sent inline or as a file uploaded to Core (Upload files):

  • On /responses, an input_image part carries image_url as a base64 data: URL, or a file_id.
  • On /messages, an image block carries a base64 source, or {"type": "file", "file_id": "..."} naming a Core file.

Core does not fetch an image from a web address: an http or https URL is refused 400.

detail. On /responses, input_image.detail (auto, low or high) says how closely the model should look. It is passed to an OpenAI-family model and left out for every other model; it never refuses a request. /messages has no such field.

Images in a tool result. A tool the agent ran can answer with an image, such as a screenshot:

  • On /responses, function_call_output.output is a string, or a list of input_text and input_image parts. The same holds on POST /responses/{id}/tool_outputs.
  • On /messages, the content of a tool_result block is a string, or a list of text and image blocks.

The image is given in the same two ways as above, and again never by URL. The model reads it as part of that tool's result, next to the result's text. A model your deployment declares without image input refuses the request with 400.

A model that may not take images. A model whose features your deployment has not declared is sent the image, and the provider decides. If it refuses the request, the turn fails with the code model_request_rejected and the message "The model provider rejected the request Core sent for this turn. The request contained an image; this model may not accept image input." Every later turn that carries the image fails the same way. An agent that resends its whole history removes the image part from it. A conversation continued from a stored response keeps an image uploaded as a file, so start a new conversation without it. Either way you can switch to a model that takes images, or ask your operator to declare what this model takes.

A background run loads no images: the model reads [image unavailable] where an image stood, in a user message and in a tool result alike. An inline image is not stored either, so a later turn that continues a stored conversation reads [image unavailable] in its place; an agent that resends its whole history is not affected.

Freeform tools

A tool of type custom is one your agent runs itself, like a function tool, but the model calls it with free text instead of JSON; Codex's apply_patch is one. Core hands the call back as a custom_tool_call with the text in input, streamed as response.custom_tool_call_input.delta and .done, and takes your agent's custom_tool_call_output as the result. On a model served through OpenAI's Responses API the model gets the tool as it is, format included. Any other model gets a function with one string argument; Core cannot hold such a model to a grammar format, so a custom tool with one is refused 400 naming tools.N.format there. A text format, or none, works on every model.

What this means for Codex. Codex offers apply_patch as a custom tool with a lark grammar when the model is an OpenAI GPT-5 model; in Core's own conformance runs that was gpt-5.4, and with the other models Codex offered no custom tool. Served from a provider on OpenAI's Responses API, such a request works as it does against OpenAI. Served from a provider on chat completions -- an Azure OpenAI deployment configured without the Responses API, for example -- it is refused 400 naming the apply_patch entry's format. Which models Codex sends the grammar to is Codex's own choice and can change between its releases; check what your version sends.

Tools grouped in a namespace

A tool of type namespace groups function and custom tools your agent runs, as Codex groups the tools of each MCP server. Core offers each one to the model as ns_<namespace>__<name> and hands a call back under the tool's own name, with the namespace beside it, so your agent routes it as it would with OpenAI. A replayed call that carries its namespace is matched to the same tool. Two things to know:

  • The flattened name may be at most 64 characters. A tool whose ns_<namespace>__<name> is longer, or that another tool of the request already offers under that name, is refused 400 naming tools.N.tools.M.name.
  • Tool policy sees the flattened name. To deny one tool of a namespace, name ns_<namespace>__<name> in a forbid rule of kind tool in the key's policy; to deny the whole namespace, ns_<namespace>__*.

A tool your agent sends outside a namespace keeps the name the agent gave it, also when that name is written mcp__<server>__<tool>: Core hands the call back to your agent and runs nothing itself (Client-executed tools).

OpenAI's {"type": "web_search"} (and web_search_preview) on /responses and Anthropic's web_search_20250305 server tool on /messages run Core's own web search, builtin__web_search. The key needs what offering that tool by name needs: tooling with web_search among its system_tools, and a deployment with a search vendor configured. Any other key gets a 400 naming the tools entry. The search comes back as one of Core's own tool rounds, an mcp_call on /responses and an mcp_tool_use with its mcp_tool_result on /messages, not as the vendor's search item. A location's country is used as the country results come from; the city, region, time zone and a context size are accepted and ignored. Anthropic's max_uses is honoured: after that many searches in one turn, a further search answers the model with a sentence saying no more searches were allowed, and the turn goes on. A domain list is refused by name, because Core's search cannot keep to it.

An agent that sends OpenAI's {"type": "tool_search"} on /responses, Anthropic's tool_search_tool_bm25 or tool_search_tool_regex on /messages, or defer_loading: true on any of its tools gets Core's own tool router for the whole request, as if it had sent "tool_search": {"mode": "router"}. The model is offered builtin__tool_search and builtin__tool_run in place of the tools Core runs and your agent's own function tools, finds a tool by describing what it needs, and runs it by name. A run on one of your agent's tools comes back as the usual function_call or tool_use under the tool's own name, which your agent answers as always. Custom (freeform) tools, and a tool your tool_choice forces, stay in the model's list. defer_loading is never read for one tool alone: a request that marks some tools true and others false puts all of them behind the router. A tool_search your request sends itself wins over these fields. The key needs tooling.tool_search.enabled and an organisation that allows tool search. A key without them is refused even when the request only carries one of these vendor fields and never names tool_search: the answer is a 400 naming tool_search.mode. The searches and runs come back as Core's own tool rounds, an mcp_call on /responses and an mcp_tool_use with its mcp_tool_result on /messages, not as the vendor's tool-search items.

What is not supported

  • /chat/completions. Core has no chat-completions route; a request to it is 404. Configure the agent to use the Responses API or the Messages API.
  • Hosted tools of the vendors other than web search and tool search, such as OpenAI's file_search or Anthropic's code execution. Core refuses them by name. Register the tool on your organisation instead and offer it as a Core tool.
  • Images by URL, as above.
  • Token counting, batches and Anthropic's Files API. POST /v1/messages/count_tokens, the Batches API and anthropic.files have no route on Core.
  • Reading a message back. There is no GET /messages/{id}. The id a /messages answer carries is a Core response id, which GET /responses/{id} reads when the key holds threads.

OpenCode

Core accepts the requests OpenCode sends on both routes. Configure a provider in OpenCode with the base URL and key from the tables above and a model id from GET /models. On the Messages route it sends its key as x-api-key, which Core accepts there and on GET /models.

Claude Code

Claude Code speaks the Messages API, so it uses the https://<your-core>/api base URL. Of what it is known to send, Core handles the following today:

  • the anthropic-beta header on every request: ignored;
  • role: "system" messages after the first request: accepted;
  • a large max_tokens on every model: lowered to the model's maximum where the catalogue states one (above);
  • metadata.user_id on every request, filled with a device, account and session identifier of its own, which is not a subject id: ignored on a key without subjects, refused 400 on a key that holds it (above). Do not enable subjects on a key Claude Code uses;
  • its connectivity check before it starts, HEAD <base URL>/api/hello: answered with an empty 200, for GET as well. That answer says the address is reachable. It does not check the key and says nothing about whether Core itself is up.