REST API
The public surface of a running harness. Management endpoints (/audit, /approvals, /plans,
/jobs, /manifests, /eval, /usage) are in the Management API.
A live, always-accurate reference is served by the harness itself: GET /openapi.json, rendered
at /docs (Scalar) and /redoc.
Base URL and auth
Examples use $BASE_URL — set it to your deployment:
export BASE_URL=http://localhost:8080Requests carry a bearer token unless the harness runs with FELIX_AUTH_MODE=none:
export FELIX_KEY=sk-felix-local-…curl -s "$BASE_URL/health" -H "authorization: Bearer $FELIX_KEY"api_key mode also accepts Authorization: ApiKey <k> and X-Api-Key: <k>. See
Auth.
FELIX_AUTH_MODE=none is only legal on a loopback bind. The harness refuses to start if it is set
while FELIX_HOST is reachable off-host, in every environment — FELIX_ALLOW_INSECURE does not
override that. Compose defaults to api_key and make up mints a local key for you.
Always public, in every mode: /health, /metrics, /docs, /openapi.json, /redoc, and
everything under /.well-known/.
Cross-cutting behaviour
Middleware runs request id → body limit → rate limit → auth.
Request id is outermost, so every response carries an x-request-id — including one rejected by
the body limit or the rate limiter. Send your own in that header to have it echoed back; otherwise
one is generated.
| Condition | Response |
|---|---|
| Body over 1 MiB, declared or streamed | 413 {"error":"payload_too_large"} |
| Per-tenant rate limit exceeded | 429 {"error":"rate_limited"} |
| Missing or bad credentials | 401 {"error":"unauthorized","reason":"missing_credentials"|"invalid_api_key"} |
| Manifest requires a scope the caller lacks | 403 |
| Unknown manifest | 404 unknown_manifest:<name> |
| Manifest pinned and drifted | 409 |
A secret:NAME cannot be resolved |
503 |
| Upstream model provider failed | 502 |
Thread ids
thread_id on the wire is a suffix; the server prefixes the tenant id, so the real id is
{tenant_id}:{suffix}. A suffix containing : or # is rejected with 400 invalid_thread_id.
Omit it and the turn is stateless.
System
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness — {"status":"ok","env":…,"version":…,"multi_region":false,"federation":null} |
GET |
/metrics |
Prometheus text exposition |
GET |
/openapi.json |
OpenAPI 3.1 document |
GET |
/docs, /redoc |
Rendered API reference |
curl -s "$BASE_URL/health" | jqGET /chat/stream/{thread_id}
Reattach to a thread after a dropped stream or a page refresh.
curl -N "$BASE_URL/chat/stream/demo" \ -H "authorization: Bearer $FELIX_KEY" \ -H "last-event-id: 12"Without Last-Event-ID (the header, or a ?last_event_id= query parameter for clients that cannot
set headers) the stream opens with a single snapshot frame carrying the thread transcript. With
one, it replays only the session events after that cursor as session_event frames. Either way it
then tails the thread, sending : keep-alive comments while idle and closing after
FELIX_STREAM_RESUME_IDLE_SECONDS — reconnect with the last id: you saw and nothing is lost.
This recovers the thread, not the interrupted run. A disconnect still tears the run down, so that a client which hangs up stops incurring model cost. Reconnecting shows you what was persisted, not the turn you were watching.
POST /chat
Run a turn and wait for the result.
curl -s -X POST "$BASE_URL/chat" \ -H "authorization: Bearer $FELIX_KEY" \ -H 'content-type: application/json' \ -d '{"manifest":"quick","messages":[{"role":"user","content":"What is 7 * 6?"}]}' | jqRequest (extra: forbid):
| Field | Type | Default | Notes |
|---|---|---|---|
manifest |
string | required | Manifest name. |
messages |
object[] | [] |
OpenAI-shaped message dicts. |
thread_id |
string | null | null |
Suffix only. |
model |
string | null | null |
Mid-session override; allowlisted. |
template |
string | null | null |
Named template from spec.prompts. |
template_args |
string[] | [] |
Either messages or template must produce a message, else 400 messages_or_template_required. An empty manifest is 400 manifest_required. A model outside the
allowlist is 400 model_not_allowlisted:<id>.
Response:
{ "messages": [ ... ], "final": { "role": "assistant", "content": "42" }, "thread_id": "default:my-thread", "model": "claude-sonnet", "leaf_id": "evt_..."}Durable runs
If the manifest sets spec.execution.mode: durable, POST /chat instead returns 202:
{ "status": "accepted", "resume_token": "fib_...", "fiber_id": "fib_...", "expires_at": "2026-08-22T12:34:56Z", "thread_id": "default:my-thread"}Poll it:
curl -s "$BASE_URL/chat/runs/$RESUME_TOKEN" -H "authorization: Bearer $FELIX_KEY" | jqGET /chat/runs/{resume_token} returns {status, fiber_id, resume_token, expires_at, final, error, manifest_id}, or 404 run_not_found. Fibers checkpoint in Postgres and are resumed by the worker’s
fiber_scheduler cron — so felix-scheduler must be running.
POST /chat/stream
Same request body as /chat, streamed as Server-Sent Events.
curl -N -X POST "$BASE_URL/chat/stream" \ -H "authorization: Bearer $FELIX_KEY" \ -H 'content-type: application/json' \ -d '{"manifest":"quick","thread_id":"demo","messages":[{"role":"user","content":"Hi"}]}'A manifest with spec.execution.mode: durable streams the run rather than the turn. The first
frame is run_accepted and carries the resume_token; status changes arrive as run_status, and a
completed run emits final.
Disconnecting tears down the poll, not the run — the opposite of the transient path, where a
hung-up client deliberately kills the run so it stops burning tokens. A client that drops mid-run
reattaches with GET /chat/runs/{resume_token} rather than starting
over.
data: {"event":"run_accepted","data":{"resume_token":"…","expires_at":…}}data: {"event":"run_status","data":{"status":"running","resume_token":"…"}}data: {"event":"final","data":{"role":"assistant","content":"…"}}data: [DONE]A run that fails or expires says so in an event: error frame rather than closing quietly —
“expired” and “still running” look identical to a client that only sees a stream end.
Frame format
Almost every frame is a bare data: line, and the stream ends with a literal
data: [DONE]. Two exceptions worth coding for:
id:accompanies structural frames (tool_start,tool_end,tool_execution_update,on_chain_end,done,aborted). It is the thread’s next session sequence — hand it back asLast-Event-IDtoGET /chat/stream/{thread_id}after a dropped connection. Token-level frames carry noid:, which per the SSE spec leaveslastEventIdunchanged, so a client always holds the last structural cursor.event: erroris used for the one error frame, emitted if the stream fails after the 200 has already been sent. Handle it withaddEventListener("error", …); it will not arrive ononmessage.
Each payload is the same envelope:
{ "event": "text_delta", "type": "text_delta", "data": { ... }, "text": "derived" }event and type always carry the same name. text is a convenience: data.chunk.content if
present, else data.text or data.delta, else "".
Events
event |
data |
When |
|---|---|---|
session_progress |
{"phase":"turn"} |
A turn started on a thread |
session_progress |
{"phase":"compaction","reason":"after_turn"} |
Compaction fired mid-loop |
session_progress |
{"progress":{"type":"assistant_delta","kind":"text","delta":str}} |
Paired with each text_delta |
text_delta |
{"chunk":{"content":str},"delta":str} |
A model token chunk |
on_chat_model_stream |
{"chunk":{"content":str}} |
Composite patterns only (deep, router, parallel, groupchat, reflect, plan_execute) |
tool_start |
{"name":str,"input":dict,"id":str} |
Before a tool runs |
tool_execution_update |
{"name":str,"id":str,"status":"running"} |
Right after tool_start |
tool_end |
{"name":str,"output":str,"id":str} |
A tool returned |
tool_execution_update |
{"name":str,"id":str,"status":"complete"} |
Right after tool_end |
tool_request |
{"id","name","args","thread_id","transport":"client"} |
The client must run this tool |
approval_required |
{"approval_id","tool_name","args","rule_id","reason"?,"thread_id","tool_call_id"} |
A gated tool is waiting on a human |
ui_request |
{"request_id","kind","prompt","default","thread_id","options":[…],"metadata":{}} |
The agent is asking the user something |
steer |
{"content":str} |
A queued steer was drained into the loop |
follow_up |
{"content":str} |
A queued follow-up was drained after idle |
aborted |
{"thread_id":str} |
POST /chat/abort was observed |
on_error |
{"message":str} |
The run failed mid-stream. Not terminal — the stream still ends with done/[DONE], so a client that ignores it reports success |
on_chain_end |
{"output": InvokeOutput} |
Penultimate frame; carries per-turn usage |
done |
{"final": ChatMessage, "messages":[…]} |
Final frame before [DONE] |
tool_request, approval_required and ui_request are side-channel frames: the blocked tool
pushes them onto a per-thread in-process queue, which the stream drains. Because that queue lives in
the process running the turn, the SSE consumer must reach the same API replica as the run.
Sticky interrupts
Three frames pause the run until something answers them out of band. The run is not failing — it is waiting.
Client-executed tools
A tool_request frame means the caller runs the tool. The harness blocks on a waiter keyed
client:{thread_id}:{tool_call_id} and resumes when you post the result:
curl -s -X POST "$BASE_URL/chat/tool_result" \ -H "authorization: Bearer $FELIX_KEY" \ -H 'content-type: application/json' \ -d '{"thread_id":"demo","tool_call_id":"call_123","content":"total 4\n-rw-r--r-- README.md"}'| Field | Type | Default |
|---|---|---|
thread_id |
string | required |
tool_call_id |
string | required |
content |
string | object | array | "" |
error |
bool | false |
Non-string content is JSON-serialized server-side. Returns {ok, signaled, thread_id, tool_call_id}; the loop then emits a normal tool_end.
Failing to answer stalls the run until the tool’s timeout — spec.client_tools[].timeout_seconds,
default 120 s, max 3600 — after which the result becomes
[error/timeout] client tool timed out.
Approvals
An approval_required frame waits up to the rule’s ttl_seconds. Decide with
POST /approvals/{approval_id}/decide — see the
Management API.
UI prompts
A ui_request frame asks the user a question. kind is select, confirm or input. Answer it:
curl -s -X POST "$BASE_URL/chat/ui" \ -H "authorization: Bearer $FELIX_KEY" \ -H 'content-type: application/json' \ -d '{"request_id":"abc123","value":"option-a"}'| Field | Type | Default |
|---|---|---|
request_id |
string | required |
value |
any | null |
cancelled |
bool | false |
note |
string | "" |
The default wait is 300 s; on timeout the agent sees cancelled: true with note: "timeout".
Steering a live run
curl -s -X POST "$BASE_URL/chat/steer" \ -H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \ -d '{"thread_id":"demo","text":"Focus on the migration instead.","kind":"steer"}'kind: "steer" interrupts the remaining tool batch mid-run; kind: "follow_up" is delivered once
the run goes idle. Drain behaviour is session.steering_mode / follow_up_mode (all or
one-at-a-time). The queues are Redis lists, so this works across replicas.
Abort, continue, thinking, compact
| Method | Path | Body | Notes |
|---|---|---|---|
POST |
/chat/abort |
{thread_id} |
Sets a flag the loop polls each step; emits aborted, phase becomes aborted. |
POST |
/chat/continue |
{thread_id, manifest, model?} |
Resumes after an abort or error with no new user message. 400 nothing_to_continue / already_complete. |
POST |
/chat/thinking |
{thread_id, thinking_level} |
off minimal low medium high xhigh max. Persisted as a thinking_level_change event. |
POST |
/chat/compact |
{thread_id, manifest, instructions?} |
Compact the transcript now. |
Thinking levels map to token budgets: off (none), minimal 128, low 512, medium 1024,
high 2048, xhigh 8192, max 32000 — and to OpenAI reasoning_effort for OpenAI models.
Sessions
| Method | Path | Purpose |
|---|---|---|
GET |
/chat/sessions |
List sessions — {sessions:[…], items:[…]} |
GET |
/chat/sessions/search?q=&limit= |
Postgres full-text search — {query, hits} |
GET |
/chat/sessions/{thread_id} |
Authoritative snapshot |
GET |
/chat/sessions/{thread_id}/export |
Active branch as JSONL (application/x-ndjson, attachment) |
POST |
/chat/sessions/name |
{thread_id, name} — also appends a session_info event |
POST |
/chat/sessions/label |
{thread_id, event_id, label} |
POST |
/chat/sessions/custom |
{thread_id, content, in_context, metadata, role} |
GET |
/chat/history/{thread_id}?limit=&before_seq= |
Flat transcript, newest window — {thread_id, messages, events, oldest_seq, has_more} |
DELETE |
/chat/history/{thread_id} |
Reset the session log |
The session snapshot
GET /chat/sessions/{thread_id} is the contract a client hydrates from. Its keys are camelCase,
deliberately:
{ "id": "default:demo", "name": "Migration work", "parentSessionId": null, "createdAt": "…", "updatedAt": "…", "phase": "idle", "model": { "id": "claude-sonnet" }, "thinkingLevel": "medium", "attached": false, "locked": false, "revision": 12, "leafId": "evt_…", "labels": {}, "transcript": [ { "id","seq","kind","role","content","timestamp","status","metadata", "toolCallId","toolName","toolCalls","usage" } ], "queuedSteer": [], "queuedSteerCount": 0, "wake": { "fresh": true, "headSeq": 41, "endedOnAssistant": true, "pendingToolCalls": [] }}phase is one of idle, turn, compaction, branch_summary, retry, aborted. An idle
phase with pending tool calls is reported as turn.
Session event kinds: message, tool_call, tool_result, thinking, audit, compaction,
model_change, thinking_level_change, branch_summary, custom, label, session_info.
Paging thread history
GET /chat/history/{thread_id} returns the newest window of a thread, not the oldest, and is
bounded at 5000 events read whether or not you pass limit. A long transcript therefore comes back
truncated, with has_more: true. Page backwards by handing the previous response’s oldest_seq
back as before_seq.
Two details decide whether paging works:
limitcounts events read, not messages returned. The response filters out non-message kinds after the window is taken, solimit=50can legitimately yield fewer than 50 messages. Alimitbelow 1 is a400 limit_must_be_positive.oldest_seqis the window’s lower bound, not the first message’sseq. Those differ precisely when the filter dropped the oldest events in the window — so paging from the first message you can see would step over whatever was filtered and lose it.
messages and events carry the same array under two keys.
Not paginated, deliberately: GET /chat/sessions/{thread_id}/export returns the whole branch
(an export that silently truncates is worse than a slow one), and the session snapshot above
carries the transcript a reconnecting client rebuilds from.
Leases
Each client takes a lease so two tabs cannot drive one session at once.
curl -s -X POST "$BASE_URL/chat/sessions/lease" \ -H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \ -d '{"thread_id":"demo","holder_id":"tab-7","mode":"exclusive","ttl_seconds":300}'| Field | Type | Default |
|---|---|---|
thread_id |
string | required |
holder_id |
string, ≤128 chars | required |
mode |
exclusive | shared |
exclusive |
ttl_seconds |
float, 5–86400 | 300 |
token |
string | null | null |
exclusive sets locked: true on the snapshot; shared marks observers attached: true.
Contention returns 409. Release with POST /chat/sessions/lease/release
({thread_id, holder_id?, token?}); failure is 403. Leases are Redis-backed, with an in-process
fallback.
Fork and rewind
curl -s -X POST "$BASE_URL/chat/fork" -H "authorization: Bearer $FELIX_KEY" \ -H 'content-type: application/json' \ -d '{"thread_id":"demo","new_thread_id":"demo-branch","from_event_id":"evt_123"}'POST /chat/rewind takes {thread_id, event_id, summarize?, instructions?, manifest?} and moves the
active leaf back to event_id. Unless summarize: false (or session.branch_summary: false), the
abandoned branch is summarized into a branch_summary event using the manifest’s model.
OpenAI-compatible surface
model is the manifest name.
curl -s "$BASE_URL/v1/chat/completions" \ -H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \ -d '{"model":"quick","messages":[{"role":"user","content":"hi"}]}' | jq| Method | Path | Notes |
|---|---|---|
GET |
/v1/models |
Bundled manifests as OpenAI model objects |
POST |
/v1/chat/completions |
stream: true emits chat.completion.chunk frames, a final chunk carrying usage, then data: [DONE] |
The optional user field becomes the thread suffix ({tenant}:{user}). Errors come back in OpenAI
shape — {"error": {...}} — including manifest_drift, content_filter and model_gateway_error.
A2A
POST /a2a speaks A2A JSON-RPC for agent-to-agent calls. Peers declared in spec.peers become
peer__{name} tools on the calling agent, and hop depth is bounded by limits.max_peer_hops.
MCP
| Method | Path | Notes |
|---|---|---|
POST |
/mcp |
JSON-RPC: initialize, tools/list, tools/call. params.manifest selects the agent. |
GET |
/mcp |
Human-readable info |
Tool inputSchema is generated from each tool’s argument schema. A remote MCP tool that already
supplies JSON Schema is forwarded verbatim through raw_input_schema, preserving its descriptions
and enums.
Well-known
| Method | Path | Notes |
|---|---|---|
GET |
/.well-known/agent-card.json |
Built from FELIX_DEFAULT_MANIFEST |
GET |
/.well-known/jwks.json |
{"error":"not_configured","keys":[]} until FELIX_JWKS_PUBLIC is set |
Internal
POST /internal/sessions/{session_id}/events lets a queue consumer write a tool result back onto a
session. It requires the X-Felix-Consumer-Secret header matching FELIX_CONSUMER_SHARED_SECRET.
With the secret unset the route is available only when FELIX_AUTH_MODE=none outside production;
otherwise it returns 503 consumer_shared_secret_required. Content is screened, so a hostile payload
gets 422 content_screening_denied.
session_id is the full, tenant-prefixed thread id as it appears in the queue envelope
({tenant}:{suffix}, or {tenant}:fiber:{id} for a durable run) — not a bare suffix. It must belong
to the tenant the consumer’s own credential names, or the write is refused with
403 thread_not_in_tenant. Every other route composes a thread id from a client suffix and the
caller’s tenant; this one receives one already built, so it checks ownership instead.
Manifest variants
There is no request header that selects a variant. When a canary is active, the harness picks a
side deterministically from a hash of tenant, thread, manifest and the two version numbers, so a
given thread stays pinned to one variant for the life of the rollout. The resolved variant is
reported on GET /manifests/{name}. See
Manifests.