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;
toolingis only for tools Core runs. - Leave
trust_envelopeoff 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 Thetrust_envelopecapability for what you give up. - Leave
threadsoff for an agent that sends its whole history each time. Withthreadson andstoreleft at its default, every request is stored as a conversation of its own (Storage settings). - Leave
subjectsoff for a key Claude Code uses. Claude Code fillsmetadata.user_idwith an identifier of its own, which a key holdingsubjectsrefuses on every request (Claude Code). - Allow the models the agent will name in the key's policy.
GET /modelslists 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 refused400instead; - set
settings.subjects_requiredtotruein the key's policy (What a request without a subject reaches), so a turn that ends up naming nobody is refused400; - 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_useon/messages) and the agent's result for it are accepted with the extra fields the SDKs write out, such asid,statusand 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
reasoningitem on/responsesand athinkingblock on/messagesare accepted and not sent to the model. - A message with
role: "system"in the middle ofmessagesis accepted on/messages, with text blocks only. - A tool result may be text, or text and images (Images).
- An
mcp_callitem — 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, aninput_imagepart carriesimage_urlas a base64data:URL, or afile_id. - On
/messages, animageblock carries abase64source, 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.outputis a string, or a list ofinput_textandinput_imageparts. The same holds onPOST /responses/{id}/tool_outputs. - On
/messages, thecontentof atool_resultblock is a string, or a list oftextandimageblocks.
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 refused400namingtools.N.tools.M.name. - Tool policy sees the flattened name. To deny one tool of a namespace, name
ns_<namespace>__<name>in aforbidrule of kindtoolin 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).
Web search¶
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.
Tool search¶
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 is404. 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_searchor 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 andanthropic.fileshave no route on Core. - Reading a message back. There is no
GET /messages/{id}. The id a/messagesanswer carries is a Core response id, whichGET /responses/{id}reads when the key holdsthreads.
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-betaheader on every request: ignored; role: "system"messages after the first request: accepted;- a large
max_tokenson every model: lowered to the model's maximum where the catalogue states one (above); metadata.user_idon every request, filled with a device, account and session identifier of its own, which is not a subject id: ignored on a key withoutsubjects, refused400on a key that holds it (above). Do not enablesubjectson a key Claude Code uses;- its connectivity check before it starts,
HEAD <base URL>/api/hello: answered with an empty200, forGETas well. That answer says the address is reachable. It does not check the key and says nothing about whether Core itself is up.