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.

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:

  • 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.

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

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
Terminal window
curl -s "$BASE_URL/audit?limit=20&status=denied" \
-H "authorization: Bearer $FELIX_KEY" | jq

The 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
Terminal window
curl -s "$BASE_URL/approvals?status=pending" -H "authorization: Bearer $FELIX_KEY" | jq

GET /approvals/{approval_id} returns one request.

POST /approvals/{approval_id}/decide

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

  1. Tenant Postgres — the active pointer for this tenant (source: tenant_postgres)
  2. Tenant object storemanifests/{tenant_id}/{name}.json (source: tenant_object)
  3. Global object storemanifests/{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.

Read

manifests:read
Terminal window
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.

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.

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

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

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

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

Usage

usage:read

Per-request token counts and derived cost.

Query Type Default
limit int 1–200 50
cursor string
manifest_id string
Terminal window
curl -s "$BASE_URL/usage?limit=50&manifest_id=support" \
-H "authorization: Bearer $FELIX_KEY" | jq

Cost comes from a built-in per-model price table, overridable per manifest with spec.model.price (USD per 1M tokens).