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
Management routes require a scope on the bearer token in addition to authentication.
| Scope | Grants |
|---|---|
audit:read |
/audit, /audit/metrics |
approvals:read / approvals:write |
read requests / decide them |
plans:read / plans:write |
read plans / upsert and delete |
jobs:read / jobs:write |
read jobs and runs / upsert and delete |
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 |
artifacts:read |
/artifacts/{manifest_id}/{artifact_id} |
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"]}}Audit
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
audit:read
| Query | Type | Default |
|---|---|---|
limit |
int 1–500 | 50 |
cursor |
string | — |
event_type |
string | — |
status |
string | — |
curl -s "$BASE_URL/audit?limit=20&status=denied" \ -H "authorization: Bearer $FELIX_KEY" | jqThe agent loop emits exactly four event types. Filter on these — event_type is matched
literally, so a name that is not in this table 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 denied | denied |
final_response |
the agent loop, once per completed turn | ok, error |
policy_deny covers every governance denial, not only spec.policies: command and content
screening, limits, guardrails, judges and pending approvals all return a deny marker that the loop
records under this one name. To attribute a denial to a layer, correlate with the Prometheus
counters — felix_policy_deny, felix_approval_required, felix_content_screening — described in
Observability.
Secret values are masked out of payloads before they are written.
GET /audit/metrics
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.
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
approvals:read
| Query | Type | Default |
|---|---|---|
status |
string | pending |
limit |
int 1–200 | 50 |
curl -s "$BASE_URL/approvals?status=pending" -H "authorization: Bearer $FELIX_KEY" | jqGET /approvals/{approval_id} returns one request.
POST /approvals/{approval_id}/decide
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 before it runs. |
Two rules change who a grant covers:
one_shot: true— the grant is consumed once, with a conditional update, so two concurrent identical calls cannot both spend it.bind_principal: true— the grant is bound to the approving principal, so approving your own call does not silently authorize somebody else’s identical one.
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.
Plans
The deep pattern records a plan and its step transitions.
| Method | Path | Scope |
|---|---|---|
GET |
/plans?limit= |
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} where plan is the plan document and expires_at is
epoch ms. limit is 1–200, default 50.
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= |
memory:read |
GET |
/memory/search?q=&limit= |
memory:read |
GET |
/memory/as-of/{turn_seq} |
memory:read |
POST |
/memory |
memory:write |
DELETE |
/memory/{memory_id} |
memory:write |
search runs the agent’s own hybrid ranking and reports which retriever found each hit, so it
answers “why did it recall that”. as-of is read-only and includes superseded rows: what was
believed at a past turn, not what is believed now.
POST takes {content, kind, manifest_id, topic_key, importance} — content up to 4000 chars,
topic_key up to 200, importance in 0–1.
Writing here is a prompt-injection ingress by design: whatever you store is text the model will read
back in a later session, quite possibly one nobody is watching. That is what makes it useful — a
correction, a standing instruction — and it is why it is gated on memory:write separately from
reading.
DELETE is soft. The row becomes forgotten and drops out of recall rather than being erased,
which is why the UI calls it “forget”.
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}.
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.
Jobs
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 |
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 (5-field cron) | "" |
manifest_id |
string | "" |
payload |
object | {} |
enabled |
bool | true |
Schedules use standard five-field cron — see Deploy.
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.
Read
manifests:readcurl -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.
PUT /manifests/{name}
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" }The active version on a pointer is version, not active_version. GET /manifests returns these
same pointer rows under both items and manifests.
Canary and rollback
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.
There is no request header that selects a variant. Variant choice is entirely server-side; the
resolved value is reported on GET /manifests/{name}.
Eval
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":[{"user_input":"Can I refund after 40 days?", "rubric":{"criteria":"States the 30-day limit","pass_threshold":0.7}}]}'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
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.
Usage
usage:readPer-request token counts and derived cost.
| Query | Type | Default |
|---|---|---|
limit |
int 1–200 | 50 |
cursor |
string | — |
manifest_id |
string | — |
curl -s "$BASE_URL/usage?limit=50&manifest_id=support" \ -H "authorization: Bearer $FELIX_KEY" | jqCost comes from a built-in per-model price table, overridable per manifest with spec.model.price
(USD per 1M tokens).