HTTP API #
Base path /v1. The interactive schema is served at /docs and the OpenAPI
document at /openapi.json; both are generated from the same models the service
validates against, so they cannot drift from what is enforced.
Authentication #
Present a key as X-API-Key: <secret> or Authorization: Bearer <secret>.
A key is configured as tenant:secret or tenant:secret:scope|scope; a client
presents only the secret half. The tenant and the scopes come from the
credential, never from the request, so a caller cannot claim authority by asking
for it.
A missing key and a wrong key produce byte-identical refusals, so the endpoint cannot be used to discover which keys exist.
/healthz and /readyz need no credential — a liveness probe that can fail
authentication is not a liveness probe.
Identity verification is separate from authentication #
Authentication answers "which integration is calling?". Verification answers "has the person on the other end proved who they are?". A correctly authenticated support widget still cannot read an account until the customer has verified, which is why verification is an endpoint on the conversation rather than a claim in a token.
Errors #
Every failing endpoint returns the same envelope:
{
"code": "not_found",
"message": "conversation not found or not accessible",
"request_id": "9f2c1b7e4a0d4f9e8b3c1a2d5e6f7a8b",
"detail": {}
}
| Status | code |
When |
|---|---|---|
| 400 | validation_error |
Input failed a documented rule |
| 403 | forbidden |
Missing, wrong or insufficiently scoped credential |
| 404 | not_found |
The resource does not exist or is not accessible to this tenant |
| 413 | request_too_large |
Body above security.max_request_bytes |
| 422 | validation_error |
Body did not match the schema |
| 502 | provider_error |
The model provider failed and no fallback applied |
| 500 | internal_error |
Unhandled. Logged in full; the client is told nothing else |
Validation errors report the field and the rule, never the submitted value — that value may be a customer message.
404 covers "does not exist" and "not yours" deliberately: distinguishing them
turns the endpoint into an oracle for enumerating identifiers.
A refusal is not an error. A run that refuses, asks a clarifying question or
escalates returns 200. Those are correct outcomes, and returning them as
failures would hide the refusal rate in an error dashboard.
Conversations #
POST /v1/conversations → 201 #
{ "customer_email": "ada@example.com" }
customer_email is optional. Supplying it binds the thread to a customer; it
does not verify them, because knowing an address is not proving one. An
unknown address binds nothing and is not reported as an error.
{
"conversation_id": "conv_5a1f…",
"identity_verified": false,
"customer_id": "cus_9b2e…",
"turns": 0,
"escalated": false,
"created_at": "2026-09-07T21:00:00Z"
}
POST /v1/conversations/{id}/verify → 200 #
{ "email": "ada@example.com" }
{ "conversation_id": "conv_5a1f…", "verified": true, "message": "Thanks, I have confirmed your identity." }
The response is the same shape whether the address is unknown or simply wrong.
This implementation checks a knowledge factor. A public deployment must replace it with a possession factor — see SECURITY.md.
POST /v1/conversations/{id}/messages → 200 #
{ "message": "Where is my order ORD-1005?", "include_trace": false }
{
"conversation_id": "conv_5a1f…",
"reply": "Order ORD-1005 is currently shipped. It is with Evri under tracking number EVR2233445566.",
"intent": "order_status",
"state": "answered",
"escalated": false,
"escalation_reason": null,
"refused": false,
"refusal_reason": null,
"ticket_id": null,
"provenance": [{ "kind": "tool_result", "reference": "call_7c1a…" }],
"unverified_claims": [],
"warnings": [],
"tool_calls": 1,
"steps_used": 6,
"duration_ms": 7.4,
"provider": "template",
"model": "template-v1",
"trace": null
}
include_trace is opt-in because the trace echoes the decisions back.
state is the terminal state of the run:
| State | Meaning |
|---|---|
answered |
A verified answer was produced |
clarifying |
The agent needs one more thing before it can act |
escalated |
Handed to a person; ticket_id is set when one was raised |
refused |
Declined; refusal_reason says why |
failed |
Something broke. Logged; the customer is told a colleague will help |
escalation_reason: customer_request, low_confidence,
policy_requires_human, unverifiable_answer, tool_failure,
budget_exhausted, repeated_failure, sensitive_topic.
refusal_reason: out_of_scope, identity_not_verified,
prompt_injection, unsupported_claim, policy_prohibited.
provenance points every stated fact at the evidence behind it.
unverified_claims is what the verifier withheld — normally empty, and
non-empty is worth alerting on.
GET /v1/conversations/{id} → 200 #
POST /v1/conversations/{id}/feedback → 204 #
{ "helpful": false, "comment": "did not answer my question" }
Feedback is joined to runs by conversation, so an unhelpful verdict can be read next to the trace that produced it.
Tools #
GET /v1/tools → 200 #
Publishes each registered tool with the constraints the registry enforces:
requires_identity, required_scopes, allowed_intents, idempotent, risk
and its JSON Schema. open_circuits names any tool currently unavailable.
This is generated from the same ToolSpec objects the registry checks, so the
published contract cannot drift from the enforced one.
Operations #
| Endpoint | Returns |
|---|---|
GET /v1/runs |
Recent runs: intent, confidence, final state, reasons, tool calls, steps, duration, whether verification passed |
GET /v1/runs/{id}/trace |
Every step of one run, in order |
GET /v1/tickets |
Recent tickets |
GET /v1/audit |
Recent audit events, each naming the key that acted |
GET /v1/feedback/summary |
Helpful and unhelpful totals |
None of these contain message text.
Health #
GET /healthz → 200 #
Reports that the process is running. Checks nothing external, so a slow dependency cannot cause a restart loop.
GET /readyz → 200 / 503 #
Reports whether this instance can serve traffic, with a component breakdown,
open_circuits, and warnings. A degraded model or an open circuit produces a
warning, not a failure — the agent degrades by escalating rather than by
stopping. Only an unreachable database makes it 503.
Headers #
Every response carries X-Request-ID, X-Content-Type-Options: nosniff,
X-Frame-Options: DENY and a restrictive Referrer-Policy. The server header
does not advertise the stack.
Limits #
| Limit | Default | Setting |
|---|---|---|
| Request body | 256 KiB | AGENT_SECURITY__MAX_REQUEST_BYTES |
| Message length | 4000 chars | AGENT_LIMITS__MAX_MESSAGE_CHARS |
| Turns per conversation | 40 | AGENT_LIMITS__MAX_CONVERSATION_TURNS |
There is no built-in rate limiting; put a limiter in front of the service.