REST API
The public surface of a running harness. Management endpoints (/audit, /approvals, /plans,
/jobs, /manifests, /eval, /usage, /memory, /documents, /artifacts) are in the
Management API.
The full endpoint reference is at /reference/, rendered from the OpenAPI
document of the release api.felix.run runs — every release attaches its openapi.json. Your own
harness serves the same thing for the version you run: GET /openapi.json, rendered at
/docs (Scalar). Both are behind auth in api_key and jwt modes unless the operator sets
FELIX_DOCS_PUBLIC=true.
Base URL and auth
Section titled “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.
Unauthenticated in every mode: the probes /health, /live, /ready, and everything under
/.well-known/. /metrics is not public — its labels carry manifest and tool names — and
neither are /docs and /openapi.json unless FELIX_DOCS_PUBLIC=true. See
Auth.
Cross-cutting behaviour
Section titled “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 (12 MiB for POST /skill-library and PUT /skill-library/{name}/versions, which carry a whole skill bundle) |
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
Section titled “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
Section titled “System”| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness — {"status":"ok","env":…,"version":…,"multi_region":false,"federation":null} |
GET |
/live |
Liveness, named for what it is — the same static body as /health, no I/O |
GET |
/ready |
Readiness. Probes the dependencies and answers 503 when one is down, so a pod with a dead database leaves rotation instead of being restarted |
GET |
/metrics |
Prometheus text exposition. Authenticated — see Observability |
GET |
/openapi.json |
OpenAPI 3.1 document |
GET |
/docs |
Rendered API reference (Scalar) |
curl -s "$BASE_URL/health" | jqGitHub login
Section titled “GitHub login”How a person gets a bearer token without an operator minting one. Both routes are public, and
only while FELIX_GITHUB_CLIENT_ID is set; otherwise they sit behind auth like any other path.
Setup and the rules (org → tenant, TTL, consent phishing) are in
Auth. felix login and
felix_client.github_device_login drive this for you.
| Method | Path | Body | Answer |
|---|---|---|---|
GET |
/auth/methods |
none | {github_device, github_redirect, google_redirect, bearer_required, github_signup} |
GET |
/auth/github/authorize |
?return_to=&tenant= |
302 to GitHub (redirect sign-in) |
GET |
/auth/github/callback |
from GitHub | 302 back to return_to with #felix_login=ok or #felix_login_error=<code> (plus &login=<account> once GitHub said who signed in) |
POST |
/auth/github/exchange |
none (the sign-in’s cookie) | the same as /auth/github/token, once |
POST |
/auth/github/device |
none | {device_code, user_code, verification_uri, expires_in, interval} |
POST |
/auth/github/token |
{device_code, tenant?} |
{access_token, token_type: "Bearer", expires_in, tenant, scopes, github_login} |
GET |
/auth/google/authorize |
?return_to= |
302 to Google |
GET |
/auth/google/callback |
from Google | 302 back to return_to with #felix_login=ok or #felix_login_error=<code> (not_allowed for an account not on the list) |
POST |
/auth/google/exchange |
none (the sign-in’s cookie) | {access_token, token_type: "Bearer", expires_in, tenant, scopes}, once |
/auth/methods is public in every auth mode and answers without starting anything, which is how a
client decides whether to offer GitHub at all — probing /device would start a real flow and spend
from its hourly budget. bearer_required is false only under FELIX_AUTH_MODE=none, where any
bearer is accepted; a proxy that lets a caller’s own bearer stand in for its own lock must check it
first. github_signup is off, or invite when accounts outside the mapped orgs can be invited
(Signup); a harness older than the field omits it, which means off.
github_login names who signed in, and is "" from /auth/github/actions and from Google.
The three Google paths are public only while Google sign-in is
configured, and google_redirect says so; they give a token only to the accounts on
FELIX_GOOGLE_ACCOUNTS.
Show user_code and verification_uri to the person, then poll /token every interval seconds
until it answers 200. A refusal is {error, message, interval?, tenants?, github_login?}, where
github_login is the account refused, present once GitHub had said who it was:
| Status | error |
Do |
|---|---|---|
| 428 | authorization_pending |
Not approved yet. Wait interval (also Retry-After) and poll again |
| 428 | slow_down |
Same, at the new interval (or five seconds more if none is given) |
| 400 | expired_token, invalid_device_code |
The code is spent or expired. Start again |
| 403 | access_denied |
The person declined on GitHub |
| 403 | not_a_member, org_access_restricted, tenant_not_granted |
No configured org grants access (or the chosen tenant) |
| 403 | not_invited |
Signup is invite-only and this account, in no mapped org, is not on the list. Sign in with the invited one |
| 409 | tenant_ambiguous |
Orgs map to several tenants, listed in tenants. The flow is spent: start a new one passing tenant |
| 429 | rate_limited |
Too many flows started from this address. Retry-After says how long |
| 502 | github_unavailable |
GitHub failed or could not be reached. Try again |
| 503 | github_config_error |
The server’s OAuth app is misconfigured; the cause is in its log |
start=$(curl -s -X POST "$BASE_URL/auth/github/device")echo "$start" | jq -r '"Open \(.verification_uri) and enter \(.user_code)"'curl -s -X POST "$BASE_URL/auth/github/token" -H 'content-type: application/json' \ -d "{\"device_code\": $(echo "$start" | jq .device_code)}"From GitHub Actions
Section titled “From GitHub Actions”Public only while FELIX_GITHUB_OIDC_AUDIENCE is set. A job with id-token: write posts its
OIDC ID token, requested with that audience. Who may log in is the org’s actions block, and
Auth has the setup and a workflow recipe.
felix_client.github_actions_login and felix login --github-actions drive it.
| Method | Path | Body | Answer |
|---|---|---|---|
POST |
/auth/github/actions |
{id_token, tenant?} |
{access_token, token_type: "Bearer", expires_in, tenant, scopes} |
| Status | error |
Do |
|---|---|---|
| 401 | invalid_id_token |
The signature, issuer, audience or expiry did not check out. The reason is in the server’s log |
| 403 | workflow_not_granted |
No actions block admits this repository, ref, workflow, environment or event |
| 403 | tenant_not_granted |
The tenant passed is not one this workflow is granted |
| 409 | tenant_ambiguous |
The workflow is granted several tenants, listed in tenants. Retry the same ID token with tenant |
| 502 | github_unavailable |
GitHub’s signing keys could not be fetched. Try again |
GET /chat/stream/{thread_id}
Section titled “GET /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.
A session_event carries id, seq, kind, role, content, name, and — on the messages that
have them — tool_calls and tool_call_id. Those last two are what a tool card is built from: read
tool_calls off an assistant message to open one per call, and match tool_call_id on the following
tool_result to close it.
On an assistant message with readable reasoning, it also carries metadata: {"thinking": [{"type": "thinking", "thinking": str}, …]} — the same key the snapshot frame’s metadata already uses, so
one function folds both. This is what lets a durable run’s reasoning reach a watching client while
the run is still going: a durable stream carries no deltas, so before this the reasoning behind an
assistant turn appeared only once the run had landed and the client re-read the snapshot. Only the
readable text goes on the frame — a stored thinking block’s signature, and a redacted_thinking
block’s opaque data, exist to be replayed back to the model provider, not shown, and stay in the
log. The key is omitted entirely when a turn has nothing readable to show.
POST /chat
Section titled “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[] | [] |
A user message written /name args, where name is one of the manifest’s spec.prompts, is
replaced by that prompt’s expansion (slash commands); /v1/chat/completions does the same.
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>.
Message content
Section titled “Message content”A message’s content is a string or a list of parts, in OpenAI’s shape. The same parsing applies to
POST /chat, POST /chat/stream and POST /v1/chat/completions.
Part type |
Shape | Notes |
|---|---|---|
text (or no type) |
{"type":"text","text":"..."} |
Text parts are joined with newlines. |
image_url / image |
{"type":"image_url","image_url":{"url":"...","detail":"low"}} |
url is an https: or data: URL. media_type defaults to image/png. |
file |
{"type":"file","file":{"file_id":"..."}} |
An image uploaded with POST /files. A top-level file_id is also accepted. |
A file part is stored in the session log as a reference, and the bytes are swapped in just before
each model call. An inline data: URL is stored in the log whole, so a large image in a long thread
grows the log it sits in, and full_replay reads that log back every turn. A file_id that resolves
to nothing is dropped with a warning, and the turn still answers.
A part of any other type, or a file part with no file_id, is dropped with a logged warning.
A message can also carry images in a top-level attachments list instead of content parts:
{"role":"user","content":"...","attachments":[{"url":"felix-file://<file_id>","media_type":"image/webp"}]}.
url takes the same three forms: felix-file:// plus a file_id from POST /files, https:, or
data:. This is the shape the session log stores, and the snapshot returns it on each user message
as metadata.attachments, so a client can draw a thread’s images again after a reload. The web
UI uses it: it fits each image to the 600 KiB upload limit in the browser (re-encoding to WebP,
or JPEG where the browser cannot encode WebP), uploads it, and sends only the reference. A GIF over
the limit is refused instead of flattened to its first frame.
Response:
{ "messages": [ ... ], "final": { "role": "assistant", "content": "42" }, "thread_id": "default:my-thread", "model": "claude-sonnet", "leaf_id": "evt_...", "approvals": []}approvals lists every gate the run asked a human about. Each entry is the approval_required
frame a stream would have carried — approval_id, tool_name, args, rule_id, reason,
thread_id, tool_call_id, expires_at — plus status: approved, denied, or expired when
nobody decided before the deadline and the gate denied on its own. A plain response cannot report
anything until the run is over, so a caller blocked on a gate still waits; to decide it meanwhile,
find it with GET /approvals?thread_id=<thread_id> (see Approvals).
Durable runs
Section titled “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}, plus webhooks: {<id>: "pending" | "delivered" | "dead"} when the run named any, 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.
Completion webhooks
Section titled “Completion webhooks”A durable manifest with spec.execution.webhooks: [ops] is announced to each named endpoint when
the run reaches completed, failed, expired or dead. The worker’s webhook_delivery cron
sends it within about a minute of the run ending:
POST /felix HTTP/1.1content-type: application/jsonuser-agent: Felix-Webhooks/1webhook-id: fib_...:opswebhook-timestamp: 1790000000webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"run":{"error":"","expires_at":"...","fiber_id":"fib_...","final":{"content":"...","role":"assistant"},"manifest_id":"support","resume_token":"fib_...","status":"completed"},"tenant_id":"acme","thread_id":"acme:my-thread","type":"run.completed"}type is run.completed, run.failed, run.expired or run.dead; run is exactly what
GET /chat/runs/{resume_token} returns. Deliveries follow
Standard Webhooks, so a receiver can verify with an existing
library: the signature is base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{body}")),
where the key is the base64-decoded whsec_… secret, or the secret’s bytes otherwise.
import base64, hashlib, hmac
def verify(secret: str, headers: dict[str, str], body: bytes) -> bool: key = base64.b64decode(secret.removeprefix("whsec_")) if secret.startswith("whsec_") else secret.encode() signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + body expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() return any(hmac.compare_digest(expected, s) for s in headers["webhook-signature"].split())- Dedupe on
webhook-id. It is stable across retries, and a receiver may see one delivery twice. - Reject stale timestamps, a few minutes either way, so a captured delivery cannot be replayed.
- Answer 2xx. Anything else, a redirect, or no answer within
FELIX_WEBHOOK_TIMEOUT_SECONDS(10 s by default, at most 60) is a failed attempt: it is retried after 1m, 2m, 4m … up to 1h, and markeddeadafterFELIX_WEBHOOK_MAX_ATTEMPTS(8). The response body is ignored. - Check
tenant_idon an endpoint registered for"*": any tenant can produce a run signed with its secret. - Dispatch on
type. An endpoint can also receiveskill.update_availableevents, under the same secret, whenFELIX_SKILL_UPDATE_WEBHOOKSbinds it to a tenant — see Skill import → Update notifications. Those carry awebhook-idofskill_update_<hex>and norun.
POST /chat/stream
Section titled “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"}]}'Resending safely: Idempotency-Key
Section titled “Resending safely: Idempotency-Key”A streamed send that fails in the client — no answer, a proxy’s 5xx, a connection that dropped
mid-reply — may or may not have reached the turn, and the client cannot tell which. Send an
Idempotency-Key header and a resend of the same message never runs a second turn or appends a
second user message:
curl -N -X POST "$BASE_URL/chat/stream" \ -H "authorization: Bearer $FELIX_KEY" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{"manifest":"quick","thread_id":"demo","messages":[{"role":"user","content":"Hi"}]}'- The key is visible ASCII, at most 255 characters, with no
{or}; anything else is400 invalid_idempotency_key. It needs athread_idin the body — a threadless stream writes no log a retry could be answered from — and is400 idempotency_key_requires_thread_idwithout one. It is scoped to the tenant, the principal and the thread, and lives forFELIX_IDEMPOTENCY_TTL_SECONDS. - Mint one per message, not per request. Every resend of a message reuses its key, with a
byte-identical body: the key is bound to a fingerprint of the body, and the same key with a
different one is
422 idempotency_key_reused. Re-uploading an image hands back a new file id, so a client keeps what it sent and sends that again. An edited message is a new message, under a new key.
What a resend gets depends on where the first request is:
| First request | Resend gets |
|---|---|
| Still streaming | 409 idempotency_in_progress. Nothing runs; reattach with GET /chat/stream/{thread_id}, as after any dropped stream. |
| Ended, having appended to the thread | 200 with Idempotent-Replayed: true, and a replay instead of a turn (below). |
| Ended having appended nothing — it failed before its turn began, or the client left before the body was sent | The key was released, so the resend runs normally. |
| Started a durable run | A reattach to that run’s stream, with Idempotent-Replayed: true. |
The replay is what the first request itself wrote to the thread — each event it appended is stamped
with its origin, so a label, a steer or another client’s turn that landed meanwhile is not in it —
in the shape a reattach uses, then that request’s error frame if it ended in one, then [DONE]:
id: 5data: {"event":"session_event","data":{"id":"…","seq":4,"kind":"message","role":"user","content":"Hi"}}
id: 6data: {"event":"session_event","data":{"id":"…","seq":5,"kind":"message","role":"assistant","content":"Hello!"}}
event: errordata: {"error":{"message":"…","type":"stream_error"}}
data: [DONE]It is finite, carries no deltas, and runs nothing, so fold it with the code that folds a reattach’s
session_event frames. A transient turn is torn down when its client disconnects, so a first
request cut off mid-reply often replays its user message and nothing after it: the message landed
once, and the conversation goes on with POST /chat/continue or a
new message. The key is claimed after inbound auth, screening and the lease check,
so a resend is judged as the first request was, and a refused one is never stored as the key’s answer.
POST /chat takes the header too, scoped to the principal alone: a resend after the first finished
gets its stored response back with Idempotent-Replayed: true, one while it runs is
409 idempotency_in_progress, and a different body is 422 idempotency_key_reused.
chat-ui sends a fresh key with every message. When a send fails it puts the message back in the composer, and sending it again unchanged reuses the key, so the transcript shows what the first attempt left — once.
The agent’s checklist
Section titled “The agent’s checklist”An agent with todo_write in its tools announces each change to its
checklist, with the whole list:
data: {"event":"todo_updated","data":{"todos":[{"id":"1","content":"Reproduce the failure","status":"in_progress","active_form":"Reproducing the failure"},{"id":"2","content":"Fix it","status":"pending","active_form":""}]}}The same list is on the session snapshot as todos — the last successful write on the current
branch — so a client that reloads draws it without waiting for the next write, and a rewind shows
the list that point in the conversation had.
Permission mode changes
Section titled “Permission mode changes”When an approved plan ends plan mode mid-run, the stream says so, so a client can update its mode indicator without reloading:
data: {"event":"permission_mode_changed","data":{"mode":"default"}}Child agents on the stream
Section titled “Child agents on the stream”When an agent hands work to a child through the task tool,
the stream marks where the child’s work begins and ends, so a client can render it apart from
the parent’s:
data: {"event":"subagent_start","data":{"agent":"researcher","background":false}}data: {"event":"subagent_end","data":{"agent":"researcher","outcome":"ok"}}outcome is ok or error. Both arrive inside the task call — after its tool_start, before
its tool_end. A background child sends only subagent_start, with background: true, its
task_id, and the thread_id its transcript is written to (linked to the parent’s as
parentSessionId); it finishes after the turn that started it, and its answer is read with
task_result.
Frame format
Section titled “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
Section titled “Events”session_progress- Three shapes. A turn started on a thread:
{"phase":"turn"}- Compaction fired mid-loop:
{"phase":"compaction","reason":"after_turn"}- Paired with each
text_delta: {"progress":{"type":"assistant_delta","kind":"text","delta":str}}text_delta- A model token chunk
{"chunk":{"content":str},"delta":str}on_chat_model_stream- Legacy — no longer emitted. A harness older than
felix-run/felix#603sent each chunk of a composite pattern’s streamed answer (parallel,plan_execute) a second time under this name, beside thetext_delta. A client that reads both should ignore it once atext_deltahas arrived, or draw the answer twice {"chunk":{"content":str}}tool_start- Before a tool runs
{"name":str,"input":dict,"id":str}tool_execution_update- Right after
tool_start {"name":str,"id":str,"status":"running"}tool_end- A tool returned.
attachmentsis present only when it returned images, each a storedfelix-file://reference to fetch fromGET /files/{id}(felix-run/felix#500) {"name":str,"output":str,"id":str,"attachments"?:[{"url","media_type"}]}tool_execution_update- Right after
tool_end {"name":str,"id":str,"status":"complete"}tool_request- The client must run this tool
{"id","name","args","thread_id","transport":"client"}approval_required- A gated tool is waiting on a human
{"approval_id","tool_name","args","rule_id","reason"?,"thread_id","tool_call_id"}ui_request- The agent is asking the user something
{"request_id","kind","prompt","default","thread_id","options":[…],"metadata":{}}steer- A queued steer was drained into the loop
{"content":str}follow_up- A queued follow-up was drained after idle
{"content":str}workspace_note- A run put an operator’s workspace edit in front of the model before its next call
{"path","op","bytes","to_path"?}todo_updated- The agent rewrote its checklist (
todo_write). Sent from inside the call, before itstool_end, and only for a write the harness accepted; the list is also the call’s arguments, and the snapshot’stodosholds the current one {"todos":[{"id","content","status","active_form"}]}subagent_start- A
taskcall handed a job to a child agent. A background child (background: true) runs as a durable run on a thread of its own and sends nosubagent_end. Neither frame carries the call id {"agent","background","task_id"?,"thread_id"?}subagent_end- A foreground child agent finished
{"agent","outcome":"ok"|"error"}permission_mode_changed- An approved
exit_plan_modemoved the thread out of plan mode mid-run.POST /chat/modeemits none; the snapshot’spermissionModecarries it {"mode"}max_turns- A
reactloop ran out ofspec.recursion_limitsteps with tool calls still pending. The session is recordedtruncatedanddonesaysstop_reason: max_turns; this frame is the one that carries the limit {"limit":int}abortedPOST /chat/abortwas observed{"thread_id":str}on_error- The run failed mid-stream. Not terminal — the stream still ends with
done/[DONE], so a client that ignores it reports success {"message":str}on_chain_end- Penultimate frame. Its
outputis the harness’s in-process result object and reaches the wire as a Pythonreprstring: there is nothing in it for a client to read. Usage rides ondone {"output": str}done- Final frame before
[DONE].stop_reasonis how the run ended —end_turn,tool_use,max_tokens,max_turns,stop_sequence,pause_turn,refusal,unknown— and is what/v1maps tofinish_reason.usageis the final model call’s block, absent when the provider reported none (and on a harness older thanfelix-run/felix#399).inputexcludes cached tokens, so the prompt isinput + cacheRead + cacheWrite. Because that call’s prompt was the whole active branch,totalTokensis also how full the context window is {"final": ChatMessage, "messages":[…], "stop_reason": str, "usage"?: {"input": int, "output": int, "cacheRead": int, "cacheWrite": int, "totalTokens": int, "cost": {…}}}
Sticky interrupts
Section titled “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
Section titled “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
Section titled “Approvals”An approval_required frame waits up to the rule’s ttl_seconds — five minutes when the rule sets
none. Decide with POST /approvals/{approval_id}/decide — see the
Management API.
Not answering is answering. On timeout the harness returns denied with the note timeout:
the tool is refused and the run moves on. A client that keeps offering the choice past the deadline
is asking about a decision already made, so show the deadline and stop accepting an answer at it.
The same number is the grant’s lifetime — approving authorizes every byte-identical call to that
tool, matched on a hash of the arguments, until it expires.
Poll GET /approvals as well as reading the stream. The frame is prompt on a streaming run, and
since felix#232 a polled row carries thread_id like the frame does, so the two channels attribute
a waiting approval the same way. See Approvals for how far that
attribution goes. The poll is still what an operator console should be built on: it finds the
approvals no stream was open for. A non-streaming POST /chat has no frame to read at all: poll
GET /approvals?thread_id= while it blocks, and read approvals on its response for how each gate
ended.
Approvals on a durable run
Section titled “Approvals on a durable run”A durable run’s agent is in the worker while its stream is served by the API, so the in-process side
event cannot cross. Since felix#246 the durable stream announces gates anyway — it reads the
pending rows for the run’s thread and rebuilds approval_required from them, rather than
forwarding a message over a bus. (A bus would be at-most-once, and here the message is the prompt:
drop it and the run blocks its whole ttl_seconds before denying, with nobody ever asked. Rebuilding
from the durable row also means a client that attaches after a gate fired still sees it.)
Three things to know before relying on it:
- It needs the
approvals:readscope, the same oneGET /approvalsneeds. Without it a durable run still streams its transcript, status and answer, and says nothing about gates.thread_idis supplied by the caller and Felix does not bind a thread to a principal, so ungated this would let chat access alone read the tool names, arguments and gate reasons of any thread in the tenant. - Each gate is announced once per stream.
GET /approvalsanswers “what is pending now”, so a row returns on every poll until decided; re-showing a prompt someone already answered is worse than showing it late. A decided or expired gate is never announced — both are history. - The poll remains the channel of record. A stream that was never opened, or that dropped before the gate fired, sees nothing. These frames are a convenience for a watching client, not a replacement for polling.
An approval request is never written to the session log, so the transcript tail added in felix#238 does not carry it — that is the gap this closes.
UI prompts
Section titled “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 '{"thread_id":"my-thread","request_id":"abc123","value":"option-a"}'| Field | Type | Default |
|---|---|---|
thread_id |
string | required — your thread id, the one the stream was opened on |
request_id |
string, ≤ 64 chars | 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".
A prompt is answerable only on the thread that asked it, and a thread is scoped to your tenant, so
knowing a request_id is not enough to answer someone else’s prompt. Send your own thread id — the
suffix you opened the stream with, not the namespaced thread_id on the frame. An answer for a
prompt on another thread is accepted (ok) and reaches nothing, the same as an answer for one that
already timed out.
Steering a live run
Section titled “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 — it has answered, or a terminal tool ended it — and the run takes another turn to
answer it, tools and all, in the same stream. Drain behaviour is session.steering_mode /
follow_up_mode (all or one-at-a-time): under all every follow-up waiting is answered in one
turn, under one-at-a-time each in its own. A run that ends any other way (a budget, a truncated
reply, the step limit, an abort) leaves its follow-ups for the thread’s next run. The queues are
Redis lists, so this works across replicas.
A steer sent while no run is going is held for the next run on that thread and delivered at
its start, right after that run’s own user turn — so “prefer metric units” shapes the next answer
rather than being lost. Every held steer is delivered then, whatever steering_mode says, and it
does not cancel that run’s tools. A steer or follow-up that arrives just after a run’s last check
is kept for the next run the same way. queuedSteerCount on GET /chat/sessions/{thread_id}
counts what is waiting.
Telling the agent about an edit
Section titled “Telling the agent about an edit”When an operator changes a workspace file the agent is working on — in chat-ui’s side pane, say —
the agent still holds what it read before. POST /chat/workspace/edited tells it.
curl -s -X POST "$BASE_URL/chat/workspace/edited" \ -H "Authorization: Bearer $FELIX_API_KEY" -H 'content-type: application/json' \ -d '{"thread_id":"demo","path":"notes/plan.md","op":"write","bytes":1204}'The body is structured fields only — path, op (write, delete or rename), bytes (not on
a delete) and to_path (a rename’s destination, and only then); the harness writes the sentence the
model reads, so no client prose reaches it. With a run in flight the answer is status: "queued":
the note is put in front of the model before its next call, without cancelling the tool calls in
progress — unlike a steer — several edits to one path collapse to the last, and the stream emits
workspace_note. With the thread idle it is status: "recorded" with an event_id: the note is
appended at once and the next run reads it. Either way it lands in the log as a custom entry
(role: "user", metadata.type: "workspace_edit", in_context: true), so a snapshot carries it.
The route is lease-guarded like /chat/steer.
Reading and writing the workspace
Section titled “Reading and writing the workspace”An agent whose file tools are the harness’s (read_file, write_file, …) works in a workspace on
the harness — the manifest’s spec.workspace.scope, or the thread’s repository checkout when it has
one — that a browser’s own files never reach. Three routes open it to a client, resolved exactly as
the tools resolve it: by the manifest the thread last ran under, or manifest for a thread that has
not run.
| Method | Path | Notes |
|---|---|---|
GET |
/chat/workspace/tree?thread_id=&limit=&manifest= |
{root_kind, scope, manifest, entries:[{path, type, bytes?}], truncated}. Recursive, symlinks neither followed nor listed, .git left out; limit 1–5000, default 2000. |
GET |
/chat/workspace/file?thread_id=&path=&manifest= |
{path, bytes, sha256, encoding: "utf-8"|"base64", content}. 413 too_large past 512,000 bytes, 404 not_found, 400 not_a_file / invalid_path / reserved_path. |
POST |
/chat/workspace/write |
{thread_id, path, content, expected_sha256?, manifest?} → {status, path, bytes, sha256, event_id}. |
POST |
/chat/workspace/delete |
{thread_id, path, expected_sha256?, manifest?} → {status, path, event_id}. A file only: 400 not_a_file for a directory. |
POST |
/chat/workspace/rename |
{thread_id, path, to_path, expected_sha256?, manifest?} → {status, path, to_path, sha256, event_id}. Never overwrites: 409 target_exists. Creates to_path’s folders. |
POST |
/chat/workspace/delete_folder |
{thread_id, path, expected_count?, manifest?} → {status, path, files, event_id}. Recursive, never through a link; refuses the root, a tree holding .git, and one over 2,000 entries (409 too_many_entries). expected_count is the number of files the operator was shown: another count now is 409 workspace_changed with count, and nothing is deleted. |
POST |
/chat/workspace/rename_folder |
{thread_id, path, to_path, manifest?} → {status, path, to_path, event_id}. Never over anything (409 target_exists), never into itself (400 invalid_path), same 2,000-entry cap. |
The write, the delete and the rename are each conditional when expected_sha256 is sent: if the file is no longer what the
client read, it is refused 409 {"detail":"workspace_changed","sha256","bytes"} and nothing is
changed. Each also tells the agent, exactly as /chat/workspace/edited does (op write,
delete or rename) — status is that
note’s queued or recorded — so a client does not call both. It is lease-guarded like a steer.
Whether an agent works there at all is on its /v1/models entry:
felix.workspace: {"tools": "server"|"client"|"both"|"none", "scope": …}. client means its
file tools are the browser’s (local_*), and its files are not on the harness.
An agent can also ask to show the operator one of these files, with the open_file tool. The
harness opens nothing: it checks the file is one GET /chat/workspace/file would serve and answers
{"opened": "<path>", "kind": "file", "in": "side_pane", "size": N}, or a [tool error/…]
refusal. A client that has a file pane opens what a successful result names, once per tool call id,
and only for a call it watched finish — a tool_end frame, or a durable run’s tool_result
session_event — never for one in a transcript it rebuilt from the session, or a reload would
reopen every file the thread ever opened. The result is the whole signal on purpose: it reaches a
durable run’s stream, where a side event cannot. chat-ui opens it in the thread’s side pane.
Abort, continue, thinking, compact
Section titled “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/mode |
{thread_id, mode} |
default plan accept_edits bypass — the conversation’s permission mode, from the next run. bypass needs approvals:bypass (403 missing_scope:approvals:bypass). Audited as permission_mode_change. |
POST |
/chat/style |
{thread_id, manifest, style} |
The conversation’s output style, from the next run: one manifest declares, or null for its default. manifest is required with a style (422 manifest_required; 404 unknown_manifest), and a name it does not declare is 422 unknown_output_style. Answers {ok, thread_id, style}. |
POST |
/chat/ask |
{thread_id, question} |
A side question: answered from the thread’s context by the agent it last ran under, with no tools, and added to nothing — no session event, no lease (so it works mid-run, and from a client that only watches). Answers {thread_id, manifest, status, answer, usage}, status being answered, not_in_context or withheld (a final-response judge refused it). 404 unknown_thread for a thread the harness has no record of. |
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
Section titled “Sessions”| Method | Path | Purpose |
|---|---|---|
GET |
/chat/sessions?limit=&cursor= |
List sessions, newest first, one page at a time — {sessions:[…], items:[…], next_cursor}. limit is 1–500 (default 100); pass next_cursor back as cursor for the next page, and it is null on the last. Each row has id, createdAt, updatedAt, parentSessionId, sessionName, preview (first user message, masked, ≤120 chars) and manifest (the newest turn’s), the last two null when unknown (felix sessions backfill-previews fills preview for threads created before v0.11.2) |
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/feedback |
{thread_id, event_id, rating: "up" | "down" | null, note?} — rate an assistant turn |
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 |
/chat/sessions/feedback rates an assistant message, and null clears the rating. An id the thread
does not have is 404 unknown_event_id, and anything that is not an assistant message is
400 not_an_assistant_message. The current rating per event comes back on the snapshot as
feedback ({rating, note, at}), and every change is also an audit event, turn_feedback, so
GET /audit?event_type=turn_feedback lists them across the whole tenant. Nothing is written to the
session log, so rating a turn does not move the thread’s leaf. In the web UI a thumbs-down asks
what was wrong. With a note, it can also save the question as an eval case, judged against that
note: a complaint becomes a regression test.
/chat/sessions/custom appends an entry from outside the conversation: a note, or an instruction
from a plugin. role defaults to system, and in_context (default false) decides whether the
model ever sees it. With in_context: true the entry enters the model’s context under the role
you gave it, so a system entry steers every later turn and an assistant one reads to the model
as something it said itself. The entry lands in the log as kind: "custom" with the flag in
metadata.in_context. Both Felix clients draw it as a note that names its role and says whether
the model read it, never as a message from either side.
The session snapshot
Section titled “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": [] }, "activeRun": { "resumeToken": "fib_…", "status": "running", "expiresAt": 1760000000000 }}phase is one of idle, turn, compaction, branch_summary, retry, aborted. An idle
phase with pending tool calls is reported as turn. A durable run writes no phase — its agent is
in the worker — so the thread reads idle while one works; activeRun is that run, or null.
Its resumeToken is what GET /chat/runs/{resume_token} takes.
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
Section titled “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
Section titled “Leases”A lease keeps two clients from driving one session at once. Each thread has one exclusive hold
(mode: exclusive — the client driving it) and any number of observer holds (mode: shared —
clients watching it read-only). Every hold has its own token and its own expiry.
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 — required to renew |
A grant answers {ok, renewed, token, mode, held_by_other, status, snapshot}. token is this hold’s
own, and held_by_other says someone else is driving the thread.
exclusiveis409 lease_heldwhile another holder has the exclusive hold. Observers never block it: a thread held only by observers is free to drive.sharedalways succeeds, with a token of its own — never the exclusive one. On a thread another holder drives it returns200withheld_by_other: true, and that hold is read-only. On a free thread it makes an observer-only lease. This is what a second tab falls back to when itsexclusiveacquire is409.- Renewing is acquiring again with the hold’s
token, and extends that hold and nothing else. The holder id alone is no proof — every status publishes the exclusive holder’s, and a duplicated browser tab copies its own — so a renewal without the token is409 lease_heldand returns no token. An observer’s renewal never extends the exclusive hold. A driver that wants to step down to watching renews insharedmode with its exclusive token. - Nobody is promoted. When the exclusive hold is released or lapses, observers stay observers.
An observer that wants to drive acquires
exclusiveitself — chat-ui retries on every renewal while it watches, so a watching tab takes over within one renewal of the driver leaving.
Release with POST /chat/sessions/lease/release ({thread_id, token, holder_id?}). It drops the one
hold the token names and leaves every other in place. The token is required: without it the answer
is 403 token_required, and a holder_id sent alongside must be that hold’s holder
(403 token_mismatch / not_holder otherwise). 409 lease_contended means concurrent changes
on Redis kept the release from landing; nothing was released, so retry.
GET /chat/sessions/{thread_id}/lease reports who holds the thread:
{ "locked": true, "attached": true, "holder_id": "tab-7", "mode": "exclusive", "observers": 1, "observer_holds": [{ "holder_id": "tab-9", "expires_at": 1767225600000 }], "expires_at": 1767225480000, "token_hint": "Zx3k9q"}holder_id is the exclusive holder and is null when only observers hold the thread. locked means
someone drives it, attached that anyone holds it. expires_at values are epoch milliseconds: the
exclusive hold’s, or the last observer’s when there is none.
Presenting the token on writes
Section titled “Presenting the token on writes”Leases are advisory unless a client opts in. The routes that drive a thread accept an
X-Felix-Lease-Token header: POST /chat, /chat/stream, /chat/continue, /chat/abort,
/chat/rewind, /chat/steer, /chat/tool_result, /chat/ui, /chat/compact, /chat/thinking, /chat/mode, /chat/style,
/chat/sessions/name, /chat/sessions/label, /chat/sessions/custom, /chat/workspace/edited, /chat/workspace/write, /chat/workspace/delete,
/chat/workspace/rename, /chat/workspace/delete_folder, /chat/workspace/rename_folder and
DELETE /chat/history/{thread_id}. When the header is present, the request is refused unless it is
the exclusive hold’s token:
409 detail |
Meaning |
|---|---|
lease_read_only |
The token is an observer’s: this client is watching. |
lease_held |
Another holder has the thread exclusively — this client’s hold lapsed or was never the driver’s. |
Under the default, FELIX_LEASE_ENFORCE=advisory, a request without the header is not checked,
so a script, the OpenAI-compatible surface and A2A are unaffected.
That also means the header protects a client from its own mistakes, not the thread from a caller
that leaves it off. /chat/fork only reads its source and
/chat/sessions/feedback rates a reply without writing the log, so neither checks it; approvals are
decided through /approvals, gated on the approvals:write scope.
FELIX_LEASE_ENFORCE=strict makes the lease binding on those same routes. A request without
the header is then 409 lease_held while another holder has the thread exclusively. A thread
nobody holds, or one only observers hold, still takes a request without a token, so scripts keep
working whenever no client is driving; with the header the table above applies unchanged.
/v1/chat/completions is checked too when it sends user, because user names the same thread a
chat thread_id does ({tenant}:{user}): it is refused, with an OpenAI-shaped error, on a thread a
chat client drives, and may send X-Felix-Lease-Token itself. A2A threads ({tenant}:a2a:{task})
and MCP calls cannot reach a chat thread and are not checked. Strict is still a consistency control
between cooperating clients, not an authorization boundary: every caller in the tenant may acquire a
lease.
A client refused the exclusive hold should therefore watch rather than go without one: take a
shared hold and send its token, so the harness answers lease_read_only — a refusal that says
“you are watching” — rather than lease_held under strict or, under advisory, nothing at all.
chat-ui and the terminal both do.
Leases are Redis-backed, so they hold across replicas, and every change is one WATCH/MULTI
transaction, so two replicas cannot both grant the exclusive hold. Without Redis they fall back to
per-process state, where each replica can grant its own.
Fork and rewind
Section titled “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"}'new_thread_id must name a thread that does not exist yet: one with no events and no session
metadata, and not the source thread itself. Otherwise the fork is refused with
409 {"detail": "thread_exists"} and nothing is written. Mint a fresh id per fork (chat-ui and the
TUI use a random UUID). Two forks racing to the same new id leave exactly one fork.
POST /chat/rewind takes {thread_id, event_id, summarize?, instructions?, manifest?, restore_files?}
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.
Restoring files on rewind
Section titled “Restoring files on rewind”With restore_files: true, the rewind also undoes what the agent’s file tools did in the thread’s
own workspace on the branch it abandons. Every write, edit, delete, rename and multi-edit those tools
make is recorded: the file as it was before, and as the tool left it. The rewind puts each changed
file back to its state before the branch’s first change. The answer gains files:
{ "ok": true, "leaf_id": "…", "files": { "restored": ["src/app.py"], "removed": ["notes/draft.md"], "conflicts": ["README.md"], "skipped": [], "truncated": false }}restored: written back as it was. A file the branch deleted or renamed away is recreated.removed: deleted, because it did not exist before the branch (a new file, or a rename’s destination).conflicts: left alone, because it is no longer what the branch’s last change left. Someone edited it, or a shell command rewrote it.skipped: left alone, because its earlier state was not kept. This covers files over 512 KB, content past the tenant’s ceiling, and content the store refused.truncated: the branch changed files more than 2,000 times. Only the first 2,000 changes were considered, and the rest can no longer be restored.
If the restore itself fails (the database or object store is unavailable), the rewind still happens
and files is {"error": "unavailable"}. The records are kept, so a later rewind can still restore.
Every rewind settles the branch it leaves. With restore_files the branch’s changes are put
back; without it they are kept as they are. Either way the records of that branch are then
forgotten. A later restore undoes only what changed after the last rewind, so rewinding twice to
the same point never reaches back past the first rewind.
Each restore is audited. It writes a workspace_restore audit row with the caller, thread, event
and the four path lists, as file-pane writes and deletes do.
A few limits apply:
- The thread’s own workspace only. A manifest whose
spec.workspace.scopeistenantordeploymentshares its files across threads. Undoing one thread’s branch there could remove a file another thread relies on, so those changes are not recorded. - Only the file tools are recorded. A
shell_toolscommand’s writes are not, and neither are the operator’s file-pane edits. A file a command changed afterwards shows up as a conflict rather than being silently overwritten. - Rewinding onto another branch undoes the abandoned branch’s changes back to where the two branches split. It does not replay the target branch’s own changes.
- Recording never blocks a write. If recording fails, the write goes ahead, the failure is
counted as
felix_file_checkpoint_failed, and that change cannot be restored. If it was a file’s first change on the branch, a restore puts back the file’s next recorded state. - Storage. Earlier contents are kept in the object store once per tenant, by digest, so a file
changed step after step is not stored again each time. A tenant keeps at most
FELIX_FILE_CHECKPOINT_MAX_BYTES_PER_TENANT(256 MiB); past that, changes are recorded but not kept. A file’s records in a thread are swept onceFELIX_FILE_CHECKPOINT_RETENTION_DAYS(7 days) have passed since its last change there, however busy the thread stays. Deleting a thread does not delete them sooner, so a file the agent deleted can stay in the object store until then.FELIX_FILE_CHECKPOINTS=falserecords nothing.
The Python client takes the same flag: await client.rewind(event_id, restore_files=True).
The next message appends as a child of the new leaf, so a rewind followed by a send creates a
branch. GET /chat/sessions/{id} still returns every event on the session, across all
branches, and reports the active one only as leafId. To render the active branch, start at the
leaf and follow each event’s metadata.parent_id back to the root. That is the path the model’s
context is built from. Cutting the list off at the leaf works only while the thread has a single
branch: after an edit, the abandoned reply sits between the branch point and the new leaf in seq
order.
OpenAI-compatible surface
Section titled “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 and published manifests as OpenAI model objects, each with a felix block: providerModel, contextWindow, starters (always a list, empty when the manifest declares none), commands ([{name, description, argumentHint}], the prompts a person can run as /name; always a list), outputStyles ([{name, description}]; always a list), defaultOutputStyle (a name, or null) and greeting ({headline, subtitle}, or null) |
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.
Tool-call history round-trips the way an OpenAI SDK sends it: an assistant message’s tool_calls
(function.arguments as a JSON string) and the tool messages that answer them by tool_call_id
reach the model together. Arguments that are not a JSON object are a 400 invalid_request_error
naming the call, tool_calls[0]: arguments are not valid JSON.
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.
Images. message/send takes images as FileParts carrying their bytes. type and mime_type
are accepted as spellings too.
{"kind": "file", "file": {"bytes": "<base64>", "mimeType": "image/png"}}An image-only message is valid. mimeType is optional and read from the bytes when absent. A
FilePart is held to the /files upload rules: png, jpeg, gif or webp by its bytes, at most 600 KiB.
It is stored like an upload, under the tenant’s attachment quota. A FilePart that fails the rules,
or that carries a uri instead of bytes, refuses the message with -32602 rather than being
dropped. The image is then screened like any user turn’s. If the agent’s model route is text-only
and no vision route is set, the message gets -32602 naming the route. A refused message’s task
reads failed on tasks/get.
A peer’s answer is read from its artifacts, or else its status message. Image FileParts in it
reach the calling agent as tool images: checked and capped before screening reads them, then
stored and screened as an untrusted tool’s are.
| Method | Path | Notes |
|---|---|---|
POST |
/mcp |
JSON-RPC: initialize, tools/list, tools/call. params.manifest selects the agent. |
GET |
/mcp |
Human-readable info |
A tools/call runs the manifest’s tool the way the agent’s own loop does: through the governance
stack, and around it the plugin before_tool / after_tool hooks and the manifest’s
pre_tool_use / post_tool_use hooks, with a tool_call (or policy_deny) audit row. A hook
that refuses the call answers it with isError: true and the hook’s message; a tool that raises
is named by its error code, never by its exception’s text.
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.
Images.
- Server side. A tool that returns images (a browser screenshot, an image tool’s result)
answers
tools/callwith animagecontent block for each, as base64. The limits:- at most four images;
- each must be an image by its bytes;
- a stored image is sent only if the same call made it, read back under the caller’s tenant,
since an MCP client cannot resolve a
felix-file://reference; - a URL is never passed on.
- Client side. Felix reads a remote server’s
imagecontent blocks as tool images. They are stored and capped, and screened as an untrusted tool’s are, which means they are quarantined whencontent_screening.image_modelis unset.
POST /triggers/{id}
Section titled “POST /triggers/{id}”Starts a durable run from a signed event, for a trigger the operator registered in
FELIX_TRIGGERS (setup). No Authorization header: the
sender signs the body with the trigger’s secret, as Standard Webhooks or GitHub specify. The body
must be UTF-8 JSON of at most 256 KiB.
| Status | Body | When |
|---|---|---|
202 |
{"status":"accepted","run_id"} |
The run is queued. Watch it with GET /chat/runs/{run_id}, or let the manifest’s spec.execution.webhooks announce its end. |
202 |
the same, with idempotent-replayed: true |
A delivery already accepted. For Standard Webhooks the same webhook-id; for GitHub the same signed body, whatever X-GitHub-Delivery says. |
200 |
{"status":"ignored"} |
The event type is not one the trigger runs for (not in events, or GitHub’s ping). Remembered like an accepted delivery. |
401 |
invalid_signature |
No trigger has this id, or the signature is missing, stale or wrong, or the delivery id is malformed. One answer for all of them, so ids cannot be probed; the reason is in the server log. |
409 |
delivery_in_progress / delivery_id_reused |
The same delivery is being handled now, or a webhook-id came back with a different body. |
413 |
payload_too_large |
|
422 |
invalid_payload |
Not UTF-8 JSON. |
429 |
rate_limited |
The trigger’s per-minute limit is spent. Not remembered: a retry may succeed. |
503 |
trigger_unavailable |
The trigger cannot start its run: its secret did not resolve or is too short, its manifest is missing, refuses the trigger’s principal, or does not list trigger_event. |
The run’s user turn is the trigger’s prompt. The payload arrives only when the model calls
trigger_event, which returns the event type, delivery id and the body as sent, marked as
third-party data.
Well-known
Section titled “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
Section titled “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
Section titled “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.