Skip to content

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:

Terminal window
export BASE_URL=http://localhost:8080

Requests carry a bearer token unless the harness runs with FELIX_AUTH_MODE=none:

Terminal window
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
Terminal window
curl -s "$BASE_URL/health" | jq

GET /chat/stream/{thread_id}

Reattach to a thread after a dropped stream or a page refresh.

Terminal window
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.

Terminal window
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?"}]}' | jq

Request (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:

Terminal window
curl -s "$BASE_URL/chat/runs/$RESUME_TOKEN" -H "authorization: Bearer $FELIX_KEY" | jq

GET /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.

Terminal window
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 as Last-Event-ID to GET /chat/stream/{thread_id} after a dropped connection. Token-level frames carry no id:, which per the SSE spec leaves lastEventId unchanged, so a client always holds the last structural cursor.
  • event: error is used for the one error frame, emitted if the stream fails after the 200 has already been sent. Handle it with addEventListener("error", …); it will not arrive on onmessage.

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:

Terminal window
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:

Terminal window
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

Terminal window
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:

  • limit counts events read, not messages returned. The response filters out non-message kinds after the window is taken, so limit=50 can legitimately yield fewer than 50 messages. A limit below 1 is a 400 limit_must_be_positive.
  • oldest_seq is the window’s lower bound, not the first message’s seq. 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.

Terminal window
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

Terminal window
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.

Terminal window
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.