Files
ironclaw/docs/api/responses.mdx
Josh Ford c145f6e522 test(docs): doc-fact contract tests for CLI, manifest, and Responses claims (doc-truth PR 3/5) (#7378)
* docs: fix live drift in extension, responses API, and channel docs

The public tutorial taught the retired manifest v2 authoring format
([[host_api]] / [capability_provider.tools] / runtime_credentials), which
the v3 parser hard-rejects, and never mentioned origin_gate_matrix; the
Responses API page claimed temperature is rejected (accepted 0.0-2.0 and
forwarded), claimed model must be "default" (any well-formed name <= 256
bytes), claimed max_output_tokens is rejected (accepted and ignored by DTO
policy), and omitted the required model field from every request example;
the channel tutorial pointed at two files that no longer exist.

- docs/extensions/building-a-tool.md: rewrite manifest sections to the v3
  [[tools]] / [[tools.credentials]] / [auth.<vendor>] shape, document
  origin_gate_matrix (origins, policies, ratchet), correct the hosted-MCP
  [mcp] section, packaging via ironclaw_extension_support package modules,
  and v3 test references; drop the nonexistent script runtime kind.
- docs/api/responses.mdx: correct model/temperature/tools/tool_choice
  rejection rules, document unknown-field tolerance, add the required
  model field to all 15 request examples.
- docs/channels/building-a-channel.mdx: replace dead
  crates/ironclaw_first_party_extensions + available_extensions.rs
  registration instructions with the current package-directory mechanism.
- docs/reborn/contracts/extensions.md: state that production manifests
  author v3 (lowering into the v2 resolved model described there); label
  the v2 examples as legacy.
- docs/reborn/how-to-port-tool-to-reborn.md: superseded banner pointing at
  the v3 guides.

Part of #7317 (doc-truth pipeline, PR 1 of 5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): extend the reference gate to the docs/ surface

The public Mintlify tree had no path-reference validation — a published
tutorial told contributors to edit files that no longer exist and nothing
caught it. check-guidance.py already owned the machinery (tracked-tree
resolution, fence exclusion, suppress markers, shrink-only debt, fail-closed
floors), so the docs surface joins the same gate rather than a fork.

- discover_guidance() now collects every tracked docs/**.md|.mdx: published
  pages, the zh/ locale mirror, and the living contract corpus
  docs/reborn/contracts/. Dated archives (docs/internal/, the non-contract
  parts of docs/reborn/) are excluded as classes — measured 2026-08-07,
  705 of 709 dangling docs references sat in those historical corpora, and
  forcing dated plans/ADRs to track today's tree would either rewrite
  history or drown KNOWN_MISSING.
- docs/ files extract backticked inline paths only; Mintlify markdown link
  targets are site routes (extensionless pages, site-absolute /using/cli),
  a different namespace than the tracked tree, so the link extractor is off
  there by design.
- _reference_lines learns MDX comments ({/* ... */}), including
  {/* check-guidance: path-ok */} as the .mdx suppress-marker form, with the
  same one-reference-per-marker and multi-line semantics as HTML comments.
- Floors re-measured and re-dated (364 files / 2276 references; floors
  180/1100), plus a dedicated MIN_DOCS_FILES=60 floor: the aggregate floors
  sit below the guidance-only remainder, so the docs branch of discovery
  silently breaking needs its own refusal. --json now reports docs_files.
- Fixes the four real dangles the new scan found in docs/reborn/contracts/
  (moved nested_dispatch_stream.rs test home, retired event-store migrations
  directory, loop_driver_host tests->src move). KNOWN_MISSING stays empty.
- Self-tests: 8 new cases (dangling docs path fails; Mintlify links are not
  references; MDX marker suppresses exactly one reference; multi-line MDX
  comment hides content; zh discovered; archives excluded but contracts
  scanned; docs fence fails closed; docs floor refuses).
- ws12_workflow_contracts.py: docs/api/responses.mdx and docs/zh/index.mdx
  join the has_guidance in-scope probes so a narrowed trigger regex cannot
  silently skip the gate for public docs.

Part of #7317 (doc-truth pipeline, PR 2 of 5); stacked on #7375.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(docs): pin CLI, manifest, and Responses doc claims to code

Three deterministic doc-fact contract tests, each living in the crate that
owns the truth it checks, so the drift #7317 describes fails CI instead of
shipping:

- crates/app/ironclaw_cli/tests/docs_cli_reference.rs: parses the real
  binary's --help and cross-checks docs/using/cli.mdx table rows both ways
  (every visible subcommand documented, any alias form counting; every
  documented command real), with a fail-closed row floor. Doc gaps this
  surfaced are fixed here: ironhub had no rows at all, completion was
  fence-only, and the Trace Commons table lacked the `ironclaw` prefix the
  rest of the page uses.
- crates/extensions/ironclaw_extension_registry/tests/
  docs_manifest_schema_version.rs: walks the published docs tree (the
  frozen .mintignore fence mirrored as constants) and asserts zero
  occurrences of the retired reborn.extension_manifest.v2 literal, fenced
  code included; asserts building-a-tool.md names
  MANIFEST_SCHEMA_VERSION_V3 verbatim and documents origin_gate_matrix.
- crates/product/ironclaw_openai_compat/tests/docs_responses_contract.rs:
  docs/api/responses.mdx now carries a machine-readable
  {/* doc-fact:responses-request-policy */} marker block (invisible when
  rendered); the test parses it and drives every claim through the same
  route-level seam as the sibling *_contract.rs suites — the marker's
  values parameterize the assertions (temperature accepted at the
  documented max and rejected just above it, model accepted at the byte
  cap and rejected past it, tool_choice always 400, tools 400 without /
  registered with external-tool wiring, empty tools treated as omitted,
  unknown fields like max_output_tokens accepted and ignored, and one
  request carrying every documented field accepted).

Part of #7317 (doc-truth pipeline, PR 3 of 5); stacked on #7376.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: address Copilot and CodeRabbit review on doc-drift PR

- responses.mdx: tool_choice is rejected only without external-tools wiring;
  with external tools enabled it passes validation and is currently ignored
  (validate_responses_supported_fields_with_external_tools never checks it).
- building-a-tool.md: clarify that effect-derived host ports are validation
  vocabulary against the HostPortCatalog allowlist; adapters are built by
  host-runtime services after authorization/obligations, never from manifests.
- how-to-port-tool-to-reborn.md: mark the decision tree's RuntimeKind targets
  historical (v3 accepts only wasm|first_party; MCP is top-level [mcp];
  process/CLI work is the sandbox lane).
- building-a-channel.mdx: document the user install flow — virtual package
  root /system/extensions/<id>/manifest.toml, ironclaw extension search /
  install <extension-id> (ID, not path), WebUI Extensions lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(responses): align the limits bullet with the corrected tool_choice claim

The rejection list was corrected in the previous commit (tool_choice is
rejected only without external-tools wiring); the "Limits and quirks"
bullet still said "not supported ... rejected with 400". Same claim, one
wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(docs): tool_choice is conditionally rejected, not always

Copilot review on the docs PR caught that
validate_responses_supported_fields_with_external_tools never checks
tool_choice — with external tools wired it is accepted and ignored, not
400'd. The doc-fact marker moves tool_choice into
rejected_without_external_tools, and the dedicated test now proves both
sides: 400 naming the param on the plain router, accepted-and-ignored
(submit succeeds, nothing registers) with external-tool wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: apply verified code-review findings on the drift PR

A full code review of this PR against live code surfaced claims the
original drift pass got wrong or missed; every fix below was re-verified
against the cited source before editing:

- responses.mdx: standard `ironclaw serve` deployments always wire
  external tools (OpenAiCompatRouteMountPorts requires the store/resume
  pair; mount.rs wires them unconditionally), so `tools` is accepted and
  `tool_choice` is accepted-and-ignored on shipped binaries — the
  conditional 400s apply only to custom compositions without the wiring
  (now a Note). temperature is validated and carried in the submitted turn
  payload but not applied as a provider sampling parameter. Non-streaming
  wait timeout is 30 s (DEFAULT_RESPONSES_WAIT_TIMEOUT), not 120. usage on
  retrieval is read best-effort from persisted run state incl. USD cost
  (read_run_usage), not always zero.
- building-a-tool.md: the [auth.example] oauth2_code recipe gains the
  required token_response map (deny_unknown_fields rejects the example as
  previously written); Gmail/Google Calendar corrected to first_party
  runtimes (their manifests declare kind = "first_party"); the worked
  api_key recipe is github's, not slack's; the tail "Quick implementation
  checklist" and reference list were still v2-era (script lane,
  assets/<extension>/ path, "manifest v2", v2.rs pointer) and now teach
  the v3 shape; composition/CLI package-naming claim narrowed (the binary
  does link slack/telegram adapter crates).
- contracts/extensions.md: legacy-format paragraph no longer claims
  host-bundled packages ship v2 (none do), and origin_gate_matrix is
  attributed to capability.rs + building-a-tool.md instead of
  extension-runtime/overview.md §3, which does not mention it.
- how-to-port banner: `script` manifest authoring is retired; the
  RuntimeKind::Script symbol survives as the process-sandbox lane's kind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(contracts): repoint delivery_resolution.rs to its family directory

PR #7157 (merged to main 2026-08-07) cited
crates/ironclaw_outbound/src/delivery_resolution.rs in the
communication-delivery-resolution contract; the crate lives at
crates/domains/ironclaw_outbound/. Caught by this branch's docs surface of
check-guidance.py on the first merge of main after the gate landed —
exactly the drift class it exists for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(test-plan): route docs pages to the doc-fact tests that read them

docs/ sat in IGNORED_PREFIXES as a pure-prose class, which this PR's
doc-fact tests falsify: three cargo tests now read published pages, so
a docs-only PR would have selected zero crate tests and merged green,
leaving the failure to land on whichever unrelated change ran the full
plan next.

Published Markdown now selects the registry's schema-version sweep;
docs/using/cli.mdx and docs/api/responses.mdx additionally select
their owning crates. All selections are direct exact test targets —
no reverse-dependency widening, since prose only changes the doc-fact
assertions that read it. Fenced trees (docs/internal/, docs/reborn/,
drafts) and non-page files keep the prose classification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): harden the docs gate and fix review-surfaced doc drift

Applies the verified findings from the PR #7376 code review:

- The loop-exit and turn-runner contract docs claimed the deleted
  loop_driver_host checkpoint-rejection test had 'moved into the
  module'; it was deleted in #6696 and the fenced verification command
  could not run. Both now cite the real surviving pins
  (planned_driver.rs executor test + the ironclaw_turns projection
  test mapped in scripts/reborn-e2e-rust.sh), with runnable commands.
- An unterminated comment now refuses at EOF like an unterminated
  fence; before, one typo'd closer silently un-scanned the rest of the
  file.
- Markdown links in the re-included corpora are now checked as repo
  paths (they are never published, so the Mintlify-route rationale did
  not apply); this alone added ~165 verified references.
- Each DOCS_REINCLUDED_PREFIXES entry must match at least one tracked
  page or discovery refuses, so the planned docs/reborn consolidation
  cannot silently drop the corpus from the scan.
- The living extension-runtime spec pages (overview.md,
  standard-operations.md) and guidance-conventions.md join the scan;
  guidance-conventions.md now describes the docs surface and the MDX
  marker form, and its one dangling test path is repointed.
- Floors comment corrected (57 rule globs, not 38).

Also fixes four drifted claims from #7375's pages, verified against
live code: the interleaved function_call_output example was rejected
with 400 (resume input must be exclusively function_call_output items
with previous_response_id); model is echoed only on create (GET/cancel
report the 'reborn' placeholder); output_schema_ref is optional; and
the unknown-fields claim now names the two deliberate exemptions.

Self-tests: 43 pass (three new arms — unterminated comment refusal in
both syntaxes, re-included links as repo claims, stale re-included
prefix refusal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): sync module docstring with re-included link checking

CodeRabbit caught the docstring still claiming the link extractor is
off for all of docs/** — stale since b172f69c7 enabled it for the
re-included corpora. The docstring now states the exception and the
current re-include set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(docs): drop the retired reborn/ entry from the publication-fence mirrors

reborn/ left docs/.mintignore when #7559 consolidated it into internal/;
the fence mirrors in docs_manifest_schema_version.rs and
reborn_pr_test_plan.py still listed it. Fixture paths follow the move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(docs): tighten doc-fact comments and docstrings

Same behavior; module docs and test docstrings trimmed to the point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(docs): harden the doc-fact suites per CodeRabbit review

- CLI: validate full documented command paths via `ironclaw <path> --help`
  (immediately caught and removed the nonexistent `extension activate` row)
  and match visible aliases as exact tokens, not substrings.
- Responses: seed a real prior response so `previous_response_id` is
  actually submitted and accepted; document `metadata` in the visible table
  to match the marker.
- Manifest sweep: parse the publication fence from docs/.mintignore instead
  of mirroring it, so a removed fence entry widens the scan with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docs): correct the completion syntax and parse the fence in the planner

Review findings (sub-agent /code-review):
- docs/using/cli.mdx taught `ironclaw completion <shell>`; the binary only
  accepts `--shell <shell>`. The contract test stops extracting at flags,
  so it could not catch this.
- The planner's doc-fact arm mirrored the .mintignore fence as constants —
  the same hand-maintained-mirror class the PR removes elsewhere. It now
  parses docs/.mintignore via docs_publication_boundary, and a .mintignore
  edit itself routes to the published sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test-plan): treat a missing docs/.mintignore as no fence, not a crash

Matches docs_publication_boundary.find_violations(): fence gone means
everything is published, so every page routes to the sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(docs): replace the doc-fact count floors with derived anchors

Same move as #7376's MIN_DOCS_FILES removal: MIN_DOC_COMMAND_ROWS was
redundant with the completeness check (the binary defines the expected
set), and MIN_SCANNED_PAGES is now a docs.json nav-coverage assertion —
every source-backed navigation route must be among the walked pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(docs): assert the current schema version instead of scanning for a retired literal

Hardcoding `reborn.extension_manifest.v2` was backward-looking: retiring
v3 would need a hand-edit or the test goes stale. The scan now extracts
every `reborn.extension_manifest.<version>` mention in published pages
and asserts it equals `MANIFEST_SCHEMA_VERSION_V3`, with the family
prefix derived from the same constant — the next schema bump retargets
the test by itself, and typo'd or older versions (v1, v33) are caught
too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:40:54 +00:00

575 lines
22 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Responses API"
description: "OpenAI-compatible Responses endpoint backed by the full IronClaw agent loop"
icon: "code"
---
IronClaw exposes an OpenAI-compatible Responses API. Unlike a raw LLM passthrough, requests route through the full agent loop, so callers get tools, memory, safety, and server-side conversation state via a standard wire format.
```
POST /api/v1/responses
GET /api/v1/responses/{id}
```
The legacy `/v1/responses` path is still accepted as an alias for clients pinned to it.
## When to use this vs. the Chat Completions proxy
| Endpoint | Behavior |
|----------|----------|
| `/api/v1/responses` | Routes through the agent. The model can call IronClaw tools (file, web, memory, MCP, extensions), retain conversation state via `previous_response_id`, and pause for caller-supplied tools. |
| `/v1/chat/completions` | Raw LLM proxy. No tools, no memory, no agent loop. Use it when you only need a model call. |
If you want IronClaw's capabilities behind an OpenAI-shaped wire contract, this is the endpoint.
---
## Authentication
Every protected route requires a bearer token in the `Authorization` header:
```
Authorization: Bearer <token>
```
You have two options:
<Tabs>
<Tab title="Instance token (single-user)">
The bearer token provisioned during [onboarding](/onboard) authenticates both the web interface and this API. It is written to `webui-token` in your IronClaw home:
```bash
cat ~/.ironclaw/reborn/webui-token
```
Supply your own instead by setting `IRONCLAW_REBORN_WEBUI_TOKEN` before starting `serve`.
```bash
export IRONCLAW_REBORN_WEBUI_TOKEN="your-secure-token"
```
<Warning>
This token carries operator privileges, including configuration changes. For anything beyond a single-user instance, use per-user tokens instead.
</Warning>
</Tab>
<Tab title="Per-user tokens (multi-user)">
In a multi-user deployment, an admin creates each user through the admin surface, which mints that user's API bearer exactly once:
```bash
curl -X POST https://your-host/api/webchat/v2/admin/users \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","display_name":"Alice","role":"member"}'
```
`role` is required — one of `owner`, `admin`, or `member`. `owner` and `admin` clear
the admin authorization boundary; `member` does not. `email` and `display_name` are
optional.
The token is top-level; the user's identity is nested under `user`:
```json
{
"user": {
"user_id": "...",
"email": "alice@example.com",
"status": "active",
"role": "member",
"created_at": "2026-07-27T18:00:00Z",
"updated_at": "2026-07-27T18:00:00Z"
},
"api_token": "<shown ONCE>"
}
```
Store the `api_token` when it is returned; it cannot be retrieved again. These tokens are scoped to one user and carry user identity only — they do not inherit operator configuration privileges.
Users signed in through Google or GitHub SSO receive an equivalent session bearer. See [Web Interface](/using/webui#single-sign-on).
</Tab>
</Tabs>
A missing or wrong token returns `401`. Chat send endpoints (including this one) are rate-limited to 30 requests per 60 seconds per user.
---
## Quickstart
The simplest request, with no session state and no tools:
```bash
curl -X POST https://your-host/api/v1/responses \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "default",
"input": "What time is it?"
}'
```
Response:
```json
{
"id": "resp_<64hex>",
"object": "response",
"created_at": 1715846400,
"model": "default",
"status": "completed",
"output": [
{
"type": "message",
"id": "item_...",
"role": "assistant",
"content": [
{ "type": "output_text", "text": "It is 14:23 UTC." }
]
}
],
"usage": { "input_tokens": 42, "output_tokens": 11, "total_tokens": 53 }
}
```
The OpenAI Python and TypeScript SDKs work as-is. Point `base_url` at `https://your-host/api/v1` (or `/v1` for the alias).
```python
from openai import OpenAI
client = OpenAI(base_url="https://your-host/api/v1", api_key=TOKEN)
resp = client.responses.create(model="default", input="What time is it?")
print(resp.output_text)
```
---
## Request fields
```json
{
"input": "...",
"model": "default",
"instructions": null,
"previous_response_id": null,
"stream": false,
"tools": null,
"x_context": null
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `input` | yes | A string, or an array of `message` / `function_call_output` items. See [External tools](#external-tools) for the array form. |
| `model` | yes | Any well-formed model name: non-empty, no surrounding whitespace, no control characters, at most 256 bytes. Carried through as a server-side routing hint and echoed back in the create response; retrieval and cancel responses report the server placeholder `"reborn"` instead of the submitted name. Provider and model selection is configured via settings. `"default"` is the conventional placeholder. |
| `instructions` | no | System/developer instructions. Injected ahead of `input` as an `<instructions>` block and stored as part of the persisted user message. See [Per-request instructions](#per-request-instructions) for the IronClaw-specific replay caveat. |
| `previous_response_id` | no | The `id` from a prior response. Resumes the same thread; see [Session continuity](#session-continuity). |
| `metadata` | no | Accepted and ignored — parsed as part of the request but not persisted or echoed back. |
| `stream` | no | `true` for SSE; `false` (default) for a single JSON response. |
| `temperature` | no | Sampling temperature, `0.0``2.0` inclusive; values outside the range are rejected with `400`. Validated and carried in the submitted turn payload; it is not currently applied as a provider sampling parameter. |
| `tools` | no | Caller-supplied function tools. See [External tools](#external-tools). An empty `tools: []` is treated as omitted. |
| `tool_choice` | no | Accepted and ignored — there is no per-request tool-choice surface. |
| `x_context` | no | IronClaw extension. Structured JSON context (≤ 10 KB) prepended as `<user-context>`. The alias `context` is also accepted but may collide with future OpenAI fields. Prefer `x_context`. |
These are rejected with `400` so callers know they were not honoured:
- `temperature` outside `0.0``2.0`
- A malformed `model` (empty, surrounding whitespace, over 256 bytes, or control characters)
- `x_context` larger than 10 KB
Other OpenAI request fields not listed above (for example `max_output_tokens`)
are **accepted and ignored**: the endpoint deliberately tolerates unknown
fields so newer SDK payloads keep working, but unlisted fields have no effect.
<Note>
External-tools support is always wired on a standard `ironclaw serve`
deployment, which is why `tools` and `tool_choice` are accepted above. A
custom composition that mounts the Responses routes without external-tool
wiring instead rejects non-empty `tools` and any `tool_choice` with `400`
naming the field.
</Note>
{/* doc-fact:responses-request-policy
Machine-checked contract for the table and rejection list above — parsed
and verified against the real router by
crates/product/ironclaw_openai_compat/tests/docs_responses_contract.rs.
The rejected_without_external_tools set describes the router without
external-tool wiring (the custom-composition case in the Note above);
standard serve deployments always wire it.
Edit these values only together with the prose they pin.
request_fields = model,input,stream,temperature,instructions,previous_response_id,metadata,tools,tool_choice,x_context
rejected_without_external_tools = tools,tool_choice
ignored_unknown_fields = true
temperature_min = 0.0
temperature_max = 2.0
model_max_bytes = 256
*/}
---
## Session continuity
Each response embeds its thread UUID in the `id`. Pass the previous `id` back as `previous_response_id` to continue the same conversation:
```json
{
"model": "default",
"input": "What did I just ask?",
"previous_response_id": "resp_abc...xyz"
}
```
IronClaw replays the thread's history from the conversation store and runs the new turn against it. There is no client-side state to manage. Threads are user-scoped, so a token belonging to a different user cannot resume someone else's thread (the lookup returns `404`).
Each POST mints a fresh `response_uuid`, so two turns on the same thread produce different `id`s.
### Retrieving a past response
```bash
curl https://your-host/api/v1/responses/$ID \
-H "Authorization: Bearer $TOKEN"
```
Returns the output items reconstructed from the stored conversation. The `usage` field is read best-effort from persisted run state (token totals plus USD cost); it can be zero when the run reported no usage.
---
## Streaming
Set `"stream": true` to receive Server-Sent Events:
```bash
curl -N -X POST https://your-host/api/v1/responses \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"model":"default","input":"Summarize the news.","stream":true}'
```
Events follow the OpenAI Responses streaming format. The SSE `event:` field matches the JSON `type`:
| `event:` | `type` | Payload |
|----------|--------|---------|
| `response.created` | `response.created` | Initial in-progress shell. |
| `response.output_item.added` | `response.output_item.added` | A new `message` or `function_call` item began. |
| `response.output_text.delta` | `response.output_text.delta` | Token delta inside an `output_text` content block. |
| `response.output_item.done` | `response.output_item.done` | An item is finalised. |
| `response.completed` | `response.completed` | Terminal success. |
| `response.failed` | `response.failed` | Terminal failure (turn-level error). |
A keepalive frame fires every 15 seconds to prevent intermediate proxies from closing idle connections.
The non-streaming path has a 120-second turn timeout. Long-running tool work (sandbox jobs, multi-step agentic flows) should use `stream: true` so the connection stays responsive.
---
## External tools
You can register your own function tools per request. The agent treats them as first-class actions alongside built-in tools and pauses execution when it wants to call one. Your client executes the call and feeds the result back on the next request.
This is a function-calling round-trip, not a prompt-level convention. The wire shape matches the OpenAI Responses API spec.
### Define tools
```json
{
"model": "default",
"input": "What is the weather in NYC?",
"tools": [
{
"type": "function",
"name": "lookup_weather",
"description": "Return the current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name" }
},
"required": ["city"]
}
}
]
}
```
Constraints (validated up-front, `400` on violation):
- Only `type: "function"` is accepted. `web_search`, `file_search`, `code_interpreter`, etc. are rejected. IronClaw routes those through its own registry, not caller-provided definitions.
- Names: `^[A-Za-z0-9_-]{1,64}$`, unique within the request.
- Names must not shadow a registered IronClaw action (built-in tool, extension tool, or an agent capability like `mission_*`, `skill_*`, `memory_*`). Shadowing is rejected to prevent confused-deputy behavior.
- The entire `tools[]` payload caps at 16 KiB of canonical JSON.
### The round trip
When the model calls one of your tools, the response completes with a `function_call` output item and the thread sits in `Waiting`:
```json
{
"id": "resp_...",
"status": "completed",
"output": [
{
"type": "function_call",
"id": "item_...",
"call_id": "call_ext_lookup_1",
"name": "lookup_weather",
"arguments": "{\"city\":\"NYC\"}"
}
]
}
```
The model may emit prose before the call. Any pre-call text is flushed as a leading `message` item so you see both pieces in order.
Execute the call locally, then POST a follow-up with a `function_call_output` item. Include `previous_response_id` so the resume targets the same thread:
```json
{
"model": "default",
"previous_response_id": "resp_...",
"input": [
{
"type": "function_call_output",
"call_id": "call_ext_lookup_1",
"output": "{\"temp_f\":72,\"conditions\":\"sunny\"}"
}
],
"tools": [ /* same tools array */ ]
}
```
The agent resumes, the LLM sees your tool result, and the final answer comes back as a normal `message` output item.
Pass the same `tools[]` on the resume request. The catalog is per-thread; passing the definitions keeps the tool available for any follow-up calls the model makes.
### Resume validation
The resume request must satisfy the bridge:
- A `function_call_output` item without a live external-tool gate on this thread returns `400`.
- A `function_call_output` item whose `call_id` does not match the pending gate returns `400`.
- A `function_call_output` item with an empty `call_id` or missing `output` returns `400`.
- A resume request that omits `previous_response_id` returns `400`.
- A resume `input` array must contain **only** `function_call_output` items — mixing in a `message` item returns `400`.
- If the thread is paused on an unrelated gate (OAuth, approval, pairing), resolve that first.
To ask a follow-up question alongside a tool result, complete the resume first, then send the new `message` as its own request with `previous_response_id` pointing at the resumed response.
### Multi-call batching
The engine pauses on the **first** external tool call in an assistant turn. If the model wants to invoke `tool_a` and `tool_b` together, only `tool_a` surfaces on the first response. After you resume, `tool_b` is emitted on the next turn. This is a known limitation; OpenAI-style "post all results together" is a follow-up.
### Streaming flow
With `stream: true`, the external-tool flow looks like:
1. `response.created`
2. Optional `response.output_text.delta` events for any leading prose.
3. `response.output_item.added` with the `function_call` item.
4. `response.output_item.done` with the same item.
5. `response.completed`.
The stream closes after `response.completed`. Send the resume as a fresh POST with `previous_response_id`.
---
## Structured context (`x_context`)
For integrations that need to pass structured state alongside the user message (notification approval, webhook payload, environment hints), use `x_context`:
```json
{
"model": "default",
"input": "Process the latest webhook.",
"x_context": {
"webhook": { "source": "stripe", "event": "invoice.paid", "amount_cents": 4200 }
}
}
```
The handler renders it as a `<user-context>` block ahead of the user message. Total serialized size caps at 10 KB. Pass a flat `{key: {object}}` structure; deeper nesting is serialized as raw JSON.
`x_context` is an IronClaw extension and is not part of the OpenAI Responses API spec.
---
## Per-request instructions
Use `instructions` to inject a one-turn system/developer message:
```json
{
"model": "default",
"input": "Summarize this.",
"instructions": "Respond in three bullet points, no preamble."
}
```
**IronClaw caveat — diverges from the OpenAI spec.** The OpenAI Responses API contract is that `instructions` apply only to the current turn and are not carried by `previous_response_id`. IronClaw currently prepends the `<instructions>` block into the user message and persists it as part of the conversation, so it *is* visible on later turns when history is replayed. Until the handler stores instructions out-of-band, treat them as sticky for the thread and re-send (or override) them explicitly on each turn that needs different behaviour.
The agent's persistent identity files (`AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md`) come from workspace memory and are not affected by this field.
---
## Errors
Errors from the Responses handler use the OpenAI envelope:
```json
{
"error": {
"message": "function_call_output items must include a non-empty `call_id` field",
"type": "invalid_request_error",
"code": null
}
}
```
Common codes:
| Status | `type` | When |
|--------|--------|------|
| `400` | `invalid_request_error` | Schema, validation, shadowed tool name, missing pending gate, unsupported field. |
| `429` | `rate_limit_error` | More than 30 requests in 60 seconds for this user. |
| `503` | `server_error` | Agent loop not started, database unavailable, or connection cap reached. |
A turn that completes but fails mid-flight (the model errored, a required tool raised) returns `200` with `"status": "failed"` and a populated `error` field. Inspect `status` before reading `output`.
Two cases do **not** use the JSON envelope and need separate handling:
- **`401` Unauthorized** comes from the gateway auth middleware before the request reaches the handler, and the body is a plain-text string (`Invalid or missing auth token`). Same for `403` (`Forbidden` for OIDC domain violations) and the `503` (`Database unavailable`) emitted by the middleware when the token store is down.
- **`GET /api/v1/responses/{id}`** returns `404` (still JSON-enveloped) when the response id is unknown or the thread does not belong to the authenticated user. `POST` with a foreign `previous_response_id` does **not** return `404`; the handler only decodes the UUID and dispatches into the agent, where the cross-user resume surfaces as a turn-level failure rather than an HTTP error. Treat cross-user resume as undefined and avoid relying on the response shape.
The Responses API does **not** support interactive approvals or authentication gates in the response stream. If the agent hits a tool that requires user approval (e.g. shell with a destructive command) or an extension OAuth flow, the turn fails with a clear error directing you to resolve the gate via the web UI or a different channel.
---
## SDK usage
The OpenAI SDKs are the easiest way to use this endpoint. Point them at IronClaw and they "just work":
<CodeGroup>
```python Python
from openai import OpenAI
client = OpenAI(
base_url="https://your-host/api/v1",
api_key="<your-bearer-token>",
)
# Basic
resp = client.responses.create(model="default", input="Hello.")
print(resp.output_text)
# With session continuity
follow_up = client.responses.create(
model="default",
input="What did I just say?",
previous_response_id=resp.id,
)
# With caller tools
import json
resp = client.responses.create(
model="default",
input="What is the weather in NYC?",
tools=[
{
"type": "function",
"name": "lookup_weather",
"description": "Return current weather.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
],
)
# Inspect the function_call item, run it locally, resume.
call = next(item for item in resp.output if item.type == "function_call")
result = {"temp_f": 72, "conditions": "sunny"} # your code
final = client.responses.create(
model="default",
previous_response_id=resp.id,
input=[
{
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result),
}
],
tools=[ ... ], # same definitions
)
print(final.output_text)
```
```typescript TypeScript
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://your-host/api/v1",
apiKey: process.env.IRONCLAW_TOKEN,
});
const resp = await client.responses.create({
model: "default",
input: "What is the weather in NYC?",
tools: [
{
type: "function",
name: "lookup_weather",
description: "Return current weather.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
],
});
const call = resp.output.find((it) => it.type === "function_call");
if (call) {
const result = { temp_f: 72, conditions: "sunny" };
const final = await client.responses.create({
model: "default",
previous_response_id: resp.id,
input: [
{
type: "function_call_output",
call_id: call.call_id,
output: JSON.stringify(result),
},
],
tools: [ /* same definitions */ ],
});
console.log(final.output_text);
}
```
</CodeGroup>
---
## Limits and quirks
- **Rate limit**: 30 requests per 60 seconds per user (shared with `/api/chat/send`).
- **Body size**: 14 MiB request limit at the gateway.
- **Turn timeout (non-streaming)**: 30 seconds. Use `stream: true` for long-running work.
- **Tool batching**: one external-tool call per round trip. The engine resumes the next call on the next turn.
- **Approvals and auth gates**: not surfaced over the Responses API. Resolve them via the web UI before retrying.
- **`tool_choice`**: accepted and ignored — there is no per-request tool-choice surface. `max_output_tokens` and other unlisted OpenAI fields are likewise accepted and ignored.
- **`model`**: required, any well-formed name (≤ 256 bytes). It is a routing hint echoed back in the create response; `GET` and cancel responses report the server placeholder `"reborn"`. Provider and model selection is server-side via settings.
- **Token usage on retrieval**: `GET /api/v1/responses/{id}` reads `usage` best-effort from persisted run state (token totals plus USD cost); it can be zero when the run reported no usage.
---
## Related
- [Inference Providers](/capabilities/llm-providers) — configure the model that backs `"default"`.
- [Configuration](/capabilities/configuration) — configuration file layout, profiles, and environment overrides.
- [Sandboxed Tools](/capabilities/sandboxed-tools) — how IronClaw's built-in tools execute when the agent calls them.
- [MCP](/extensions/mcp) — connect Model Context Protocol servers to extend the agent's tool surface server-side instead of supplying caller tools per-request.