Skip to content

Management API

Operator surfaces for inspecting and governing a running harness. The conversational endpoints are in the REST API.

Every route here is tenant-scoped — the tenant comes from the authenticated principal, never from the request body, so a caller cannot read or write another tenant’s rows.

Management routes require a scope on the bearer token in addition to authentication.

Scope Grants
audit:read /audit, /audit/export, /audit/metrics
approvals:read / approvals:write read requests / decide them; approvals:read also covers every /push route
plans:read / plans:write read plans / upsert and delete
jobs:read / jobs:write read jobs and runs / upsert, delete and run now
manifests:read / manifests:write read manifests / publish, canary, rollback
eval:read / eval:write read datasets and runs / write and run them
usage:read /usage
memory:read / memory:write read stored memory / write and forget it
documents:read / documents:write search and list the corpus / ingest and delete
artifacts:read /artifacts/{manifest_id}/{artifact_id}
files:read / files:write read an upload back / upload and delete
skills:read / skills:write /skills/{manifest_name} and every /skill-library read / save, publish, roll back, reject, archive, adopt an import, decide feedback, queue evaluations and set the publish policy
skills:personal writing the caller’s own personal library under /skill-library/~me, and promoting from it; implied by nothing but admin / *

Three rules make this practical:

  • admin or * bypasses every check.
  • x:write satisfies x:read — you do not need to grant both.
  • When FELIX_AUTH_MODE=none, checks are skipped entirely, so local development works without minting tokens.

A caller missing a scope gets 403 {"detail":"missing scopes: manifests:write"}. An unauthenticated caller gets 401.

export BASE_URL=http://localhost:8080
export FELIX_KEY=sk-felix-local-…

Keys carry their scopes in FELIX_AUTH_API_KEYS:

{"sk-demo": {"tenant_id": "acme", "sub": "ops", "scopes": ["audit:read", "manifests:write"]}}

Every governed action writes an audit row. Audit events are buffered in the process that emits them and flushed to Postgres on a timer (FELIX_AUDIT_FLUSH_SECONDS, default 5 s) plus a drain on shutdown. The agent loop runs in the API process, so the API flushes too — not only the worker.

audit:read
Query Type Default
limit int 1–500 50
cursor string —
event_type string —
status string —
manifest_id string —
since int (epoch ms, inclusive) —
until int (epoch ms, exclusive) —
curl -s "$BASE_URL/audit?limit=20&status=denied" \
-H "authorization: Bearer $FELIX_KEY" | jq

Seven event types come from a turn. Filter on these — event_type is matched literally, so a name that is not in this table, or among the skill events below it, returns an empty page rather than an error:

event_type Emitted by status
user_input the agent loop, once per caller turn ok
tool_call the agent loop, per tool invocation that was not denied ok, error
policy_deny the agent loop, per tool invocation a governance wrapper or a plugin’s before_tool hook denied denied
final_response the agent loop, once per completed turn ok, error
inbound_screening inbound screening, before the turn reaches the model denied, quarantined, redacted, oversize, unavailable
guardrails_reply the reply path, when PII was found in the answer blocked, redacted
judge_deny the reply path, when a final_response judge scored under threshold denied

The skill tools and the skill library write skill_activation, skill_draft_saved, skill_published, skill_rolled_back, skill_rejected, skill_archived, skill_adopted, skill_promoted, personal_library_accessed, skill_feedback_submitted, skill_feedback_accepted, skill_feedback_rejected, skill_feedback_applied, skill_feedback_failed, skill_eval_queued, skill_eval_finished, skill_policy_updated and skill_policy_deleted; payloads are in Observability.

Of the seven, the last three are the ones an operator looking only at policy_deny will miss. They are emitted by the governance wrappers on either side of the loop rather than by the loop itself, so they carry no tool — inbound_screening carries surface (turn or tool_arguments), judge_deny carries judge, score and threshold.

policy_deny covers every governance denial that a tool call ran into, not only spec.policies: command and content screening, limits, guardrails, tool-targeted judges and pending approvals all return a deny marker the loop records under this one name. The row’s payload.control names the layer — one of policy, limits, guardrails, approvals, command, screening, or hook when a plugin’s before_tool hook blocked the call, in which case payload.hook names it — so GET /audit?event_type=policy_deny filtered on it answers “every call approvals blocked this week” without correlating counters. The web UI’s Activity page names the layer on the row and can narrow its threads to those with a denial by one layer; a row from a harness that predates the field reads as plain Blocked.

The Prometheus counters — felix_policy_deny, felix_approval_required, felix_content_screening, felix_inbound_screening, described in Observability — still carry the per-rule detail (which policy, which judge) the audit row does not.

Secret values are masked out of payloads before they are written.

audit:read

Every matching event as JSONL (application/x-ndjson), one per line, newest first, in the shape GET /audit lists. The export streams, so there is no row cap and no cursor: it is the whole range, not a page.

Query Type Default
since int (epoch ms, inclusive) —
until int (epoch ms, exclusive) —
event_type string —
status string —
manifest_id string —
curl -s "$BASE_URL/audit/export?since=1782864000000&until=1790812800000" \
-H "authorization: Bearer $FELIX_KEY" -o audit-q3.jsonl

The range is half-open, so consecutive windows ([a, b), then [b, c)) tile a history without repeating a row. An until at or before since is a 400 rather than an empty file, here and on GET /audit. The export reads flushed rows only, so an export whose until is in the past is stable, and one left open-ended can miss events emitted in the last flush interval.

A store failure before the first row is an ordinary error status. One partway through cannot change a status that has already been sent, so the file ends with a line of its own instead — the rows above it are real, and it marks everything after them as missing:

{"error": "export_incomplete", "detail": "…"}

Check the last line before treating an export as complete.

audit:read

Per-tool rollups — call counts, error rates and durations.

Query Type Default
since int (epoch ms) —
limit int 1–2000 200

Rows arrive already aggregated by tool and sorted by calls descending, so clients should render them as-is rather than folding again. avg_latency_ms is a true mean over calls.

{
"tools": [
{ "tool": "local_shell", "calls": 12, "errors": 1, "avg_latency_ms": 84.5 }
],
"window_since": 1787515061000
}

For time-series monitoring prefer Prometheus at GET /metrics; see Observability.

A tool gated by spec.approvals creates a pending request, emits an approval_required stream frame, and blocks until someone decides or the rule’s ttl_seconds expires. It fails closed.

approvals:read
Query Type Default
status string pending
limit int 1–200 50
thread_id string (all threads)
curl -s "$BASE_URL/approvals?status=pending" -H "authorization: Bearer $FELIX_KEY" | jq
curl -s "$BASE_URL/approvals?thread_id=acme:demo" -H "authorization: Bearer $FELIX_KEY" | jq

GET /approvals/{approval_id} returns one request.

thread_id narrows to one conversation, applied before the limit — filtering a returned page yourself would drop whatever limit had already cut off, so a busy tenant would hide the thread you asked about. ?thread_id= (empty) means “approvals with no thread” and is distinct from omitting the parameter.

Why the gate fired, and what it is blocking. Alongside tool_name, args and rule_id, each row carries:

Field Meaning
reason The gate’s own words — an approval rule’s description, or the finding for a command-screening gate. Distinct from decision_note, which is the decider’s note, written when someone answers.
thread_id The conversation that requested it, so a client polling cold can say which run is waiting.
tool_call_id The call it is blocking, so a prompt can be attached to the tool card on screen instead of floating free.
expires_at Epoch ms after which the harness stops waiting and denies. null when the rule set no ttl_seconds — a real state, not a missing value; fall back to your own default.

Read thread_id and tool_call_id as attribution, not ownership. A pending row is reused for a byte-identical call to the same tool in the same manifest, so two threads making that call share one row and one decision, and each field names whichever asked first. Filtering by thread therefore under-reports rather than over-reports — the safe direction, since a caller asking about one conversation never learns about another’s.

reason, tool_call_id and thread_id are "" where the harness genuinely has no answer (a command-screening gate has no rule description; a tool called outside a tool loop has no call id; a gated tool called outside a chat context has no thread) and on any row created before the field existed. Mirror all three as optional in a client that must work against an older harness — reason and tool_call_id arrived in felix-run/felix#245, thread_id in #232.

approvals:write
curl -s -X POST "$BASE_URL/approvals/apr_123/decide" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"decision":"approved","note":"reviewed the diff"}'
Field Type Default Notes
decision approved | denied —
status approved | denied — Accepted as an alias for decision.
note string ""
edited_args object | null null Replaces the tool’s arguments — see below.

edited_args is the third answer, besides yes and no: it approves a modified call, and the tool runs with the arguments you sent rather than the ones the model asked for. Note that it is a standing substitution, not a one-off correction — the grant is matched on a hash of the original arguments, and the reuse path applies your replacement to every identical call until the grant expires or is consumed.

Two rules change who a grant covers:

  • one_shot: true — the grant is consumed once, by the call that waited for the decision or the first identical call that finds it, with a conditional update, so two concurrent identical calls cannot both spend it.
  • bind_principal: true — the grant is bound to the principal that asked, so approving your own call does not silently authorize somebody else’s identical one. That holds while a call waits too: another principal’s identical call is refused rather than joining your request.

A pending row may carry args.preview — text the harness computed for you to read, such as the diff publish_commits is about to push. It is not an argument the tool runs with; leave it in or out of edited_args, it is stripped either way.

A require_approval rule in spec.command_screening creates a real approval too, bounded by command_screening.approval_ttl_seconds (default 300 s) so a run cannot block forever.

A browser subscribes to be woken when a run is waiting on a person: a new approval, or an agent’s question (ui_request). It is the one signal that reaches a device whose page is not running, such as a phone that suspended the web app when its owner switched apps. The web UI does this from the installed app (see Phones and iPads); any client can.

The deployment must opt in. Until FELIX_PUSH_VAPID_PRIVATE_KEY and FELIX_PUSH_VAPID_SUBJECT are set, reading the key and subscribing answer 503 push_not_configured, so a client can tell “this harness does not push” from a failure. Every route needs approvals:read, the scope the approval_required frame needs, so a push tells nobody what the frame would not.

What a push carries is deliberately thin, because it crosses the browser vendor’s push service:

kind Fields
approval approval_id, tool_name, thread_id, expires_at
question thread_id

Never the call’s arguments, the rule’s reason, or the question’s text: those are read from the thread, where the decision is made. thread_id is the full {tenant}:{suffix}. An approval row that create_pending reuses for a byte-identical call pushes nothing, and a push is held by the push service only until the approval’s deadline.

approvals:read
curl -s "$BASE_URL/push/vapid-public-key" -H "authorization: Bearer $FELIX_KEY"
# {"public_key":"BP4z9KsN6nGR…"}

The applicationServerKey to pass to pushManager.subscribe(). It is derived from the private key, so rotating that key invalidates every existing subscription.

approvals:read

The body is PushSubscription.toJSON(), as the browser produces it:

curl -s -X POST "$BASE_URL/push/subscriptions" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"endpoint":"https://web.push.apple.com/QGuQ…","keys":{"p256dh":"BCVx…","auth":"BTBZ…"}}'
# {"id":"9f2c…","created_at":1759370000000}

Idempotent on the endpoint: subscribing again replaces the keys and clears the failure count. The endpoint is a capability and is never echoed back.

Status detail Means
422 push_endpoint_not_allowed Not an https URL on FELIX_PUSH_ALLOWED_HOSTS (Apple, Google, Mozilla and Microsoft by default), or not in the form a browser produces: a port, a username, an uppercase host.
422 push_keys_malformed p256dh is not a P-256 point, or auth is not 16 bytes.
409 push_subscription_limit The tenant already has 200 subscriptions.

A subscription is dropped by the harness when the push service answers 404 or 410, which is how a revoked permission or an uninstalled app is cleaned up, and after five failed sends in a row.

approvals:read
curl -s -X DELETE "$BASE_URL/push/subscriptions" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"endpoint":"https://web.push.apple.com/QGuQ…"}'
# {"removed":true}

Answered whether or not push is configured, so a device can always leave.

The deep pattern records a plan and its step transitions.

Method Path Scope
GET /plans?limit=&thread_id= plans:read
GET /plans/{plan_id} plans:read
PUT /plans/{plan_id} plans:write
DELETE /plans/{plan_id} plans:write

PUT takes {plan, manifest_id, expires_at, expected_updated_at} where plan is the plan document and expires_at is epoch ms. limit is 1–200, default 50. expected_updated_at makes the write conditional on the version the edit was based on — the agent updates step statuses while a run is live — and a plan that moved since is 409 plan_changed, with the current row in detail.

PUT is how a person edits a plan; the agent writes through its plan tools. So a plan written here says so: the harness stores plan.edited = {by, at} (any edited the caller sends is replaced), records a plan_edit audit event, and, for a plan written in a conversation, tells the agent — a workspace_note with kind: "plan" before its next model call if a run is going, a log entry otherwise — naming the plan’s id and asking it to plan_get it. The response adds agent_told: queued, recorded, or null for a plan with no conversation. chat-ui’s run panel edits plans this way and marks an edited plan wherever it draws one.

Each plan names the conversation it was written in: thread_id, as {tenant}:{suffix}, or '' for a plan written outside a chat or before the harness recorded it. ?thread_id= narrows the list to one conversation and takes the thread’s suffix — the same form every chat route takes; the harness adds the tenant itself, so a value naming another tenant answers 400 invalid_thread_id. An empty ?thread_id= lists plans with no thread, which is different from omitting it.

A harness older than the thread_id column ignores the parameter and lists the whole tenant, with no thread_id on any row. A client that wants to say “this thread’s plans” checks that every row it got back names that thread rather than trusting that it asked — chat-ui’s run panel does.

The agent’s plan_get with no plan_id reads the same way: the newest plan on its own conversation, not the tenant’s newest, so one thread cannot pick up a plan another is following.

What the agent has stored across sessions, and the one surface that lets an operator see and change it without a database console.

Method Path Scope
GET /memory?limit=&thread_id=&status= memory:read
GET /memory/search?q=&limit= memory:read
GET /memory/as-of/{turn_seq}?thread_id= memory:read
POST /memory memory:write
DELETE /memory/{memory_id} memory:write
POST /memory/{memory_id}/restore memory:write

Every row says where it came from: thread_id ({tenant}:{suffix}, or '' for a memory written outside any conversation), origin_seq (the turn of that thread that wrote it), manifest_id, created_at and last_used_at. search hits carry the same fields, plus the agent’s own hybrid ranking and which retriever found each hit, so a surprising recall answers both “why that” and “which conversation taught it this”.

as-of is read-only and includes superseded rows: what was believed at a past turn, not what is believed now. A turn number is an ordinal into one thread’s log, so pass thread_id (the suffix, as every chat route takes it) to ask about one conversation; without it, as-of/4 collects whatever every conversation wrote at its own fourth turn. GET /memory takes the same thread_id, and an empty one means memories written outside any thread.

POST takes {content, kind, manifest_id, topic_key, importance}. manifest_id decides who recalls it: an agent recalls only memories stored under its own manifest id, so a memory stored with an empty one is recalled by no agent. content is up to 4000 chars, topic_key up to 200, importance in 0–1. A POST with a topic_key retires every other active memory under that key: this route is the only writer that can. The agent’s own writes — capture and the remember tool — are stored beside what already holds a key, so a topic can carry several active values; the model is shown the current one, and this is where you settle the rest.

DELETE is soft. The row becomes forgotten and drops out of recall rather than being erased, which is why the UI calls it “forget”. GET /memory?status=forgotten lists forgotten memories, most recently forgotten first, and POST /memory/{memory_id}/restore brings one back. Restore answers 409 not_forgotten for a memory that is active or superseded (a superseded memory has a place in turn time and is not restorable), and 403 restore_refused when the caller ranks below whoever forgot it, so an agent cannot undo an operator’s forget. These three arrived with harness felix-run/felix#548.

An operator-owned corpus, separate from Memory: Memory holds facts the agent wrote, this holds text you ingested. Different lifecycle, different trust, different write path.

Method Path Scope
GET /documents?limit= documents:read
GET /documents/search?q=&limit= documents:read
POST /documents documents:write
DELETE /documents/{doc_id} documents:write

POST takes {title, source, text, metadata, max_chars, overlap_chars}. Ingest chunks the text, embeds it when an embedder is configured, and stores the chunks. It is idempotent on (source, title) — re-ingesting the same pair replaces the document rather than duplicating it, so re-syncing a corpus on a schedule is safe. Replacement is delete-then-insert in one transaction, so a document that got shorter does not keep its old tail.

search is hybrid — a full-text channel and a vector channel fused with the same ranking Memory uses — and every hit reports the channels that surfaced it. ["lexical"] alone means the vector channel did not run — usually FELIX_MEMORY_EMBEDDER=none, or the default auto on an install without felix-harness[embeddings] (the default image is built without it). That is the difference between “the corpus disagreed with you” and “half the retrieval stack is switched off”.

Documents reuse FELIX_MEMORY_EMBEDDER rather than taking a second setting: one embedder per deployment, one vector dimension. With none configured the corpus is still fully searchable by full text.

felix ingest-docs loads a directory of Markdown/MDX pages, one document per page. Each page’s source is its public URL, so an agent can quote a hit and then read the whole page:

FELIX_API_KEY=... felix ingest-docs path/to/docs/src/content \
--site-url https://docs.example.com --url https://felix.example.com --prune
  • MDX is reduced to what a reader sees. Frontmatter supplies the title, description and an optional slug:. Import/export statements, component tags and ::: fences are dropped; a directive’s label, or a tag’s title=/label=, is kept. .md pages are plain Markdown, so only the ::: fences go. Code blocks are kept verbatim.
  • Skipped: the site’s 404 page, hidden or _-prefixed paths, and a second file that lands on the same page as another (reported, not overwritten).
  • It is safe to repeat. A page sent again replaces itself.
  • --prune deletes documents under --site-url that no page produced any more. That covers deleted, moved and retitled pages. Anything outside the URL is never touched. Because it deletes, it is fenced:
    • --site-url must be an http(s) URL with a host;
    • prune is skipped when any page failed, or when the corpus listing is full (500) and might be hiding documents;
    • it deletes nothing when more than --max-prune documents (default 10) would go. A --site-url wider than the pages you synced looks exactly like that.
  • --dry-run lists what would change. With --prune it still reads the listing to work out what it would remove.
  • Credentials: --api-key (or FELIX_API_KEY) needs documents:write. Without one, the token felix login --save kept for that server is used.

The bundled support agent pairs search_docs with fetch_docs, confined to https://docs.felix.run/. Syncing this site with --site-url https://docs.felix.run gives it a corpus whose sources it can fetch.

A manifest with artifacts.enabled spills any tool result longer than its threshold to the object store and replaces it in the transcript with a preview and a reference:

…[artifact:5f2c… key=artifacts/acme/quick/5f2c….txt chars=41302 spilled_at=1756000000]
Method Path Scope
GET /artifacts/{manifest_id}/{artifact_id} artifacts:read

Returns {artifact_id, manifest_id, chars, content}. Spill is kept for FELIX_ARTIFACT_RETENTION_DAYS (30 by default) and then swept, after which this answers 404.

This is the client’s way back. The model’s is the read_artifact tool, which pages through the same object but only for the conversation that spilled it — see spec.artifacts.

The tenant is in the key and is not a parameter. It comes from your own credentials, so no spelling of a reference reaches another tenant’s data — while the manifest, which is needed to locate the object, is validated rather than trusted. A reference that is not well-formed is reported as 404, not as malformed: which references are valid is not a caller’s business.

The web UI reads these markers off tool output and offers the rest of the result behind a Show full output control on the tool card, fetched on request rather than on render.

Upload an image once and reference it by id on later turns. An image sent inline as a data: URL lands in the session event log, and full_replay re-sends that log every turn; a stored upload leaves only a reference in the log, expanded to bytes immediately before each model call.

Method Path Scope
POST /files files:write
GET /files/{file_id} files:read
DELETE /files/{file_id} files:write

POST takes {data, media_type, filename} — data is the file’s bytes base64-encoded, media_type is one of image/gif, image/jpeg, image/png, image/webp, and filename is optional, up to 255 chars. It returns {file_id, media_type, size_bytes, filename}. The bytes decide, not the label: a body whose leading bytes do not match media_type is refused.

A later message references the upload with an OpenAI-shaped content part:

{"role": "user", "content": [
{"type": "text", "text": "What is in this screenshot?"},
{"type": "file", "file": {"file_id": "5f2c…"}}
]}

A reference that no longer resolves — deleted, expired, or another tenant’s — is dropped with a warning naming the id, not raised, so the thread keeps answering; the model answers without that image.

GET returns {file_id, size_bytes, data}, with data base64-encoded as it was sent — a JSON envelope rather than raw bytes, so a caller-supplied media type is never served as a content type. DELETE returns {file_id, deleted}. It is idempotent: a well-formed id that names nothing still reports deleted: true, and false means the harness could not act (a malformed id, or the object store raised).

The tenant comes from your credentials, never the path, as with Artifacts. A malformed or unknown file_id on GET is 404 not_found.

Status When
400 data is not valid base64 or is empty, media_type is not allowed, the bytes are not that type, or the decoded size exceeds 600 KiB
409 the upload would put the tenant over FELIX_ATTACHMENTS_MAX_BYTES_PER_TENANT (default 256 MiB; 0 disables)
503 no object store is configured

Read-only inspection of the Agent Skills a manifest compiles: which skills the model can reach, which are active, and the instructions activation would hand it. Activation stays a decision the model makes mid-turn; there is no write surface here. Writing the tenant’s skills is the skill library.

Method Path Scope
GET /skills/{manifest_name} skills:read
GET /skills/{manifest_name}/{skill_name} skills:read
GET /skills/{manifest_name}/activations/recent?limit= skills:read

The manifest resolves the way a chat request resolves it — stored revision first, bundled YAML behind it — so the answer is what the next turn will see. An unknown or invalid manifest name, or a skill not in its catalog, is 404 not_found.

The list returns {manifest, declared_only, items, active}. Each item is {name, description, version, active, has_body, model_invocable, declared, source}:

Field Meaning
active switched on for this (tenant, manifest) in the activation store; only ever a skill in the caller’s own catalog, so one caller’s personal skill names never appear for another
has_body the skill has instructions; the body itself is only on the single-skill route
model_invocable false when the skill sets disable_model_invocation — in the catalog, never offered to the model
declared the manifest named it in spec.skills
source bundled (the harness’s skills/ or FELIX_SKILLS_DIR), store (an operator’s object-store upload) or library (the live version of a tenant-library skill)

declared: false is the one to read. Unless the manifest sets spec.skills_declared_only, the catalog is seeded with every bundled skill, every skill in FELIX_SKILLS_DIR and every live skill in the tenant’s library before spec.skills is resolved, so a manifest declaring one skill is offered every skill on the host and in the library. With declared_only: true, only declared skills are in the catalog.

The single-skill route returns {name, description, version, filename, metadata, active, model_invocable, declared, source, body}. body is the instructions activate_skill returns to the model; body and metadata are secret-redacted, and filename is the basename only. A skill named in spec.skills whose body was never found still resolves, with the loader’s placeholder description and an empty body.

activations/recent reads the audit log, newest first — the activation store holds only current state. limit is 1–200, default 50. Each item is {ts, skill, action, thread_id, tool_call_id, status, library}. library is org, or a personal library’s digest (never the person’s identity), so an investigator can tell whose skill ran; it is empty for a host skill. History shows every caller’s activations to skills:read, personal skill names included. action is activate or deactivate, and status is ok or unknown_skill (the model named a skill that does not exist). tool_call_id joins the row to the matching tool_call audit event.

The tenant’s own skills: every skill an agent drafted (create_skill, update_skill), an operator saved, a person imported from GitHub or a person promoted from their own library, each version’s review record, the feedback on it, its evaluations, and the publish policy. /skills/{manifest} answers “what can this manifest’s model reach”; this answers “what is in the library, and what is waiting on a person”. Concepts → Skill has the model.

Reads need skills:read; anything that changes the library needs skills:write, which implies the read. The principal is recorded as the author of a save, as decided_by on a decision, and on every audit event. Every state change goes through the same library service an agent’s tools use, so an operator’s publish passes the same gate as an agent’s, and a failing security scan blocks whoever asks.

Method Path Scope Success
GET /skill-library?status=&source=&limit=&cursor= skills:read 200
GET /skill-library/-/review?limit=&cursor= skills:read 200
GET /skill-library/-/feedback?status=&limit=&cursor= skills:read 200
GET /skill-library/-/policy skills:read 200
GET /skill-library/-/browse?source=&ref= skills:read 200
GET /skill-library/-/upstream?refresh=&limit=&cursor= skills:read 200
GET /skill-library/{name}/-/upstream?ref= skills:read; skills:write with ref 200
GET /skill-library/{name} skills:read 200
GET /skill-library/{name}/versions/{version} skills:read 200
GET /skill-library/{name}/versions/{version}/files/{path} skills:read 200
GET /skill-library/{name}/versions/{version}/preview skills:read 200
GET /skill-library/{name}/feedback?status=&limit=&cursor= skills:read 200
GET /skill-library/{name}/evals?version=&limit=&cursor= skills:read 200
GET /skill-library/{name}/evals/{eval_id} skills:read 200
POST /skill-library skills:write 201
PUT /skill-library/{name}/versions skills:write 201
POST /skill-library/-/import skills:write 201, or 200 unchanged
POST /skill-library/{name}/-/update skills:write 201, or 200 unchanged
POST /skill-library/{name}/versions/{version}/publish skills:write 200
POST /skill-library/{name}/versions/{version}/rollback skills:write 200
POST /skill-library/{name}/versions/{version}/reject skills:write 200
POST /skill-library/{name}/versions/{version}/adopt skills:write 201
DELETE /skill-library/{name} skills:write 200
POST /skill-library/{name}/versions/{version}/eval skills:write 202
POST /skill-library/{name}/feedback skills:write 201
POST /skill-library/-/feedback/{feedback_id}/accept skills:write 200
POST /skill-library/-/feedback/{feedback_id}/reject skills:write 200
PATCH /skill-library/-/policy skills:write 200
DELETE /skill-library/-/policy skills:write 200
GET /skill-library/-/personal admin 200

Each person’s own library sits beside the tenant’s under /skill-library/~me; see Personal libraries.

Collection-wide routes sit under /-/. - cannot be a skill name, so every skill stays addressable as /{name} and no name is reserved. A name is 1–64 lowercase letters, digits and single hyphens; a version is major.minor.patch. Anything else is 404 not_found before a store is read.

Every listing returns {items, next_cursor}. Pass next_cursor back as cursor; it is null on the last page. limit is 1–100, default 50.

Listing Order Filters
GET /skill-library by name status: live (has a live version), draft (has drafts awaiting review), archived (neither); source: agent, operator, import or promoted, of the newest version
GET /-/review oldest draft first, across skills —
GET /-/feedback oldest first status, default pending
GET /{name}/feedback newest first status
GET /{name}/evals newest first version

The library list applies its filters to a page after reading it, so a filtered page can be short — even empty — and still have a next one; follow next_cursor until it is null. Each item is {name, live_version, latest, pending_drafts, shadows_operator_upload, created_by, created_at, updated_at}, where latest is the newest version’s {version, status, source, quality_score, security_status, created_at}. A review-queue item is a version record plus the skill’s live_version, to diff against. A cursor the route did not issue is 422 invalid_cursor.

GET /{name} returns the skill and its versions, newest first, metadata only. A version record is {name, version, parent_version, status, source, author, origin_manifest_id, session_id, reason, description, quality_score, security_status, decided_by, decision_note, created_at, decided_at, published_at, origin_source, origin_ref, origin_commit, origin_tree_hash, origin_license, origin_committed_at, lineage_import, adopted_from, promoted_from}. status is draft, published (the live one) or archived (superseded, rejected, or taken out by an archive); source is agent, operator, import or promoted. The origin_* fields name where an import came from and are null on every other version; lineage_import marks an import and every version built on one, which the gate judges as an import (see Skill import); adopted_from names the import-lineage version an operator adopted into this one, and promoted_from the version of the promoter’s (author’s) personal library a promoted draft was copied from; both are null on every other version. The review queue’s items carry them all. GET /{name}/versions/{version} adds review_checks, security_issues, files ({path, sha256, size}) and shadows_operator_upload.

shadows_operator_upload: true means an operator upload exists under the same name at a key a spec.skills ref reads, so the name is split: an unpinned ref gets the library’s live version, a ref pinning the upload’s version gets the upload. The object store has no listing, so only the unversioned keys and the versions in question are probed.

GET …/files/{path} returns {path, encoding, content_type, content, sha256, size} for one file, read back against the digest recorded when it was saved. Text is utf-8 and secret-redacted; a binary asset under assets/ is base64 and unredacted. A path outside the bundle allowlist is 422 invalid_path; bytes that no longer match their digest are 500 version_corrupt.

GET …/preview re-runs validation, the quality review and the security scan on the stored bytes and applies the publish policy in force: {name, version, status, valid, validation_issues, quality_score, review_checks, security_status, security_issues, policy_passes, reasons}. It changes nothing and is not audited. policy_passes says whether the gate would let these bytes through, not whether the version’s state allows a publish.

Every read is redacted by default. Every field of a row except a structural allowlist — ids, names, versions, statuses, sources, authors and principals, paths, digests, model route ids — is secret-redacted on the way out: reasons, descriptions, decision notes, review-check and security-issue messages, feedback bodies and suggested patches, evaluation scenarios, judge reasons and job errors. A field added later is redacted unless someone decides it is structural.

Both saves take {files, reason, publish}. files maps a bundle path to its content (1–200 entries; a binary asset under assets/ is base64), reason is at most 2000 characters, and publish: true also publishes in the same request.

curl -s -X POST "$BASE_URL/skill-library" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"files":{"SKILL.md":"---\nname: invoice-triage\ndescription: Sort incoming invoices by urgency.\n---\n\nRead the due date first.\n"},"reason":"first cut"}' | jq

POST /skill-library creates a skill at 0.1.0 under the SKILL.md’s own name; 409 skill_exists if the library already holds it. PUT /{name}/versions adds a version and also takes:

Field Type Meaning
parent_version major.minor.patch, required The newest version the editor loaded. 409 parent_changed when anything newer was saved since, so two editors cannot silently overwrite each other.
version string An explicit new version; it must be newer than every existing one (409 version_conflict).
bump patch | minor | major Bump the newest version; default patch. Give version or bump, not both.

A save is validated, reviewed and scanned before anything is written; an invalid bundle is 422 invalid_bundle with up to 50 issues ({path, message}). Both return the new version’s record with files, shadows_operator_upload, published, and publish_blocked — the refusal body of the publish asked for, or null. The draft is saved either way. Operator saves are not counted against any manifest’s max_pending; a skill holds at most 200 versions (409 version_cap_reached).

Route From Effect
…/publish a draft Runs the gate; the version becomes live_version and the one it replaces is archived. 422 publish_blocked with reasons.
…/rollback a version that was once live Runs the gate without the evaluation requirement; makes it live again.
…/reject a draft Body {note} (1–2000 characters). Archives the draft without publishing it.
DELETE /{name} any skill Clears live_version, so the skill leaves every catalog. Versions and bytes are kept, and a rollback brings one back. Returns {name, live_version}.

A version in the wrong state for the move is 409 version_conflict. Publish and rollback take an optional {expected_live_version}: the live version the page showed, or null for “nothing was live”. It is checked under the skill row’s lock, and another version being live by then is 409 live_changed. Leave the body out to move whatever is live.

GET /-/browse?source=github:owner/repo[/path]&ref= lists the skills a repository offers at one resolved commit, and POST /-/import with {source, ref} fetches one as a draft with source: import, pinned to that commit. An import is never published in the same request (publish: true is 422 publish_not_allowed); it is reviewed and then published with …/publish like any draft, through a stricter gate. A re-import whose files are unchanged saves nothing and answers 200 with unchanged: true; dropped_files lists what the import left out, and relocated_files what it moved under references/ or assets/. Sources, refs, the allowlist, the cooldown and the call budgets are in Skill import.

An imported skill can be checked against its origin: GET /{name}/-/upstream re-resolves its stored ref (skills:read; naming another with ?ref= needs skills:write) and returns the upstream commit, whether its files moved, when the cooldown lets them in, and a per-file diff against the live version; POST /{name}/-/update re-imports it as a new draft, never published; GET /-/upstream lists every imported skill’s state, 25 a page, checked now or as last recorded (refresh=false). GET /{name} carries the last recorded check as upstream for an imported skill, null otherwise. A check or update of a skill whose newest version was not imported is 409 not_imported. See Skill import → Update checks.

POST /{name}/versions/{version}/adopt with {reason} (1–2000 characters, required) makes an import-lineage version the operator’s own: its files saved byte for byte as a new operator draft with parent_version and adopted_from set to version and lineage_import false, answered 201 with the record a save returns. The versions before it keep their mark, and the adopt never publishes. It is refused for a version with no imported text (409 not_imported), an agent’s draft no person has decided (409 agent_draft), a rejected draft (409 parent_rejected), anything but the skill’s newest non-rejected version (409 parent_changed, naming who wrote the newer one), and a whitespace-only reason (422 reason_required). Audited as skill_adopted. See Skill import → Adopting an import.

POST /{name}/feedback takes {body, suggested_patch, target_version} — body 1–8000 characters, suggested_patch up to 32000, target_version defaulting to the live version, else the newest — and files it as source: human, status: pending. An agent files the same with submit_skill_feedback.

status Means
pending waiting for a person
accepted accepted; with improve, waiting for the worker, otherwise final
applied the worker saved a rewrite as draft result_version, now in the review queue
failed the rewrite failed; error says why (parent_changed if the skill moved past target_version)
rejected final

…/accept takes an optional {improve, note}; improve defaults to true, which queues an AI rewrite as a skill job. …/reject requires {note}. Deciding feedback that was already decided is 409 feedback_conflict. A feedback row also carries model, claimed_at, heartbeat_at and attempts for the worker’s run.

POST …/versions/{version}/eval queues an evaluation and returns it queued with 202. The worker’s skill_jobs sweep runs it within a minute; poll GET /{name}/evals/{eval_id}. A version holds one queued or running evaluation at a time (409 eval_in_progress).

An evaluation is {id, name, version, status, scenario_source, scenarios, baseline_score, with_skill_score, uplift, results, model, judge_model, error, requested_by, created_at, started_at, heartbeat_at, attempts, finished_at, counts_for_gate, gate_note}. status is queued, running, succeeded or failed. Scores are 0–100 from the judge, uplift is with_skill_score − baseline_score, and all three are null until it has succeeded. results holds each scenario’s two scores, uplift and the judge’s reasons.

Scenarios come from the bundle when it has them — evals/scenarios.json (a list) or one evals/<dir>/scenario.json each, every entry {name, prompt, criteria}, at most 10 — else up to 5 are generated by the eval model from the SKILL.md, else three defaults are built around the description (scenario_source: bundle, generated, default). FELIX_SKILL_EVAL_MAX_SCENARIOS caps all three. A version’s first evaluation fixes its scenarios, and every later one reuses them.

counts_for_gate says whether the evaluation can satisfy require_eval and min_eval_uplift, and gate_note why not: only a succeeded one can, and for a version an agent wrote only one on the bundle’s own evals/ scenarios.

GET /-/policy returns the gate in force: {min_quality, block_on_advisory, security_fail_blocks, require_eval, min_eval_uplift, import_min_age_days, source, tenant_values, updated_at, updated_by}. security_fail_blocks is always true.

PATCH /-/policy sets fields of the tenant’s own policy and returns the policy now in force. Only the fields sent change.

Field Type Tightened by the deployment’s
min_quality int 0–100 FELIX_SKILL_PUBLISH_MIN_QUALITY — the higher wins
block_on_advisory bool FELIX_SKILL_PUBLISH_BLOCK_ON_ADVISORY — either blocks
require_eval bool FELIX_SKILL_PUBLISH_REQUIRE_EVAL — either requires
min_eval_uplift int −100–100, or null to remove the tenant’s floor FELIX_SKILL_PUBLISH_MIN_EVAL_UPLIFT — the higher floor wins
import_min_age_days int 0–365 FELIX_SKILL_IMPORT_MIN_AGE_DAYS — the longer cooldown wins

Tighten only. The deployment’s settings are a floor a tenant cannot go under. A value looser than a setting is stored and outvoted, and source reads tenant+settings, with tenant_values showing what the tenant set; tenant means the tenant’s policy is at least as strict everywhere; settings means there is no tenant policy. DELETE /-/policy drops the tenant’s values, so the settings alone decide. min_eval_uplift without require_eval still blocks a version that has no evaluation that counts. Changes are audited as skill_policy_updated and skill_policy_deleted.

A principal with a verified identity has a personal library in its tenant, keyed by issuer and subject; an anonymous caller, FELIX_AUTH_MODE=none, and an API key configured without its own sub have none. Its skills reach only its owner’s turns, and only on a manifest with spec.personal_skills set to read or write. /skill-library/~me mirrors the library routes for the caller’s own library:

Method Path Scope Success
GET /skill-library/~me?status=&source=&limit=&cursor= none 200
GET /skill-library/~me/{name} none 200
GET /skill-library/~me/{name}/versions/{version} none 200
GET /skill-library/~me/{name}/versions/{version}/files/{path} none 200
GET /skill-library/~me/{name}/versions/{version}/preview none 200
POST /skill-library/~me skills:personal 201
PUT /skill-library/~me/{name}/versions skills:personal 201
POST /skill-library/~me/{name}/versions/{version}/publish skills:personal 200
POST /skill-library/~me/{name}/versions/{version}/rollback skills:personal 200
POST /skill-library/~me/{name}/versions/{version}/reject skills:personal 200
POST /skill-library/~me/{name}/versions/{version}/promote skills:personal 201
DELETE /skill-library/~me/{name} skills:personal 200

Bodies, responses and refusals are the tenant library’s. Reading needs no scope; writing needs skills:personal, because a principal can be a credential many people share. A caller with no library gets 403 {"detail":"no_personal_library"}. The owner publishes their own drafts: there is no review queue, but the publish gate runs as for the tenant. Evaluations are kept for the tenant’s library only, so where the deployment or the tenant’s policy requires one, a personal publish is 422 publish_blocked. Feedback, evaluations, imports, adopts, update checks, the review queue and the publish policy are the tenant’s alone; upstream on a personal skill is null.

A personal library holds at most 100 skills in use (live, or with a draft waiting; archiving one or rejecting its drafts frees a place), 500 names ever, and FELIX_SKILL_PERSONAL_MAX_BYTES (default 50 MiB) across every version it keeps. A save past any of them is 409 personal_library_full.

Administrators. GET /-/personal lists the tenant’s personal libraries as {items, truncated}, each item {library, owner, skills}: library is the digest (~ and 32 hex characters), owner is issuer|subject, and skills counts archived ones too. The first 1000 owners are listed; truncated says there are more. An admin or * caller reads or archives one library through /skill-library/~{digest}/… (the digest without its ~): the read routes and DELETE above, and no other write (403 {"detail":"personal_library_read_only"}). A digest that names no library is 404. Both refuse under FELIX_AUTH_MODE=none (403 {"detail":"personal_libraries_need_auth"}), and every look is audited as personal_library_accessed.

POST /skill-library/~me/{name}/versions/{version}/promote proposes a version of your library to the tenant’s. It takes an optional {reason} (up to 2000 characters, shown to the reviewer) and answers 201 with the tenant draft, in the shape a save returns.

curl -s -X POST "$BASE_URL/skill-library/~me/invoice-triage/versions/0.1.0/promote" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"reason":"the team asks for this every week"}' | jq

The version’s files are copied byte for byte, server-side, into a draft of the tenant’s skill of the same name: source: promoted, author the promoter, promoted_from your version. The draft follows the tenant’s newest version that was not rejected, or starts the skill if the tenant has none. It waits in the ordinary review queue (GET /-/review), and a reviewer with skills:write publishes or rejects it. Promotion never publishes, and your own skill is left as it was.

A promoted draft is judged as an agent’s: the copy rule runs on it and it inherits your version’s lineage_import, only an evaluation on its bundle’s own evals/ scenarios counts toward the gate, an agent’s edit of it waits for a person, and it cannot be adopted until someone decides it.

Refusal Status When
version_conflict 409 The version never went live in your library (a draft, or a rejected one); publish it there first
promotion_pending 409 The tenant’s skill already holds an undecided promotion
origin_mismatch 409 The tenant’s newest version of the name carries imported text; a reviewer adopts it first
parent_changed / skill_exists 409 The tenant’s skill moved while the promotion was saved
invalid_bundle 422 The evals/ files are not exactly the tenant parent’s: none added, changed or removed
pending_cap_reached 429 You already hold 20 undecided promotions in the tenant

Every refusal a save can return applies as well (name_shadows_host_skill, version_cap_reached). Only ~me has the route: the tenant’s own path has none, and an administrator’s digest is 422. Audited as skill_promoted, with library naming the personal library it came from.

Every refusal is {error, message}, with issues on invalid_bundle and reasons on publish_blocked.

error Status When
not_found 404 The skill, version, file, feedback or evaluation is not in the library
invalid_bundle 422 The files do not validate
publish_blocked 422 The publish gate refused the version
invalid_cursor 422 cursor is not one this route issued
invalid_path 422 A file path outside the bundle allowlist
reason_required 422 An adopt whose reason is only whitespace
org_only 422 An import, adopt or promotion aimed at a personal library rather than the tenant’s
name_shadows_host_skill 409 The name is a bundled or FELIX_SKILLS_DIR skill, or an operator upload at an unversioned key
skill_exists 409 POST /skill-library for a name the library holds
parent_changed 409 parent_version is not the newest version, or an adopt named anything but the newest non-rejected version
parent_rejected 409 An agent’s edit named a rejected draft as its parent, or an adopt named a rejected draft
version_conflict 409 The version exists, is not newer than every existing one, or is in the wrong state; a promotion of a version that never went live in the caller’s library
live_changed 409 expected_live_version is not the live version
not_imported 409 An upstream check or update of a skill whose newest version was not imported, or an adopt of a version with no imported text
agent_draft 409 An adopt of an agent’s draft no person has decided
adopt_mismatch 409 A save carrying adopted_from whose parent or files are not that version’s
version_cap_reached 409 The skill already holds 200 versions
personal_library_full 409 A personal library is at one of its limits: 100 skills in use, 500 names, or FELIX_SKILL_PERSONAL_MAX_BYTES
promotion_pending 409 A promotion into a tenant skill that already holds an undecided one
origin_mismatch 409 A promotion into a tenant skill whose newest version carries imported text (for imports, see below)
feedback_conflict 409 The feedback was already decided
eval_in_progress 409 The version already has an evaluation queued or running
pending_cap_reached 429 An agent’s manifest holds max_pending undecided drafts, or a promoter holds 20 undecided promotions
feedback_cap_reached 429 An agent’s manifest holds max_pending undecided feedback
skill_jobs_cap_reached 429 The tenant’s skill-job caps; see Deploy
version_corrupt 500 The object store no longer holds the bytes the row recorded

Browse and import add their own codes — invalid_source, ambiguous_ref, commit_not_in_repo, source_too_large, publish_not_allowed (422); source_not_allowed, too_recent (403); source_not_found (404); origin_mismatch, not_imported (409); rate_limited (429); upstream_rate_limited, upstream_error, egress_blocked (502) — each explained in Skill import → Errors.

A 429 drains as drafts, feedback and jobs are decided or run, so a retry later can succeed. A request body that fails schema validation is FastAPI’s 422 {"detail": […]}, not this shape. A missing scope is 403, an unauthenticated caller 401, as everywhere here. The personal-library refusals that come before a library is resolved — no_personal_library, personal_library_read_only, personal_libraries_need_auth — are 403 {"detail": …} too, not this shape.

Scheduled agent runs. The worker’s run_scheduled_jobs cron sweeps due rows every minute — which means felix-scheduler must be running alongside felix-worker, or nothing ever fires.

Method Path Scope
GET /jobs jobs:read
GET /jobs/{name} jobs:read
GET /jobs/{name}/runs jobs:read
PUT /jobs/{name} jobs:write
DELETE /jobs/{name} jobs:write
POST /jobs/{name}/run jobs:write
curl -s -X PUT "$BASE_URL/jobs/nightly-digest" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"schedule":"0 3 * * *","manifest_id":"deep","payload":{"topic":"changelog"},"enabled":true}'
Field Type Default
schedule string — see below "" (manual only)
manifest_id string ""
workflow_id string — a workflow to run instead of a manifest ""
workflow_revision string — the revision you reviewed (412 if it moved) current
payload object {}
enabled bool true

schedule is five-field cron in UTC (minute hour day month weekday), a macro (@hourly, @daily, @weekly, @monthly, @yearly), an interval (every:30s, every:5m, @every 2h), or integer seconds. An empty schedule is manual only: the job never fires on its own and runs only through Run now. (Through Felix 0.13.1 it meant “every 60 seconds”, so a job created to be tried by hand fired every minute on one thread; a row stored with an empty schedule simply stops firing.) To pause a scheduled job, set enabled: false — Run now still works on a disabled job. A schedule outside that grammar is refused with 422. A new scheduled job, with no next_run_at yet, fires at the next sweep whatever its schedule says, and follows the schedule from then on.

payload.prompt (or payload.message) is the user turn the manifest is invoked with; the rest of the payload is the job’s to interpret. Every firing runs on one thread named after the job, so a job that should remember its last run — a weekly digest — does. Set payload.fresh_thread: true for a job that works something different each time, such as the next ticket in a queue: each firing then gets a thread of its own, so one run’s transcript never sits in the next run’s context. Both are fields on the web UI’s job form, and a stored job’s row says which mode it runs in.

POST /jobs/{name}/run fires a job at once and waits for it: the response is the finished run ({job_name, run_id, status, error, started_at, finished_at, result}, with the agent’s reply in result.answer), recorded with the rest but result.trigger: "manual". It runs a disabled job too, and leaves the schedule alone. The web UI’s Jobs page has it as Run now on each row, which opens the job’s run history with that run on top.

Schedules use standard five-field cron, evaluated in UTC — see Deploy. @hourly, @daily, @weekly, @monthly and @yearly work too, as do plain intervals (300 seconds, every:5m). Anything else is refused with 422 and nothing is stored. A job stored before that check existed, with a schedule it cannot read, does not fire: its last_status reads error and last_error says why.

POST /jobs/{name}/run runs the job immediately, without waiting for its schedule, and answers with the finished run — so a new job can be tried before it is left to cron:

curl -s -X POST "$BASE_URL/jobs/nightly-digest/run" -H "authorization: Bearer $FELIX_KEY" | jq
{ "tenant_id": "default", "job_name": "nightly-digest", "run_id": "3f2a…", "status": "ok", "error": "",
"started_at": 1790000000000, "finished_at": 1790000004210,
"result": { "status": "ok", "answer": "…", "trigger": "manual", "requested_by": "ops" } }

It runs exactly as a scheduled firing does: as the principal cron on behalf of the job’s last writer, with the scopes recorded when they wrote it (an API-key writer’s cut to what the key holds now) — not as the caller — on the job’s own thread, with its prompt screened like any user turn. Only the run record differs: trigger is manual (a scheduled run says schedule) and requested_by is the caller’s subject. The schedule is not moved, and a disabled job runs when asked, since the call is explicit. It needs jobs:write, because whoever may rewrite a job’s prompt may already make it run. An unknown job is 404. The call is synchronous and lasts as long as the job’s turn does.

A job may name a workflow_id instead of a manifest_id (never both: 422 manifest_and_workflow). Each firing starts a run of that workflow with payload.inputs as its inputs, as the job’s writer, and the run’s trigger reads job:<name>; the job’s run record carries result.workflow_run_id. Writing one needs workflows:write beside jobs:write, an existing workflow (422 unknown_workflow) and inputs it takes (422 bad_inputs). The job keeps the workflow’s revision — workflow_revision if you send the one you reviewed, else the current one — and runs only that revision: once anyone saves the workflow again, each firing is refused until the job is re-saved, so a later edit never runs on its writer’s scopes unseen.

An authored workflow is a graph of typed steps wired output port to input port, run durably. The web UI’s Workflows page draws it on a canvas, edits it there, and shows each run on the same graph.

Method Path Scope
GET /workflows workflows:read
GET /workflows/{id} workflows:read
PUT /workflows/{id} workflows:write
DELETE /workflows/{id} workflows:write
GET /workflows/-/kinds workflows:read
POST /workflows/-/check workflows:read
GET /workflows/-/tools?manifest= workflows:read
GET /workflows/{id}/runs workflows:read
GET /workflows/{id}/runs/{run_id} workflows:read
GET /workflows/{id}/runs/{run_id}/stream workflows:read
GET /workflows/{id}/runs/{run_id}/steps/{step_id}/thread workflows:read
POST /workflows/{id}/runs workflows:write
POST /workflows/{id}/runs/{run_id}/cancel workflows:write
curl -s -X PUT "$BASE_URL/workflows/triage" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' -H 'if-none-match: *' \
-d '{"name":"Triage issue","inputs":{"issue":{"type":"string"}},
"steps":[{"id":"classify","kind":"agent_turn",
"config":{"manifest":"quick","prompt":"Label this issue: {{ issue }}",
"output_schema":{"type":"object","properties":{"label":{"type":"string","enum":["bug","feature"]}},"required":["label"]}}},
{"id":"route","kind":"branch","config":{"cases":["bug","feature"]}}],
"edges":[{"source":{"step":"trigger","port":"issue"},"target":{"step":"classify","port":"issue"}},
{"source":{"step":"classify","port":"label"},"target":{"step":"route","port":"value"}}]}'

Steps. Kinds are a registry (GET /workflows/-/kinds lists them with each config’s JSON Schema); four are built in:

  • agent_turn runs one manifest on a prompt as a child durable run on its own thread. Each {{ name }} in the prompt is a required input; with an output_schema each top-level field is an output, else the output is text.
  • tool_call calls one tool from its manifest’s governed tool set with no model, every governance wrapper and tool hook applying. Its inputs are the properties of the tool’s input schema that args does not fix; GET /workflows/-/tools?manifest= lists what a step can name, compiled as you, with each tool’s schema and ports.
  • approval waits on an ordinary /approvals row; approved and denied are control outputs.
  • branch routes a boolean to true/false, or a string to one of its cases (else default).

The workflow’s inputs are the outputs of the node trigger. Every step has the control input when. A step runs once everything it reads from has finished, and is skipped when a source was skipped or none of its when ports fired. A failed step fails the run.

Saves are conditional. Every response carries the revision as its ETag. If-Match: "<revision>" saves only over that revision and If-None-Match: * only where none exists; a miss is 412 with the workflow as it stands. A definition with problems saves — each response lists the node ports and the problems (code, message, step, port) that stop it running — and only a run refuses it.

Runs. POST /workflows/{id}/runs with {"inputs": {…}, "revision": "…"} answers 202 with the run; poll GET /workflows/{id}/runs/{run_id} for each step’s status, inputs, outputs, fired ports, error, an agent or tool step’s thread_id and an approval step’s approval_id. GET /workflows/{id}/runs/{run_id}/stream sends the same record as Server-Sent Events, a workflow_run frame each time it changes (its id: the record’s version, so a reconnect with Last-Event-ID resumes past it), then [DONE] once the run is over. GET /workflows/{id}/runs/{run_id}/steps/{step_id}/thread reads that step’s transcript (the snapshot GET /chat/sessions/{id} would answer), or 404 for a step with no thread yet. No chat route can address a step’s thread, so nothing outside the run can write into a step’s history. A run keeps the definition it started on. It runs as the person who started it, with their scopes, each manifest a step names checked and pinned at the start (403 not_permitted otherwise). Refusals: 422 not_runnable (with problems), 422 bad_inputs (missing, unknown), 412 workflow_changed, 409 too_many_runs. A tenant may have 50 runs going and a run 8 agent and tool steps in flight; a step hands on at most 128 KiB and a run’s record holds at most 4 MiB. Finished runs are swept after FELIX_WORKFLOW_RUN_RETENTION_DAYS (30).

A run starts three ways: by hand (trigger: manual), from a job naming the workflow (job:<name>, above), or from a signed trigger (trigger:<id>, see Inbound triggers) whose payload fields are mapped to typed inputs; a run a trigger started lists the sender’s values as untrusted_inputs, and its agent steps read every value they are given inside a fence.

Tenants manage their own manifests in an append-only, version-pinned store. Each write inserts a new version row and flips the active pointer; a rollback is a pointer flip, not a content rewrite.

The request-path resolver walks four layers and returns the first hit:

  1. Tenant Postgres — the active pointer for this tenant (source: tenant_postgres)
  2. Tenant object store — manifests/{tenant_id}/{name}.json (source: tenant_object)
  3. Global object store — manifests/{name}.json (source: global_object)
  4. Bundled — the YAML shipped in the image (source: bundled)

So a tenant manifest named quick shadows the bundled quick for that tenant only. Active pointers are cached for 30 seconds.

manifests:read
curl -s "$BASE_URL/manifests" -H "authorization: Bearer $FELIX_KEY" | jq
curl -s "$BASE_URL/manifests/support?version=3" -H "authorization: Bearer $FELIX_KEY" | jq

GET /manifests/{name} returns {name, version, variant, manifest}, where variant is stable or canary.

GET /manifests/{name}/versions lists the stored versions — what a rollback can go back to — newest first, without their bodies:

curl -s "$BASE_URL/manifests/support/versions?limit=20" -H "authorization: Bearer $FELIX_KEY" | jq
{ "items": [
{ "tenant_id": "default", "name": "support", "version": 8, "created_at": 1790000000000,
"created_by": "ops", "comment": "tighten the refund prompt", "active": false, "canary": true },
{ "tenant_id": "default", "name": "support", "version": 7, "created_at": 1789000000000,
"created_by": "ops", "comment": "", "active": true, "canary": false } ],
"next_before": 7 }

limit is 1–200, default 50. Pass before=<next_before> for the next page; next_before is null on the last one. active and canary mark the versions the pointer currently names. Fetch a body with ?version=N. Under FELIX_MANIFEST_SOURCE=bundled there are no stored versions and the list is empty.

manifests:write

Publish a new version. The body is validated against the full felix/v1 schema before it is stored, and stdio MCP commands are checked against the operator allowlist here too — so a hostile manifest cannot be stored and then executed by the next request that resolves it. A rejected manifest is 400.

curl -s -X PUT "$BASE_URL/manifests/support" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"manifest":{"apiVersion":"felix/v1","kind":"Agent",
"metadata":{"name":"support"},
"spec":{"pattern":"react","model":{"id":"claude-sonnet"}}},
"comment":"tighten the refund prompt"}'
Field Type Default
manifest object required
comment string ""

PUT returns the stored version row — {tenant_id, name, version, manifest, created_at, created_by, comment}.

Every other manifest write returns the active pointer:

{ "tenant_id": "default", "name": "support", "version": 7,
"canary_version": null, "canary_weight": 0,
"updated_at": 1787448845351, "updated_by": "ops" }
manifests:write
Method Path Body
POST /manifests/{name}/canary {canary_version, canary_weight} — weight 0–100
DELETE /manifests/{name}/canary — clears the canary
POST /manifests/{name}/rollback {version, comment} — default comment "rollback". Activates any version, forward or back; this is the only way to flip the active pointer.
curl -s -X POST "$BASE_URL/manifests/support/canary" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"canary_version":7,"canary_weight":10}'

Routing is deterministic, not random: the harness hashes "{tenant}|{thread}|{manifest}|{stable_version}|{canary_version}" with SHA-256 and takes the first four bytes modulo 100, sending the request to the canary when that bucket is below canary_weight. A given thread therefore pins to one side for the life of the rollout, instead of flapping between versions mid-conversation.

Datasets and scored runs, stored in Postgres.

Method Path Scope
GET /eval/datasets eval:read
GET /eval/datasets/{name} eval:read
PUT /eval/datasets/{name} eval:write
POST /eval/datasets/{name}/run eval:write
POST /eval/runs eval:write
POST /eval/runs/compare eval:write
GET /eval/runs?limit=&dataset= eval:read
GET /eval/runs/{run_id} eval:read

POST /eval/runs/compare answers a different question from a single run. Given {dataset_name, baseline: {name, manifest}, candidates: [{name, manifest}], judge_threshold?} it replays the same items against every manifest and returns each one’s pass_rate plus its lift_pp against the baseline — percentage points, positive being better. Every candidate is a full run, so a baseline and two candidates is three passes over the dataset.

A dataset is written whole — name, description and its items — in one PUT:

curl -s -X PUT "$BASE_URL/eval/datasets/refunds" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"description":"Refund policy questions",
"items":[{"item_id":"late-refund",
"user_input":"Can I refund after 40 days?",
"rubric":{"contains":"30 days"}}]}'

An item’s rubric is free-form JSON, and only these keys are read. Trajectory rules are read first and can only reject — an item that names one and an answer rule needs both. Then the scorer picks one answer rule per item, in this order, and stops at the first one present. The web UI’s eval workbench authors exactly these keys, previews the reading an item will get before it is stored, and shows the rule that decided each score:

Key Rule Passes when
tools_called tools_called every named tool was invoked at least once during the run
tools_not_called tools_not_called none of the named tools was invoked
max_tool_calls max_tool_calls the run made at most that many tool calls
max_errors max_errors at most that many tool calls came back as an error or a governance denial
expect, or equals equals the answer equals it, after stripping whitespace
contains contains the answer contains it, case-insensitively
min_chars min_chars the stripped answer is at least that long
(none of the above) nonempty the answer is not blank — any answer passes
mock_answer — the answer --mock scores instead of calling a model
mock_tool_calls, mock_tool_errors — the trajectory --mock scores: a list of tool names and an error count

Judging is separate and only runs when the rubric names llm_judge, judge_criteria or judge_model — or when the request sends use_llm_judge: true. It then reads judge_criteria (falling back to criteria), judge_threshold (default 0.7) and judge_model (default claude-haiku), and its verdict replaces the rule above.

Items are validated on write. PUT returns 422 for an item that would be stored and then score nothing, naming what to change — a prompt under a near-miss key (input, prompt, question, …) rather than user_input, a rubric that is not an object, or a repeated item_id. Every problem in the batch is reported at once:

{"detail": {
"errors": [
"item 'a' (items[0]) has no user_input (found 'input' — the key Felix reads is 'user_input'); it would be stored as an empty prompt"
],
"warnings": [
"item 'a' (items[0]) rubric names no rule (tools_called, tools_not_called, max_tool_calls, max_errors, expect, equals, contains, min_chars) and no judge; it will score as non-empty, which passes any answer that is not blank"
]
}}

Nothing is written when the batch is refused.

A run row’s fail_count is every item that did not pass — a scored rejection and one that raised, counted the same, which is what the CLI’s exit code has always keyed on. error_count is the subset of fail_count that never reached the scorer at all, so a dataset with a bad item reads differently from a manifest that regressed: a fail_count with no error_count behind it is a real scoring loss, and one that is entirely error_count is a dataset or a run problem, not the model’s.

Run one:

curl -s -X POST "$BASE_URL/eval/datasets/refunds/run" \
-H "authorization: Bearer $FELIX_KEY" -H 'content-type: application/json' \
-d '{"candidate_manifest":"support","deterministic_judge":true}'
Field Type Default Notes
dataset_name string | null null Implied by the path on /datasets/{name}/run.
candidate_manifest string required
manifest_version int | null null Pin to a specific version — the only way to eval an inactive one before promoting it.
deterministic_judge bool false Rubric matching only, no model calls.
use_llm_judge bool false Score with a judge model.

POST /eval/runs/compare scores a baseline against candidates in one call.

Run a dataset offline, with no model calls and no database, using the CLI:

uv run felix eval --dataset smoke --manifest quick \
--fixture fixtures/eval/smoke.json --mock

That is the merge-blocking path in the harness repo’s CI. See Testing.

The token meter: what each turn spent, and what a tenant has spent over a window.

usage:read

Per-request token counts and the cost each was priced at.

Query Type Default
limit int 1–200 50
cursor string —
manifest_id string —
thread_id string, the thread’s suffix —
curl -s "$BASE_URL/usage?limit=50&manifest_id=support" \
-H "authorization: Bearer $FELIX_KEY" | jq

A row carries tokens_input, tokens_output, cache_creation, cache_read, cost_usd, the thread_id the call ran on, and two model ids. tokens_input is the uncached part of the prompt only; the whole prompt is tokens_input + cache_read + cache_creation, and cache writes are priced above plain input, so a row read as “input” alone will not explain its cost.

thread_id is the full {tenant}:{suffix} id — the same spelling an audit row’s payload.thread_id uses, so the two records join directly — or "" for a call made with no thread in context (memory consolidation, skill model calls, maintenance) and for every row written before the column existed. The thread_id filter takes the suffix, as /plans does; an empty value asks for the calls outside any thread, and a full other:x id is a 400 invalid_thread_id.

The two model ids: model_id is the logical route the operator configured; wire_model_id is the provider’s own id, and is what the row was priced by. On a plain route they agree; on a custom one they are the interesting thing on the row.

Cost comes from a built-in per-model price table, overridable per manifest with spec.model.price (USD per 1M tokens). It is priced once, at write time, and fixed on the row — a later rate change does not rewrite history.

usage:read

Spend grouped by manifest, model and UTC day, with totals — “what did this tenant spend last month” in one call, instead of paging /usage and adding it up yourself.

Query Type Default
since_ms int, epoch ms, inclusive 30 days ago
until_ms int, epoch ms, exclusive now
manifest_id string —
curl -s "$BASE_URL/usage/summary?manifest_id=support" \
-H "authorization: Bearer $FELIX_KEY" | jq '.totals'

Returns {since_ms, until_ms, items, totals}. The window is half-open, and since_ms >= until_ms is a 422. The same unpriced caveat applies to totals.cost_usd: it sums the column, so it inherits the floor.

usage:read

Spend per thread over a window — “what did that run cost” without paging /usage and grouping it yourself. The web UI’s Activity page reads it to put a cost on each thread beside what happened on it.

Query Type Default
since_ms int, epoch ms, inclusive 30 days ago
until_ms int, epoch ms, exclusive now
limit int 1–200 50
curl -s "$BASE_URL/usage/threads?limit=20" \
-H "authorization: Bearer $FELIX_KEY" | jq '.items[0]'

Returns {since_ms, until_ms, items, totals, truncated}. Each item is one thread — thread_id, calls, the four token columns, cost_usd, and first_ts/last_ts of its calls in the window — sorted by last_ts, newest first. Calls with no thread form one item whose thread_id is "". totals covers every thread in the window, not just the page returned, and truncated says more threads spent than limit let through. A harness that predates the column answers 404: its rows carry no thread to group by.