Admin API¶
Everything the UI does goes through this JSON API, so it can be automated. Authenticate with Authorization: Bearer <token> (a user token from Users) or the UI's session cookie. Cross-origin browser writes are refused. Errors are application/problem+json with the failing field and reason.
Draft writes and publish use optimistic concurrency: send the ETag from the last read as If-Match. A stale value gets 412, and a missing one 428.
Endpoints¶
| Method and path | Permission | Does |
|---|---|---|
POST /api/auth/login |
public | Session sign-in |
POST /api/auth/logout, GET /api/auth/me, POST /api/auth/password |
signed in | Sign out, current user (with log_search_url, the deployment's LOG_SEARCH_URL, when it is set), change password |
GET /api/roles |
signed in | Roles and their permissions |
GET /api/draft |
config.read |
Read the draft: {config, tests}, people's rules only, plus overlay, the active version's autopilot rules that have not expired, and adopted, the ones adopted since the last publish |
PUT /api/draft |
config.write |
Replace the draft; a save validates everything |
POST /api/draft/check |
config.read |
Validate a {config} as a save would, without saving: returns {errors, warnings}, each with its path |
POST /api/draft/adopt |
config.write |
Copy autopilot rule id into the draft without its ap- prefix and expiry. 404 when the overlay has no such rule, 409 when the draft already has a rule with that id in any phase, since rule ids are unique across phases |
GET /api/draft/rebase |
config.read |
Preview a rebase onto the active version |
POST /api/draft/rebase |
config.write |
Apply it |
GET /api/expr/schema, POST /api/expr/parse, POST /api/expr/render, POST /api/expr/validate |
config.read |
Fields and operators, and the values rule settings and config sections take (such as algorithms, content types, JWT algorithms and HTTP methods); convert between text and tree; validate |
POST /api/tests/run, POST /api/simulate |
config.test |
Run the draft's tests; simulate one request. A mistyped client IP is a 422 with an error at request.ip |
POST /api/publish |
config.publish (plus canary.manage with canary_percent) |
Publish the draft. Body: message, optional canary_percent, override_reason, guard_override with guard_override_reason |
GET /api/versions, GET /api/versions/{v} |
config.read |
List and read versions |
GET /api/diff/{a}/{b}, GET /api/rules/{id}/history |
config.read |
Compare versions; one rule's history |
POST /api/versions/{v}/revert |
config.rollback |
Publish version v's own rules, with the current autopilot rules, as a new version. Body: reason (required), optional override_reason, canary_percent (needs canary.manage), guard_override with guard_override_reason |
POST /api/versions/{v}/copy-to-draft |
config.write |
Start the draft from version v |
GET /api/canary |
config.read |
Canary state |
POST /api/canary/step, POST /api/canary/promote, POST /api/canary/abort |
canary.manage |
Step (percent), promote, abort (reason) |
GET /api/export |
config.read |
Export the active version, autopilot rules included, as {envelope, tests} (envelope is an edge bootstrap file) |
POST /api/import |
config.publish |
Publish {config, tests, message, override_reason} as a new version. ap- rate-limit rules in config are ignored and this admin's autopilot rules are kept, less those expired. 422 when a config rule or request header rule has an ap- id |
GET /api/settings/guard |
config.read |
Default guard settings |
PUT /api/settings/guard |
settings.manage |
Change them |
GET /api/settings/notifications |
config.read |
Notification settings: defaults per signal and per-rule settings |
PUT /api/settings/notifications |
settings.manage |
Change them |
GET /api/anomalies |
stats.read |
Open anomalies and those resolved in the last 7 days, newest first, with whether ALERTMANAGER_URL is set |
GET /api/fleet, GET /api/stats |
stats.read |
Edges and their versions; per-minute stats for the last minutes, one row per minute and version summed across replicas. With step, a number of minutes that divides a day (such as 5 or 60), each row is a bucket of step minutes from UTC midnight, minute is its start, the range starts at the start of the bucket minutes falls in, so the first bucket is whole, and top_limited keeps, for each rule, the 20 clients with the most limited plus would_limit, summed by rule and key. Rows have status_codes, requests by status code apart by who answered ({"edge": {"502": 3}, "upstream": {"200": 980}}; code 0 is a request that got no answer and 600 any code above 599), and upstream_errors, the edge's upstream failures by cause ({"refused": 3}). Both are empty for minutes reported by edges older than this admin |
GET /api/observed-routes, GET /api/observed-routes/{id} |
stats.read |
Routes built from access logs, with traffic, status mix, latency and grouping suggestions (hours sets the range; limit keeps the busiest routes and more counts the rest); one route with its hourly series, status_codes (its answers by code) and upstream_errors (the edge's upstream failures by cause) |
GET /api/observed-routes/{id}/flow, GET /api/observed-routes/{id}/condition |
config.read |
What a version does to a request like this route's (version: a number or draft; the live version when omitted); an expression that matches the route, to start a new rule |
DELETE /api/observed-routes, DELETE /api/observed-routes/{id} |
routes.manage |
Delete every route, or one. Routes are collected again from new access logs |
GET /api/route-patterns |
stats.read |
Patterns that group similar paths into one route |
POST /api/route-patterns, DELETE /api/route-patterns/{id} |
routes.manage |
Add a pattern (existing routes it covers are merged into it), or remove one |
GET /api/audit |
audit.read |
Read the audit log, newest first, 100 rows per page. actor, action and target match exactly, and action also takes a comma-separated list of up to 50; mentions matches part of the target or details; since takes an RFC 3339 time and leaves out earlier events; before takes a seq to page. A publish, import, revert or guard rollback that changes the kill switch from the live version's has the new switch in details.kill_switch. A revert or a guard rollback of a person's version that published the restored rules without the autopilot rules, because together they were refused, has details.overlay_dropped: true |
POST /api/audit/verify, POST /api/audit/export |
audit.read |
Verify the chain, or export every row with its HMAC as a streamed JSON array. Both are POST because each writes its own audit row |
GET /api/users, POST /api/users, PUT /api/users/{id}/roles, POST /api/users/{id}/disable |
users.manage |
Manage users |
GET /api/tokens, POST /api/tokens, DELETE /api/tokens/{id} |
tokens.manage |
Manage tokens. name and kind (edge or user) are required. User tokens need expires_in (for example "720h", at most 8760h); their scopes must be permissions you hold |
GET /v1/snapshot, POST /v1/edge/report |
edge token | The edge polls its policy (with If-None-Match) and reports its stats |
POST /v1/logs/access |
logs.ingest |
The log shipper posts edge access lines as newline-delimited JSON. Only the token in admin's LOG_INGEST_TOKEN_FILE holds this permission; a 429 means admin is busy and the batch should be sent again |
GET /api/autopilot |
autopilot.read |
Autopilot state: mode, agent_connected, the runners seen in the last day, credentials (whether a connected runner holds a credential for claude and for openai; never the credential itself), warnings (such as a model whose provider no connected runner can run), budgets with what they use, cases_open, the live autopilot rules (each with the host and path_prefix its conditions fix), the active exclusions, spend, spend_days and precision. spend has last_24h and last_7d, each with runs (finished runs started in the window), unpriced_runs (those whose model had no price), configured_usd_micros and provider_usd_micros. spend_days has a row per UTC day, provider and model for the finished runs started in the last 14 UTC days, today included, newest day first: day (YYYY-MM-DD), runs, cases (distinct cases, so a retried case counts once), unpriced_runs, configured_usd_micros and provider_usd_micros. precision has a row per model and prompt_sha256 with correct (expired after enforcing, or adopted) and wrong (rolled back, or retired as a false positive) for enforce rules that ended in the last 7 days |
GET /api/autopilot/contract |
autopilot.read |
What a claim gives the runner, less the case: prompt, prompt_sha256, tools (each name, permission, description, input_schema and evidence, true for a stats.read or connector tool, whose successful runner call answers an evidence_id; only this deployment's tools), tools_sha256 (the hash of the tools without evidence) and output_schema. Replay reads it |
GET /api/settings/autopilot |
autopilot.read |
Autopilot settings |
PUT /api/settings/autopilot |
settings.manage |
Change them. An optional reason, at most 500 characters, goes into the audit row only. 422 when default_ttl or max_ttl is under 4 times (30 seconds plus min_publish_interval), the shortest TTL a rule can be renewed within; when default_ttl is not over observe_for plus min_publish_interval and 30 seconds; or when the protected networks and the active exclusions don't fit in half of an expression. Setting mode to off is the kill switch: in one transaction it retires every autopilot rule, cancels open cases and publishes the active version without autopilot rules. 409 while a canary runs |
GET /api/autopilot/cases, GET /api/autopilot/cases/{id} |
autopilot.read |
The latest 100 cases (rule filters by rule); one case with its runs (with prompt_sha256, tools_sha256, token counts, costs and error_category), proposals (with rule, ttl_seconds, evidence and checks), rule transitions, tool_calls (every MCP call its runs made, by run and seq, each with at and payload; a call's result needs both stats.read and config.read, and a connector call's also autopilot.manage; without them result is left out, hidden is true and hidden_reason names the permissions needed, and a hidden connector call also leaves out its arguments and refusal, since its query is part of what it read) and the rules it made, each with its state, outcome and expression as text |
POST /api/autopilot/cases |
autopilot.manage |
Investigate: open a case on at most one of rule, route_id or anomaly_id, with an optional question, or on a question alone. 422 when it names more than one target, names none and asks nothing, or has a rule over 200 characters or a question over 2,000. 409 while autopilot is off, when no runner was seen in the last 2 minutes, when a case on the same target (or the same question) is open, or when 100 cases are open |
POST /api/autopilot/rules/{id}/enforce |
autopilot.manage |
Approve an observing enforce rule in observe mode; the next tick makes it block. 409 with observed_too_short before observe_for has passed |
POST /api/autopilot/rules/{id}/retire |
autopilot.manage |
End an autopilot rule. Body: reason (required), false_positive. A false positive is not proposed again for max_lifetime |
GET /api/autopilot/exclusions |
autopilot.read |
The exclusions active now, and those revoked or expired in the last 30 days, newest first, at most 200 |
POST /api/autopilot/exclusions |
autopilot.manage |
Exclude a client: subject (an IP, or a CIDR of at least /24 for IPv4 or /48 for IPv6), host, path_prefix (optional, starting with /), reason (1 to 500 characters), ttl (1h to 8760h; left out, it never expires) and rule_id (optional: a proposed or observing sensor, whose host and path prefix fill those left empty, and which is retired). 201 with the exclusion; 422 with the field at fault, or when the exclusions and protected networks would take over half of an expression |
POST /api/autopilot/exclusions/{id}/revoke |
autopilot.manage |
Revoke an exclusion. Body: reason, 1 to 500 characters. 204; 409 when there is no such exclusion or it is revoked already |
GET /api/autopilot/notes |
autopilot.read |
The newest 500 autopilot notes; trust and scope_key filter exactly |
GET /api/autopilot/notes/{id} |
autopilot.read |
One note with its revisions, its last 200 uses, its cited reads as evidence (each with its run event's payload, which needs both stats.read and config.read, and for a connector read also autopilot.manage; without them it is left out, hidden is true and hidden_reason names the permissions needed) and blast_radius: the cases served it, the proposals that cited it and the enforcing_rules of those proposals |
POST /api/autopilot/notes/{id}/retract |
autopilot.manage |
Retract a note. Body: reason, 1 to 500 characters. 204; 409 when it is retracted or retired already, naming its trust as read in the retraction's transaction |
POST /api/autopilot/notes/{id}/reevaluate |
autopilot.manage |
Open an investigate case on each rule still enforcing that cites the note; answers the cases opened. 409 while autopilot is off or no runner was seen in the last 2 minutes, as for POST /api/autopilot/cases, and when no enforcing rule cites it |
GET /api/autopilot/prices |
autopilot.read |
Model prices, in USD per million tokens, newest first |
POST /api/autopilot/prices |
settings.manage |
Add a price: provider (claude or openai), model, input_usd_per_mtok, output_usd_per_mtok, cache_read_usd_per_mtok, cache_write_usd_per_mtok (each required, 0 to 1000) and effective_from. A run is priced at the row in effect when it started |
POST /v1/autopilot/claim |
autopilot.agent |
The runner long-polls for a case, up to wait (default and most 30s), sending runner, version, sdk_version and providers, the providers it holds a credential for. Answers the case, its run, the lease, the model and its provider, the system prompt with prompt_sha256, tools_sha256 and the output schema; 204 with no case, while autopilot is off or when cases_per_hour is spent. It takes the oldest queued case whose model's provider it holds. 409 when cases are queued and it holds the provider of none of them |
POST /v1/autopilot/cases/{id}/heartbeat |
autopilot.agent |
Extend the 10 minute lease of run_id, and update providers. 409 once the lease lapsed |
POST /v1/autopilot/cases/{id}/result |
autopilot.agent |
The run's verdict (attack, benign or unsure), summary, proposals, status, usage, provider_cost_usd, the transcript's size and hash and last_seq. A result after the lease lapsed is kept as lease_lost and answers 409 |
POST /v1/autopilot/runs/{id}/events |
autopilot.agent |
Runner events of a run (seq, kind, at, payload), at most 1 MiB a batch. An event over 64 KiB is kept as its size and hash; a run keeps at most 1,000 events and 4 MiB, past which a batch gets 409 |
POST /v1/mcp |
signed in, with a bearer token only; each tool checks its own permission | The MCP server the runner and Claude Code call. A session cookie gets 401 |
MCP tools¶
POST /v1/mcp is a stateless MCP server: every request is one JSON-RPC call answered as JSON. A user token reaches the tools its scopes allow; the runner's AUTOPILOT_TOKEN_FILE token reaches every tool, and its calls must send X-Autopilot-Run naming a run in progress. tools/list shows only the tools the token can call, and a connector's tools only where the deployment sets that connector. Each token has at most 4 calls in progress; each call has 5 seconds (a connector call 10), a result of at most 64 KiB and a window of at most 20160 minutes (14 days). Values clients wrote, such as paths and hosts, sit inside fields named untrusted. Every call the runner makes is kept as an admin event of its run. A successful call of a stats.read or connector tool by the runner, kept whole, also answers its evidence_id, the event's seq, as a second text content {"evidence_id":N} and as a field of the structured result; a note cites it as evidence.
The six connector tools (prom_query, prom_query_range, grafana_annotations, grafana_dashboards, dd_query_metrics, dd_search_logs) read the deployment's own Prometheus, Grafana and Datadog, set in Admin settings. Only the runner's token calls them, since a connector reaches whatever its credentials do. Each answer names its connector, has every string, keys included, scrubbed of what looks like a credential, and is cut to at most 48 KiB by leaving out trailing items, which more counts. A failed connector request is a tool error.
| Tool | Permission | Returns |
|---|---|---|
get_autopilot_state |
autopilot.read |
What GET /api/autopilot returns, with each rule's expression inside an untrusted field |
list_cases |
autopilot.read |
Past cases and verdicts, on one rule when set. An ablation run's list leaves out ablation cases and the cases its withheld notes were served to |
list_rules |
config.read |
The active version's rate-limit and config rules (not request-header rules), people's and the autopilot's, with expressions as text inside untrusted fields |
describe_fields |
config.read |
Expression fields, rate-limit key kinds and algorithms |
get_stats |
stats.read |
GET /api/stats for minutes, in hourly steps past 360 minutes |
get_rule_stats |
stats.read |
One rate-limit rule's counts per minute (hourly past 360 minutes) and its 50 most-limited subjects |
list_anomalies |
stats.read |
What GET /api/anomalies returns |
list_routes |
stats.read |
What GET /api/observed-routes returns for hours (default 24), busiest first, at most limit (1 to 200, default 50) |
get_route |
stats.read |
What GET /api/observed-routes/{id} returns |
check_subject |
stats.read |
Whether an ip is in a verified crawler range or a protected subject |
list_versions |
config.read |
Recent versions with source, author and change summary |
get_case |
autopilot.agent |
The run's case: trigger, evidence and prior cases on the same rule |
validate_rule |
autopilot.agent |
The policy checks and the compile of a rule with the live rules, and the notes it cites, without proposing it. Its rule, as in simulate_rule and propose, takes expression (text), algorithm, limit, period, key and on_missing_key, and optionally burst, penalty, distinct_of, count_statuses (upstream statuses or classes from 1xx to 4xx to count instead of requests, as in Counting responses) and fail_mode |
simulate_rule |
autopilot.agent |
Which rules, the candidate included, match up to 20 test requests |
propose |
autopilot.agent |
Propose add_sensor, enforce or retire (of a proposed or observing sensor only), with up to 5 notes that search_notes served the run; refused in an ablation run; answers proposal_id, accepted, the new rule_id, and reasons with each check |
record_learning |
autopilot.agent |
Record a note through the gate; answers note_id, trust, anchor and the stats admin computed, or the reason it refused |
search_notes |
autopilot.read |
Notes, newest first, at most 20, by scope and kind. For a run it checks the 20 newest unconfirmed and confirmed notes, less those an ablation withholds, and serves those whose claim matches now and hypotheses, each check recorded; notes past the answer's 56 KiB cap are neither served nor recorded, and a narrower scope or kind reaches them; a person's token gets every note not retracted or retired, with matches_now, and records nothing |
prom_query |
autopilot.agent |
A PromQL instant query (1 to 2,000 characters) on PROMETHEUS_URL: result_type, up to 50 series of {labels, value} with the labels inside untrusted, more (series left out) and link from METRICS_LINK_URL, or null. A scalar or string result answers value instead of series |
prom_query_range |
autopilot.agent |
A PromQL query over the last minutes (default 60), at a step of 60 seconds or minutes seconds, whichever is larger: from, to, step_seconds, up to 10 series of {labels, points} with at most 60 points each, more and link |
grafana_annotations |
autopilot.agent |
Annotations on GRAFANA_URL over the last minutes (default 1,440), with every one of up to 5 tags, at most limit (1 to 100, default 100): id, time, time_end, text and tags inside untrusted, dashboard_uid and a link to its dashboard |
grafana_dashboards |
autopilot.agent |
Dashboards whose title matches query (up to 200 characters), at most limit (1 to 20, default 20): uid, title inside untrusted, url on GRAFANA_URL and tags |
dd_query_metrics |
autopilot.agent |
A Datadog metrics query (1 to 2,000 characters) over the last minutes (default 60): from, to, up to 10 series of {metric, scope, points}, the scope inside untrusted, every k-th point so at most 60 remain, and more. link is null: Datadog documents no URL form for a query |
dd_search_logs |
autopilot.agent |
Datadog logs matching query over the last minutes (default 15), newest first, at most limit (1 to 50, default 50): lines inside untrusted, each timestamp, service, host, status and message cut to 500 characters, and more when there were more. link is null |
Example: publish from CI¶
API=https://admin.internal
AUTH="Authorization: Bearer $EDGE_POLICY_TOKEN" # a user token scoped to config.read, config.write and config.publish
etag=$(curl -sfI -H "$AUTH" $API/api/draft | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -sf -X PUT -H "$AUTH" -H 'Content-Type: application/json' -H "If-Match: $etag" --data @policy.json $API/api/draft
etag=$(curl -sfI -H "$AUTH" $API/api/draft | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' -H "If-Match: $etag" \
-d '{"message":"deploy 1234"}' $API/api/publish
policy.json holds {"config": ..., "tests": [...]}, as returned by GET /api/draft.