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.
Scopes
Section titled “Scopes”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:
adminor*bypasses every check.x:writesatisfiesx: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:8080export 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.
GET /audit
Section titled “GET /audit”| 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" | jqSeven 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.
GET /audit/export
Section titled “GET /audit/export”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.jsonlThe 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.
GET /audit/metrics
Section titled “GET /audit/metrics”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.
Approvals
Section titled “Approvals”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.
GET /approvals
Section titled “GET /approvals”| 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" | jqcurl -s "$BASE_URL/approvals?thread_id=acme:demo" -H "authorization: Bearer $FELIX_KEY" | jqGET /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.
POST /approvals/{approval_id}/decide
Section titled “POST /approvals/{approval_id}/decide”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.
GET /push/vapid-public-key
Section titled “GET /push/vapid-public-key”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.
POST /push/subscriptions
Section titled “POST /push/subscriptions”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.
DELETE /push/subscriptions
Section titled “DELETE /push/subscriptions”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.
Memory
Section titled “Memory”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.
Documents
Section titled “Documents”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.
Syncing a docs site
Section titled “Syncing a docs site”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’stitle=/label=, is kept..mdpages 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.
--prunedeletes documents under--site-urlthat 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-urlmust 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-prunedocuments (default 10) would go. A--site-urlwider than the pages you synced looks exactly like that.
--dry-runlists what would change. With--pruneit still reads the listing to work out what it would remove.- Credentials:
--api-key(orFELIX_API_KEY) needsdocuments:write. Without one, the tokenfelix login --savekept 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.
Artifacts
Section titled “Artifacts”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 |
Skills
Section titled “Skills”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.
Skill library
Section titled “Skill library”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.
Addressing and paging
Section titled “Addressing and paging”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.
Reading a skill
Section titled “Reading a skill”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.
Saving a version
Section titled “Saving a version”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"}' | jqPOST /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).
Publish, roll back, reject, archive
Section titled “Publish, roll back, reject, archive”| 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.
Importing from GitHub
Section titled “Importing from GitHub”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.
Feedback
Section titled “Feedback”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.
Evaluations
Section titled “Evaluations”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.
Publish policy
Section titled “Publish policy”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.
Personal libraries
Section titled “Personal libraries”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.
Promoting a skill
Section titled “Promoting a skill”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"}' | jqThe 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.
Errors
Section titled “Errors”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.
Run a job now
Section titled “Run a job now”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 that runs a workflow
Section titled “A job that runs a workflow”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.
Workflows
Section titled “Workflows”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_turnruns one manifest on a prompt as a child durable run on its own thread. Each{{ name }}in the prompt is a required input; with anoutput_schemaeach top-level field is an output, else the output istext.tool_callcalls one tool from itsmanifest’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 thatargsdoes not fix;GET /workflows/-/tools?manifest=lists what a step can name, compiled as you, with each tool’s schema and ports.approvalwaits on an ordinary/approvalsrow;approvedanddeniedare control outputs.branchroutes a boolean totrue/false, or a string to one of itscases(elsedefault).
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.
Manifests
Section titled “Manifests”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:
- Tenant Postgres — the active pointer for this tenant (
source: tenant_postgres) - Tenant object store —
manifests/{tenant_id}/{name}.json(source: tenant_object) - Global object store —
manifests/{name}.json(source: global_object) - 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.
curl -s "$BASE_URL/manifests" -H "authorization: Bearer $FELIX_KEY" | jqcurl -s "$BASE_URL/manifests/support?version=3" -H "authorization: Bearer $FELIX_KEY" | jqGET /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.
PUT /manifests/{name}
Section titled “PUT /manifests/{name}”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" }Canary and rollback
Section titled “Canary and rollback”| 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"}}]}'Rubric keys
Section titled “Rubric keys”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.
The CI gate
Section titled “The CI gate”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 --mockThat 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.
GET /usage
Section titled “GET /usage”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" | jqA 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.
GET /usage/summary
Section titled “GET /usage/summary”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.
GET /usage/threads
Section titled “GET /usage/threads”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.