API Reference
Base URL: http://your-aicontrol-host:8001
Interactive docs (Swagger UI): http://your-aicontrol-host:8001/docs
Authentication
AIControl uses two token types:
Machine API tokens — Issued by POST /tokens, the dashboard's Tokens page (/tokens), or scripts/public/issue_token.py. Used by AI agents and automation scripts. Stored as SHA-256 hashes; never recoverable after issuance.
Human JWTs — Issued by POST /auth/login after password authentication. Used by dashboard users and the React frontend. 8-hour expiry. Not stored in the database — validated by signature only.
Pass either type as a Bearer header:
Authorization: Bearer <token>Endpoint access by token type:
| Role | Token type | Can call |
|---|---|---|
agent (agent-scoped) | Machine | POST /mcp/{server_id}/tools/list, POST /mcp/{server_id}/call_tool, GET /reviews/{id} |
admin (machine) | Machine | All management endpoints |
admin (human) | Human JWT | Everything an admin machine token can call, plus dashboard endpoints |
analyst / auditor (human) | Human JWT | Endpoints marked "human JWT (any role)" below |
Where an endpoint says admin, either an admin machine token or a human JWT whose role is admin is accepted. Where it says human JWT, only a dashboard login token works; machine tokens are rejected. Human JWT with role admin means a dashboard login by an admin user.
Setup
GET /setup/status
Returns whether first-run setup is required. Use this to decide whether to redirect to /setup on the frontend.
Auth: none required
Response 200:
{ "setup_required": true }setup_required: false once any user exists in the database.
POST /setup/complete
Create the root administrator account and organization on first run. Returns 409 if any user already exists.
Auth: none required
Request:
{
"org_name": "Acme Corp",
"timezone": "America/New_York",
"full_name": "Alice Smith",
"email": "alice@acme.com",
"password": "securepassword"
}timezone must be a valid IANA timezone string (e.g. "UTC", "Europe/London"). password minimum 8 characters.
Rate limited to 10 attempts per 5 minutes per IP (429).
Response 200:
{
"token": "eyJhbGc...",
"user": { "id": "uuid", "email": "alice@acme.com", "full_name": "Alice Smith", "role": "admin" }
}Auth
POST /auth/login
Authenticate with email and password. Returns a human JWT valid for 8 hours.
Auth: none required
Request:
{
"email": "alice@acme.com",
"password": "securepassword"
}Response 200:
{
"token": "eyJhbGc...",
"user": {
"id": "uuid",
"email": "alice@acme.com",
"full_name": "Alice Smith",
"role": "admin"
},
"first_login": false
}Returns 401 for invalid credentials. Returns 403 for deactivated accounts. Returns 429 after 10 attempts in 5 minutes from one IP or against one account.
POST /auth/magic-link/validate
Validate a magic link token (used in the invite and password reset flows). Does not issue a JWT — call POST /auth/set-password after this to complete the flow.
Auth: none required
Request:
{ "token": "plaintext_invite_token_from_url" }Response 200:
{
"valid": true,
"email": "bob@acme.com",
"full_name": "Bob Jones"
}Returns 401 for expired or invalid tokens.
POST /auth/set-password
Set password (minimum 8 characters) using a valid magic link token. Issues a human JWT on success.
Auth: none required
Request:
{
"token": "plaintext_invite_token_from_url",
"password": "newpassword"
}Response 200:
{
"token": "eyJhbGc...",
"user": { "id": "uuid", "email": "bob@acme.com", "full_name": "Bob Jones", "role": "analyst" }
}MCP Servers
Admin-only CRUD for registering downstream MCP servers with the gateway. No credential field exists anywhere in this API — registration is metadata-only (Decision 9 of the gateway pivot: pure passthrough, no stored secret).
POST /mcp-servers
Register a downstream MCP server.
Auth: admin
Request:
{
"name": "invoicing-mcp",
"base_url": "https://invoicing.internal/mcp",
"approved_tools": ["get_invoice", "create_invoice"]
}base_url must be an absolute http or https URL, otherwise the request is rejected with 422.
approved_tools is optional, defaults to [] (meaning every tool the
downstream server exposes is reachable through this registration, subject to
policy and the agent's own approved-tools list).
Response 201:
{
"id": "3f2a1c8b-...",
"name": "invoicing-mcp",
"base_url": "https://invoicing.internal/mcp",
"status": "pending_review",
"approved_tools": ["get_invoice", "create_invoice"],
"created_at": "2026-09-09T10:00:00Z"
}New registrations always start in status: "pending_review".
GET /mcp-servers
List all registered MCP servers, newest first.
Auth: admin
Response 200: array of server objects (same shape as the POST response).
GET /mcp-servers/
Get one registered server.
Auth: admin
Response: 200, or 404.
PATCH /mcp-servers/
Update a server's status and/or approved_tools. All fields optional.
Auth: admin
Request:
{ "status": "active" }status must be one of pending_review, active, or blocked — any other
value is rejected. Only active servers accept gateway traffic; a
non-active server's gateway calls return 403.
Response: 200 with updated server, or 404.
DELETE /mcp-servers/
Delete a server registration.
Auth: admin
Response: 204, or 404.
MCP Gateway
The core integration point. Agents point their MCP client at these endpoints
instead of the real downstream server. Both require an agent-scoped
token — an admin token, or an agent-role token issued without an agent_id,
is rejected with 403.
POST /mcp//tools/list
Proxies tools/list to the registered downstream server, filtered to the
server registration's approved_tools if non-empty.
Auth: agent-scoped token
Request:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }Response 200: the downstream server's own tools/list response
(optionally filtered), or a JSON-RPC error if the server is unreachable:
{"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "Downstream MCP server unreachable"}}Returns 403 if the server's status isn't active, or 404 if
server_id doesn't match a registered server. If the downstream server answers
with an HTTP error status, the response is a -32001 JSON-RPC error with
data.status_code and data.detail.
POST /mcp//call_tool
The core interception point. Evaluates Cedar policy on the requested tool call, audits the result, and — if allowed — forwards to the real downstream server and scans the response before returning it.
Auth: agent-scoped token
Request:
{
"jsonrpc": "2.0",
"id": 1,
"params": {
"name": "query_credit_bureau",
"arguments": {"applicant_id": "A-10042", "report_type": "full"},
"session_id": "b3e1f9a2-...",
"sequence_number": 3,
"workflow": "loan_application_review",
"input_tokens": 812,
"output_tokens": 143,
"cost_usd": 0.0091
}
}session_id and sequence_number are required — a missing or invalid value
returns a -32602 JSON-RPC error, not an HTTP 422. workflow defaults to
"unassigned". input_tokens/output_tokens/cost_usd are optional and
only matter to token_budget policy conditions scoped to this tool.
Response 200 (allowed, forwarded): the downstream server's own
tools/call response, unchanged.
Response 200 (denied by a server or agent approved-tools gate, Cedar policy, review, or
response scan): a JSON-RPC result with isError: true — never an HTTP
error code. A review result also carries _meta.review_id; poll
GET /reviews/{review_id}:
{
"jsonrpc": "2.0",
"id": 1,
"result": {"content": [{"type": "text", "text": "Denied by policy: policy_matched:block_dangerous_tools"}], "isError": true}
}Response (malformed request): -32602 JSON-RPC error for a missing
name, missing/invalid session_id or sequence_number, or arguments that is
not a JSON object. The request is audited.
Response (downstream unreachable): -32000 JSON-RPC error, same shape as
tools/list. Also returned when the downstream drops the connection.
Response (downstream HTTP error): -32001 JSON-RPC error with the downstream
status in data.status_code. Audited as an allowed call with reason
upstream_http_error:<status>. A JSON-RPC error object returned by the
downstream server is relayed unchanged and audited with decision: error.
Response (downstream invalid response): -32002 JSON-RPC error when the
downstream returns a body that is not valid JSON. Audited.
Response (response scan): with MCP_RESPONSE_SCAN_POLICY=block (default) a flagged
downstream response is returned as isError: true; with log it is delivered
and only recorded in the audit event.
Returns 403 if the server's status isn't active or if the token isn't
agent-scoped, or 404 if server_id doesn't match a registered server. A call
to an unavailable server by a valid agent token is audited.
Agents
GET /agents
List all registered agents with enriched metadata.
Auth: admin
Response 200: array of agent objects including governance_mode, last_active, deny_rate, approved_at, created_at, system_prompt_hash, plus coverage-tracking fields: framework, hook, sdk_version, workflow, coverage_last_seen_at, coverage_state (unknown | governed | installed_not_firing), silent_noop_warnings (array), unresolved_systems (array). These fields are populated by the adapter's own install-time handshake to POST /agents/{agent_id}/coverage (see below) — the handshake distinguishes "agent never ran" from "agent ran but the hook never fired," which traffic alone can't tell apart. The call goes only to the customer's own self-hosted API; nothing leaves their infrastructure.
GET /agents/
Get one agent.
Auth: admin
Response: 200 with agent object (same coverage fields as GET /agents), or 404.
POST /agents//coverage
Install-time handshake — called by the SDK adapter itself, not by a human. Records which framework/hook/SDK version is instrumenting this agent and which workflow it's reporting.
Auth: agent token (must match agent_id, or be unscoped)
Request:
{
"framework": "openai_agents",
"hook": "on_tool_start",
"sdk_version": "0.4.0",
"workflow": "loan_application_review",
"agent_name": "loan-underwriting-agent",
"silent_noop_warnings": []
}An unrecognized agent_id is auto-registered in observe mode rather than rejected — a live, ungoverned agent nobody registered is exactly what this handshake exists to surface, and 404ing would leave it invisible instead.
Response 200: { "agent_id": "...", "coverage_state": "governed" }
POST /agents/register
Zero-friction self-registration: an idempotent get-or-create by name.
Auth: agent or admin token
Request: { "name": "...", "owner": "...", "framework": null, "approved_tools": [] }. Only name is required; owner defaults to sdk-auto-registered.
Response: the agent object; 201 for a new agent, 200 when one with this name already existed. For an existing agent, owner, framework and approved_tools are overwritten with the values sent.
GET /agents//policies
Every active policy whose principal matches this agent (the policies scoped to it plus the ones scoped to every agent).
Auth: admin
Response 200: array of { id, principalType, principalId, actionTool, resourceSystem, effect, condition }, or 404.
POST /agents
Register a new agent.
Auth: admin
Request:
{
"name": "loan-underwriting-agent",
"owner": "platform-team@company.com",
"framework": "langchain",
"model_version": "gpt-4o",
"approved_tools": ["query_credit_bureau", "read_loan_application"],
"system_prompt_hash": "sha256hex",
"governance_mode": "observe",
"metadata": {}
}All fields except name and owner are optional. governance_mode is "observe" (the default —
decisions are recorded exactly as if enforced, but the gateway never actually blocks a call for
this agent, which is useful for rolling out a new policy set risk-free) or "govern" (policy
decisions are enforced). Set "govern" at creation or later with PUT /agents/{agent_id}.
Response 201: agent object with generated id.
PUT /agents/
Update an agent. Accepts any of owner, framework, model_version, system_prompt_hash, approved_tools, status (active or suspended), approved_by and governance_mode ("govern" or "observe").
Auth: admin
Response: 200 with updated agent, or 404. status other than active or suspended returns 422.
DELETE /agents/
Delete an agent record.
Auth: admin
Response: 204, or 404.
DELETE /agents//token
Revoke all active tokens for this agent.
Auth: admin
Response 200: { "revoked": 2 }. Returns 404 if the agent does not exist or has no active token.
PATCH /agents//approved-tools
Replace the agent's approved tools list. The gateway enforces this before policy evaluation runs at all — any tool absent from this list is denied immediately with reason: tool_not_approved_for_agent.
Auth: admin
Request: { "approved_tools": ["tool_a", "tool_b"] }
Pass [] to remove all per-tool restrictions for that agent.
Response 200: { "agent_id": "...", "approved_tools": ["tool_a", "tool_b"] }
Tokens
GET /tokens
List all issued API tokens. The raw token value is never returned.
Auth: admin
Query parameters: active_only (boolean, default false)
Response 200: array of { id, role, description, agent_id, agent_name, revoked, created_at }
POST /tokens
Issue a new API token. role is agent or admin (400 otherwise). If agent_id is provided (404 if no such agent), any prior active token for that agent is revoked automatically. scripts/public/issue_token.py does not revoke prior tokens.
Auth: admin
Request:
{
"role": "agent",
"description": "loan-underwriting-agent prod token",
"agent_id": "7c4d2e8f-..."
}Response 200:
{
"token_id": "a3b1c9d2-...",
"role": "agent",
"description": "loan-underwriting-agent prod token",
"agent_id": "7c4d2e8f-...",
"token": "eyJhbGc..."
}The token value is shown once. Save it immediately.
Policies
GET /policies
List all policies ordered by priority.
Auth: admin
Response 200: array of policy objects. Scope fields carry a camelCase alias: principalType, principalId, actionTool, resourceSystem, alongside effect, cedar_text, condition, library, priority, category.
GET /policies/library
List all library template policies (library=true). These are inactive templates, not active rules.
Auth: admin
Response 200: array of policy objects grouped by category.
GET /policies/
Get one policy.
Auth: admin
Response: 200, or 404.
POST /policies
Create a policy. Compiles to Cedar and takes effect on the very next call_tool request through the gateway — no restart, no bundle push.
Auth: admin
Request:
{
"name": "block_large_disbursements",
"description": "Block loan disbursements above $10,000",
"principal_type": null,
"principal_id": null,
"action_tool": null,
"resource_system": null,
"effect": "deny",
"condition": {
"tool_name_in": ["initiate_transfer", "disburse_loan_funds"],
"numeric_conditions": { "amount": { "gt": 10000 } }
},
"severity": "critical",
"compliance_frameworks": ["SOC2", "OCC"]
}principal_type/principal_id scope the policy to one agent or agent group (null = every agent); action_tool scopes to one tool (null = any tool); resource_system scopes to one business system (null = any system). effect is deny or review — there is no stored allow. See Policies for the full condition-key vocabulary.
A policy with an empty condition must still constrain at least one of principal_id, action_tool, or resource_system, or the request is rejected with 422 — an unconstrained, unconditional policy would deny every call from every agent.
priority is accepted but not read by the engine — see Policies → Precedence.
Response 201: policy object with generated id, including the compiled cedar_text.
PUT /policies/
Update a policy. All fields optional (same shape as POST /policies). Recompiles cedar_text and takes effect on the next call_tool request through the gateway.
Auth: admin
Response: 200 with updated policy, or 404.
GET /policies//activity
How often this policy has fired recently.
Auth: admin
Query parameters: window (default "7d")
Response 200:
{ "window": "7d", "fired": 12, "calls_evaluated": 340 }calls_evaluated matches on the policy's agent + tool scope only — a business system isn't persisted on the audit event itself (it's resolved at gateway-call time), so for a system-scoped policy this count is an upper bound, not exact.
DELETE /policies/
Delete a policy. Takes effect immediately.
Auth: admin
Response: 204, or 404.
POST /policies/activate-baseline
Activate a set of pre-built safety policies from the library. Idempotent — calling again when policies are already active is safe.
Auth: admin
Request:
{ "mode": "standard" }mode is "standard" or "strict".
standard— activates 4 Dangerous Operations policiesstrict— activates those 4 plus 4 Data Protection policies
Response 200:
{
"mode": "standard",
"activated": ["block_shell_execution", "block_file_deletion", "block_cloud_metadata_access", "block_sensitive_file_reads"]
}Returns 422 for invalid mode.
POST /policies/nl-draft
Enterprise feature. Describe a policy in plain English and get back a scoped, compile-checked draft. Never writes to the policies table.
Auth: admin + enterprise license
Request:
{ "description": "Flag any transfer over $10,000 for review", "agent_id": null }Response 200:
{
"draft": {
"principal_type": null,
"principal_id": null,
"action_tool": null,
"resource_system": null,
"effect": "review",
"condition": { "numeric_conditions": { "amount": { "gt": 10000 } } }
},
"sentence": "Flag any call where amount > 10000 for review.",
"status": "ok",
"warnings": []
}If the draft doesn't compile, draft is null, status is "requires_manual_authoring", and warnings explains why — a draft is never handed back looking ready when it isn't.
POST /policies/simulate
Enterprise feature. Replay a draft (from nl-draft, or hand-written) against real historical audit_events. Read-only — writes neither a policy nor an audit event.
Auth: admin + enterprise license
Request:
{
"draft": {
"principal_type": null, "principal_id": null,
"action_tool": null, "resource_system": null,
"effect": "review",
"condition": { "numeric_conditions": { "amount": { "gt": 10000 } } }
},
"window_days": 7
}Response 200:
{
"eligible_events": 340,
"would_deny": 0,
"would_review": 12,
"corpus_start": "2026-05-01T00:00:00Z",
"corpus_note": null,
"matches": [
{ "audit_event_id": "uuid", "tool_name": "initiate_transfer", "decision": "review", "occurred_at": "2026-05-14T09:12:00Z" }
]
}If no events fall in the simulation's eligible corpus, would_deny/would_review are null and corpus_note explains why — a false "0 of 0" is never returned as if it meant "this policy changes nothing." The draft is compile-checked before replay; a draft that doesn't compile returns 422.
Rate-limit and token-budget aggregates are zeroed during simulation — those reflect point-in-time state and can't be honestly replayed against a historical event.
Reviews (HITL)
GET /reviews/
Get the status of a human review.
Auth: any valid token: an agent token, an admin token or a human JWT. An agent token can read any review by id, so treat review ids as unguessable identifiers, not secrets.
Response 200:
{
"id": "f1e2d3c4-...",
"audit_event_id": "3f2a1c8b-...",
"session_id": "b3e1f9a2-...",
"tool_name": null,
"tool_parameters": null,
"response_deadline": "2026-06-03T14:00:00Z",
"assigned_to": null,
"status": "pending",
"reviewer": null,
"review_note": null,
"reviewed_at": null,
"created_at": "2026-06-03T10:00:00Z"
}status is pending, approved, or denied. tool_name and tool_parameters are always null here; they are filled in only by GET /reviews.
GET /reviews
List reviews.
Auth: human JWT (any role) + business license
Query parameters: status (pending/approved/denied), limit (default 50, max 200), offset
Response 200: array of review objects
Returns 402 for community installations.
PATCH /reviews/
Approve or deny a pending review from the dashboard.
Auth: human JWT (any role) + business license
Request: { "action": "approve" } or { "action": "deny", "note": "reason text" }
Response 200: updated review object. Returns 409 if the review was already resolved
(approved or denied) by a prior call — resolution is row-locked, shared with the Slack
approve/deny path, so a concurrent Slack and dashboard resolution on the same review can't
both succeed. Returns 402 for community installations.
If your agent retries the identical call (same session_id, tool name, and parameters)
within HITL_RETRY_WINDOW_MINUTES (15 minutes by default) of an approve here, that retry
is automatically allowed instead of being routed to review again.
Audit Events
GET /audit-events
List audit events with optional filters. Community plan: limited to events from the last 7 days.
Auth: human JWT (any role). Machine tokens are rejected.
Query parameters:
| Parameter | Description |
|---|---|
decision | Filter by allow, deny, review, or error |
agent_id | Filter by agent UUID |
tool_name | Case-insensitive substring match on the tool name |
date_from | ISO 8601 start |
date_to | ISO 8601 end |
limit | Default 50, maximum 200 |
offset | Pagination offset |
Response 200: { "events": [...], "total": N, "limit": 50, "offset": 0 }. Each event has id, session_id and agent_id (each null when the event has none), agent_name, tool_name, tool_parameters, decision, decision_reason, policy_id, policy_name, workflow, duration_ms, sequence_number, created_at and tool_response (truncated 200 chars; always null via the MCP gateway path — not currently recorded). Each event where a policy fired includes a nested policy object (id, principalType, principalId, actionTool, resourceSystem, effect, condition) so the caller doesn't need a second lookup to render what happened.
GET /audit-events/export
Export filtered audit events as CSV. Accepts the same filters as GET /audit-events. Maximum 10,000 rows.
Auth: human JWT (any role) + enterprise license
Response: text/csv with Content-Disposition: attachment; filename=audit_events.csv
Returns 402 for community installations.
POST /audit-export-configs
Register an outbound SIEM export target. Every audit event written after creation is streamed to it — see Audit Log: SIEM Export.
Auth: admin + business license
Body:
| Field | Description |
|---|---|
export_type | otel or webhook |
target_url | Destination URL — an OTLP/HTTP collector endpoint for otel, a receiver URL for webhook |
enabled | Default true |
tenant_id | Optional |
Response 201: the created config (id, export_type, target_url, enabled, tenant_id).
Returns 402 for community installations.
GET /audit-export-configs
List all configured SIEM export targets.
Auth: admin + business license
Response 200: array of configs, same shape as the create response.
DELETE /audit-export-configs/
Remove a SIEM export target. Stops future exports; does not affect events already delivered.
Auth: admin + business license
Response 204: empty. Returns 404 if the config doesn't exist.
Policy Warnings (Enterprise)
Drift warnings raised by the policy drift detector. Served by the licensed API image only.
GET /warnings
List drift warnings, newest first.
Auth: admin + enterprise license
Query parameters: is_active (boolean, default true; send false for resolved warnings), warning_type, agent_id
Response 200: array of { id, warning_type, agent_id, agent_name, policy_id, policy_name, tool_name, message, is_active, created_at, resolved_at }
PATCH /warnings//resolve
Mark a warning resolved.
Auth: admin + enterprise license
Response 200: the updated warning, or 404.
Sessions
GET /sessions
List agent sessions with enriched metadata.
Auth: human JWT (any role) + enterprise license
Query parameters: limit (default 50, maximum 200), offset
Response 200: { "sessions": [...], "total": N }. Each session has id, agent_id, agent_name, status, started_at, completed_at, event_count and has_pending_review.
Returns 402 for community/business installations.
GET /sessions//events
Get all gateway call events for a session, ordered by sequence_number.
Auth: human JWT (any role) + enterprise license
Response 200: { "session_id", "agent_id", "trigger_context", "events": [...] }. Each event has id, session_id, tool_name, tool_parameters (truncated to 120 characters), decision, decision_reason, policy_name, sequence_number, duration_ms and created_at. Returns 404 if the session does not exist.
Returns 402 for community/business installations.
Dashboard
GET /dashboard/summary
Returns current operational summary. Polled by the dashboard Overview page.
Query parameters: window (24h, 7d or 30d, default 7d) sets the range for top_tools and decisions_by_hour; the other counts are fixed. 30d returns daily buckets instead of hourly ones.
Auth: human JWT (any role)
Response 200:
{
"window": "7d",
"granularity": "hour",
"intercepts_today": 47,
"allow_count_today": 40,
"deny_count_today": 5,
"review_count_today": 2,
"deny_rate_today": 10.6,
"pending_reviews": 3,
"active_warnings": 1,
"agents_with_unresolved_systems": 0,
"overdue_reviews": 0,
"top_denied_tool": { "tool": "query_credit_bureau", "count": 3 },
"high_risk_sessions": 0,
"active_sessions": 12,
"active_agents": 7,
"active_policies": 18,
"top_tools": [{"tool": "read_loan_application", "count": 22}],
"decisions_by_hour": [{"hour": "2026-06-03T10:00:00", "decision": "allow", "count": 10}]
}deny_rate_today is a percentage. active_sessions counts sessions started in the last hour and high_risk_sessions those among them with a risk score above 50. active_agents counts agents with status active.
active_warnings counts active drift warnings, which only the Enterprise drift detector creates, so it is 0 on other plans. top_denied_tool is null when no deny events today.
GET /dashboard/metrics
Returns analytics for the Decision metrics page.
Auth: human JWT (any role)
Response 200:
{
"policy_hit_rate": 34.0,
"deny_trend": [{"day": "2026-06-01T00:00:00", "decision": "deny", "count": 15}],
"top_agents_by_deny_rate": [{"agent_name": "loan-agent", "total": 45, "deny_rate": 22.0}],
"avg_review_seconds": 342
}policy_hit_rate and deny_rate are percentages. policy_hit_rate covers the last 7 days, deny_trend the last 30 days (one row per day and decision), and top_agents_by_deny_rate the top 10 agents by deny count over 7 days. avg_review_seconds is null when no review has been resolved.
GET /dashboard/activity-log
List user activity log entries (admin actions on policies, agents, users).
Auth: human JWT (any role)
Query parameters: action, date_from, date_to (ISO 8601 dates, inclusive; 422 otherwise), limit (default 50, maximum 200), offset
Response 200: { "logs": [...], "total": N }. Each entry has id, user_email, action, resource_type, resource_id, before_state, after_state, ip_address and created_at.
GET /dashboard/outcomes
Returns per-workflow and per-agent outcome counts, for the Overview page.
Auth: human JWT (any role)
Query parameters: window (a number plus h or d, such as 24h or 7d; default 7d)
Response 200:
{
"window": "7d",
"workflows": [
{ "workflow": "claims-intake", "agents": 2, "calls": 120, "held_for_approval": 3, "denied": 5,
"outcomes": [{ "kind": "...", "count": 3 }] }
],
"agents": [
{ "agent_name": "claims-agent", "calls": 90, "held_for_approval": 2, "denied": 4 }
]
}Users
GET /users
List all dashboard users.
Auth: admin
Response 200:
[
{
"id": "uuid",
"email": "alice@acme.com",
"name": "Alice Smith",
"role": "admin",
"is_active": true,
"is_root": true,
"password_set": true,
"last_login": "2026-06-03T09:00:00Z",
"created_at": "2026-06-02T14:00:00Z"
}
]POST /users
Invite a new dashboard user. Returns a one-time magic link for the user to set their password.
Auth: admin
Request: { "email": "bob@acme.com", "full_name": "Bob Jones", "role": "analyst" }
role is admin, analyst, or auditor (422 otherwise). Returns 409 if the email already exists.
Response 201:
{
"user": { "id": "uuid", "email": "bob@acme.com", "full_name": "Bob Jones", "role": "analyst" },
"magic_link": "http://localhost:3000/invite?token=<plaintext>"
}The magic link is valid for 24 hours and is shown once — share it with the user securely.
PATCH /users/
Update a user's role or active status.
Auth: admin
Request: { "is_active": false } or { "role": "admin" }
Response 200: updated user object.
Returns 400 when the target is the root user and the request deactivates it or sets a role other than admin. Returns 422 for an invalid role.
DELETE /users/
Delete a user. The root user cannot be deleted (and cannot be deactivated or demoted via PATCH).
Auth: admin
Response: 204, or 404.
POST /users//regenerate-invite
Generate a new magic link for a user. Invalidates any previous link. Works for both pending users (never set password) and active users (password reset).
Auth: admin
Response 200: { "user": { "id", "email", "full_name", "role" }, "magic_link": "http://localhost:3000/invite?token=<plaintext>" }. The link is valid for 24 hours. Returns 404 if the user does not exist.
Org Settings
GET /org-settings
Get the organization singleton settings.
Auth: human JWT (any role)
Response 200:
{
"org_name": "Acme Corp",
"timezone": "America/New_York"
}PUT /org-settings
Update organization settings. Both fields are required.
Auth: human JWT with role admin. Other signed-in users receive 403.
Request: { "org_name": "Acme Corp", "timezone": "Europe/London" }
timezone must be a valid IANA timezone string.
Response 200: updated org settings object.
License
GET /license-info
Returns the current license plan. Used by the frontend at runtime to unlock enterprise features. Safe to call unauthenticated — never returns the raw license key or email.
Auth: none required
Response 200:
{
"plan": "enterprise",
"company": "Acme Corp",
"is_enterprise": true,
"is_business": true,
"expires_at": "2027-06-03T00:00:00Z"
}plan is community, business, enterprise, or trial. Returns community when no org_settings row exists, or when no valid, unexpired activation code is stored.
GET /license/features
Returns which paid destinations exist for this install and their feature flags, for the dashboard to decide what to render in its navigation. A paid destination is absent entirely on a lower tier, never shown locked or greyed out.
Auth: human JWT (any role)
Response 200:
{
"tier": "enterprise",
"features": {
"nl_authoring": true,
"simulation": true,
"hitl": true,
"compliance_reports": true
},
"license_status": "active"
}tier is "free", "business", or "enterprise" (a trial reports "enterprise"). license_status is "active", "expired" (the stored code is past its expiry; tier is then "free"), or null (community).
PUT /settings/license-activation
The only way an activation code enters the system — there's no install.sh
prompt for it. Verifies the code's signature and expiry locally before saving
it; a code that doesn't verify is never stored.
Auth: human JWT with role admin
Request: { "activation_code": "..." }
Response 200:
{
"license_status": "active",
"license_plan": "business"
}Response 400: invalid, altered, or expired activation code — nothing is saved.
Billing
GET /billing/usage
Returns plan details and gateway call usage for the current and previous calendar month.
Auth: human JWT (any role)
Response 200:
{
"plan": "business",
"company": "Acme Corp",
"annual_price_usd": 999.0,
"retention_days": 30,
"features": ["Cedar policy enforcement", "..."],
"this_month": { "period": "2026-09", "intercepts": 124500 },
"last_month": { "period": "2026-08", "intercepts": 98000 },
"license_status": "active",
"expires_at": "2027-10-26T00:00:00+00:00"
}features is the cascaded list for the plan (community features, plus
business features if applicable, plus enterprise features if applicable) —
not a fixed enum of internal flag names. license_status is "active",
"expired" (the stored code is past its expiry; plan is then community) or
null (no code). expires_at is the stored code's expiry, or null with no
code. To cancel or change a subscription, see
Manage subscription.
Enterprise Compliance
POST /enterprise/compliance/report
Generate a compliance report narrative using the configured LLM.
Auth: admin + enterprise license
Request:
{
"frameworks": ["soc2", "eu_ai_act"],
"date_from": "2026-01-01",
"date_to": "2026-05-31",
"format": "pdf"
}frameworks values: eu_ai_act, nist_ai_rmf, soc2, iso_42001; at least one is required. date_to must be after date_from. format: pdf (default), md, or both.
Set LLM_MOCK_ENABLED=true to generate a placeholder without an LLM API key.
Response: Binary file download (application/pdf, text/markdown, or application/zip for both).
GET /enterprise/compliance/reports
List previously generated compliance reports.
Auth: admin + enterprise license
Response 200: array of { id, tenant_id, generated_at, date_from, date_to, frameworks, format, report_path, md_path, llm_model, mock_used, token_input, token_output, generated_by_email }
GET /enterprise/compliance/reports//download
Download a previously generated report.
Auth: admin + enterprise license
Response: Binary file download (PDF, Markdown or a zip, matching the report's format), or 404.
Health
GET /health
Health check. No authentication required. Use for load balancer health checks. Verifies the API can reach Postgres.
Response 200 (community, no license):
{
"status": "ok",
"service": "aicontrol",
"database": "ok",
"audit_wal_pending": 0,
"audit_wal_dead_lettered": 0,
"policy_engine_status": "in_process",
"drift_detector_status": "enterprise_only"
}Response 503: the API cannot reach Postgres.
{ "status": "unhealthy", "service": "aicontrol", "database": "unreachable" }audit_wal_pending is the number of audit events in the local write-ahead log not yet shipped to Postgres; a value that keeps growing means events are not reaching the database. audit_wal_dead_lettered counts events quarantined to the dead-letter file after 25 failed shipping attempts since the API started; alert on a non-zero value.
policy_engine_status is always "in_process" on every tier — Cedar evaluates in the same process as the API, so there's no separate service to be unreachable.
drift_detector_status reflects an internal background consistency check (agent-approved tools vs. policy coverage) and is only meaningful on an enterprise-licensed install, where it returns "healthy" or "degraded" instead of "enterprise_only".
Error responses
| Code | Meaning |
|---|---|
| 400 | Bad request |
| 401 | Missing or invalid token |
| 402 | Enterprise license required |
| 403 | Token lacks permission |
| 404 | Resource not found |
| 409 | Conflict (e.g. setup already complete) |
| 422 | Validation error — field-level detail in body |
| 500 | Internal server error |