Skip to content

WARDEN HTTP API

Source of truth: warden/docs/api.md (synced at build time — edit the original, not this copy). Public base path: /warden/api behind the Veltro gateway. Browser requests carry a gateway-minted, request-bound assertion; machine and MCP calls use Veltro exchange-issued authority envelopes. Webhook sources use their own source tokens. Warden has no local login, JWT session, personal API key or product-owned OIDC, SAML or SCIM endpoint. Roles are admin | analyst | approver | viewer, assigned by Veltro identity grants and checked against Warden’s permissions.

Warden’s error bodies carry a stable code and its params beside detail: {"detail": "Case not found", "code": "case_not_found", "params": {}}. detail keeps the English text (or the structured object a route already returned) and the status code is unchanged. Unhandled failures are 500 with code internal_error. FastAPI’s request-validation 422 keeps its own shape and has no code. Every code and its English template is in backend/app/core/api_error_codes.json (regenerate with python -m app.core.api_errors); the frontend renders app-frame.apiError.<code> and falls back to detail for a code it doesn’t know.

  • GET /auth/me — current user {id, email, full_name, role} from the identity assertion. Sign-in and logout happen at the Veltro identity origin.
  • The Slack identity link lives on identity’s Account page; see /peer/v1/account-section below. Configure /warden-link against /api/webhooks/slack-interactions.
  • GET /cases — filter status/owner/severity/search/limit/offset → case summaries (WARDEN-{number}, owner_email, alert_count, sla_breached).
  • POST /cases — {title, description?, severity, tags?, template_id?} (template instantiates phased tasks + SLA).
  • GET /cases/{id} · PUT /cases/{id} · POST /cases/{id}/status ({status, note}) · POST /cases/{id}/assign ({owner_id|null}).
  • POST /cases/{id}/acknowledge — the analyst “I have taken this” action (ADR-001 Decision 6). Stamps acknowledged_at + acknowledged_by once (idempotent; a repeat returns the first one and adds no event), appends an acknowledged timeline event and a case.acknowledge audit entry, and 422s a never-acknowledged closed case. No automatic path (webhook intake, escalation, template create) acknowledges.
  • Case detail responses carry acknowledged_at/acknowledged_by plus the derived ack_sla — {state: none|pending|met|breached, due_at, acknowledged_at, acknowledged_by, clock_stopped_at, elapsed_seconds, breached}. The ack clock stops at acknowledgement (or at resolution/closure without one), and the state is reported apart from owner assignment and case status.
  • GET /cases/{id}/timeline — the single chronological event stream.
  • POST /cases/{id}/comments · DELETE /cases/{id}/comments/{cid} (soft-delete; admin or author). The POST response carries mentions — {notified, unresolved, not_notified: [{email, reason}]}: an @user@example.com mention notifies that user in-app, and an address that resolves to nobody or to someone who cannot read the case is reported back with a reason rather than silently dropped.
  • GET/POST /cases/{id}/alerts · DELETE /cases/{id}/alerts/{alert_id} — link/unlink.
  • GET/POST /cases/{id}/observables (dedup on normalized value; response carries cross_case_hits) · DELETE .../observables/{oid}.
  • GET/POST /cases/{id}/tasks · PATCH /cases/{id}/tasks/{tid} (status/assignee/due; an explicit null clears assignee_id/due_at). Assignment appends a task_assigned timeline event and notifies the new assignee.
  • GET/POST /cases/{id}/tasks/{tid}/comments · DELETE .../comments/{cid} — task thread (4000-char cap; soft-delete by author or case-team admin). Mentions fan out as on case comments; the task’s assignee is notified once per comment.
  • PUT /cases/{id} also accepts attack_techniques — MITRE ATT&CK ids (T1059, T1059.001), validated for shape, de-duplicated, upper-cased, capped at 50. A real change appends an attack_techniques_updated event with added/removed.
  • POST /cases/{id}/apply-template — {template_id}; applies a case template to an existing case additively: adds only blueprint items whose (phase, title) is not already present, appends them after the current highest position, applies the template’s SLA policy only when the case has none, and never touches title/severity/tags. Returns {added, skipped, sla_applied, sla_skipped_reason}. Refused with 409 {code: "case_closed"} on a closed case.
  • GET /cases/{id}/war-room — one read backing the war-room pane: recent comments, pinned evidence, runbook runs, open-task count, current presence roster, and the presence heartbeat interval.
  • GET /cases/{id}/presence — who is viewing the case now (soft, heartbeat-expiring state; read-only mirror of the /ws roster).
  • POST /cases/{id}/attachments (multipart file; sha256, size cap) · GET .../attachments/{aid}/download.
  • GET/POST /cases/{id}/evidence — pin timeline/observable/attachment/action-log/link with a required note.
  • POST /cases/{id}/runbooks/{slug}/run — {dry_run?, inputs?} (run_runbooks) → queues a run.
  • GET /cases/{id}/runs · GET /cases/assignable-users.
  • GET /cases/{id}/ai-triage — the stored advisory AI verdict (or null): {state: pending|complete|failed, verdict, confidence, rationale, evidence_refs, unverified_evidence_refs, suggested_runbook, model, provider, output_digest, error_code, my_feedback}.
  • POST /cases/{id}/ai-triage — {note?} (manage_cases) runs one advisory triage now. Never a state change: the verdict is recorded on the case and the timeline and changes no other case column, and suggested_runbook is only a name (the runbook still needs its normal trigger and approvals). A refusal is 422 {detail: {code, message}} with the typed code (ai_kill_switch, ai_not_configured, ai_disabled, ai_data_class_denied, ai_structured_output_invalid, ai_triage_output_invalid) and is audited.
  • POST /cases/{id}/ai-triage/feedback — {analyst_verdict, note?} (manage_cases) records the analyst’s verdict beside the AI’s; one row per analyst per case (re-submitting updates it). Feeds ai_triage_agreement in GET /metrics/soc.
  • GET /alerts — inbox (filter status/severity/source). POST /alerts — manual create.
  • POST /alerts/{id}/escalate → new case. POST /alerts/{id}/link ({case_id}). POST /alerts/{id}/dismiss.
  • POST /alerts/backfill — {chad_connection_id, since?, limit?} pages CHAD GET /api/external/alerts with an external:read token from the Veltro workload exchange, dedups.
  • GET /runbooks · GET /runbooks/{slug} (+versions) · POST /runbooks/{slug}/enable|disable.
  • POST /runbooks/sync — git pull+validate+upsert (or local reload) → report {created,updated,skipped,errors}.
  • POST /runbooks/validate — {yaml_text} → {errors:[{loc,msg}]} (schema + action + Jinja + DAG).
  • POST /runbooks/{slug} — {yaml_text} (manage_runbooks) create/update from the builder; version-bumps + writes history; 422 on invalid.
  • GET /runs · GET /runs/{id} (steps + action_log + chain badge) · GET /runs/{id}/log.
  • POST /runs/{id}/approve|deny — {step_id, note} (approve_runbooks) resume/abort a gated run. POST /runs/{id}/cancel.
  • GET /runs/chain/verify → {valid, checked, first_broken_index} (action_log hash chain).
  • GET /approvals?status=pending — approver queue.

Both endpoints run through the product’s configured provider (Settings → AI, contract veltro-ai-provider/v1): the kill switch, the enabled flag and the allow_data_classes consent gate all apply, prompts are redacted, and every call writes one ai.invocation audit row carrying digests only. Neither endpoint creates or changes a runbook, a run, an approval or a response effect.

  • POST /ai/runbook-drafts — {description, base_yaml?} (manage_runbooks) → {proposal, refusal, audit}. A proposal is {slug, name, yaml_text, summary, notes, actions_used, approval_steps, diff}, where diff is a unified diff against base_yaml. The draft is validated with the same checks as POST /runbooks/validate before you see it, and it is not saved — the author applies it to the builder canvas and saves through POST /runbooks/{slug}.
  • POST /ai/runs/{run_id}/explanation — team-scoped like GET /runs/{id} → {explanation:{summary, steps:[{step_id, explanation, in_log}], outcome, next_steps, risks}, audit}. Built from the run’s action log only; in_log: false marks a step the model named that the log does not contain. Requires the alerts and cases data classes. Read-only and never stored.
  • Named refusals: a draft that names an action outside the registry returns 200 {"refusal":{"code":"ai_draft_unknown_action","details":{"unknown_actions":[…]}}} and no proposal; a draft that fails validation returns ai_draft_invalid_runbook with the per-field errors. Provider-level refusals (ai_not_configured, ai_disabled, ai_kill_switch, ai_data_class_denied, ai_provider_error, ai_structured_output_invalid) and an unusable request (ai_invalid_request) come back as 422 {"detail":{"code","message"}}. Both run through app/services/ai_runtime.py, Warden’s single AI seam. Audit convention: exactly one row per call — a call that reached the provider writes one ai.invocation row (digests only) carrying refusal_code when its answer was refused, and a gate refusal that never reached a provider writes one ai.refusal row ({product, feature, refusal_code, message, actor} — no digests, no tokens). A provider that answered with an error keeps its own contract code (ai_provider_error, ai_timeout); a call that never became an answer is provider_request_failed.
  • GET /actions — registry: [{name, input_schema, output_schema, connection_kind, implemented}] (34 built-ins; custom.* appended at runtime).
  • GET/POST/PUT/DELETE /connections (manage_connections; secrets write-only) · POST /connections/{id}/rotate ({secret}; current and outgoing credentials overlap for 24 hours, with the deadline returned on Connection responses) · POST /connections/{id}/test. Supported kinds include Jira, ServiceNow, PagerDuty, Opsgenie, Teams, SMTP, IMAP, Microsoft Graph, MCP, and S3. A chad connection takes no secret (422 if one is sent): Warden calls CHAD with Veltro workload-exchange tokens.
  • GET/POST/PATCH/DELETE /webhook-sources + POST /webhook-sources/{id}/rotate (manage_connections) — UI-managed inbound generic, chad, jira, servicenow, mailbox, pagerduty, and opsgenie endpoints; token generated server-side, Fernet-encrypted at rest, returned only on create/rotate.
  • POST /custom-actions/import/openapi (manage_connections) — {document, name_prefix} (OpenAPI 3 JSON or YAML, capped by WARDEN_MAX_OPENAPI_KB) → {title, openapi_version, server_url, server_path_prefix, drafts:[{spec, method, path, suggested_risk, notes}], skipped:[{location, reason}]}. Writes nothing; drafts are saved one at a time through POST /custom-actions and arrive disabled with risk: arbitrary + an unbounded blast radius until an operator lowers them deliberately.
  • GET /integration-packs/registry (manage_runbooks) — the packs the image ships (content/packs/index.json) with signature_state, verified, install state, and available: false + refusal for a pack that fails verification. POST /integration-packs/registry/{id}/preview|install|upgrade reuse the upload path’s plan and consent gates (preview_token, accepted_overwrite_ids, acknowledge_danger); POST /integration-packs/registry/{id}/test runs the pack’s own fixtures and executes only against a test-mode connection.
  • POST /webhooks/chad — CHAD alert intake (token = the chad source’s token, or WARDEN_CHAD_WEBHOOK_SECRET as a bootstrap fallback); correlation-dedup → case create/append → trigger matcher enqueues runs. 202.

  • POST /webhooks/{slug} — any registered source (header X-Warden-Token or Authorization: Bearer <token>); chad uses its native shape, generic maps via field_map (rule_title/severity/external_id/external_url/correlation_root, flat-or-dotted paths), jira/servicenow update an existing TicketLink, mailbox accepts normalized email payloads, and pagerduty/opsgenie acknowledge the case a page was sent for. A pagerduty source authenticates with X-PagerDuty-Signature (v1 HMAC-SHA256 of the raw body, keyed by the subscription’s signing secret, set as the source’s write-only signing_secret) instead of the token; opsgenie sends the token in a custom header. An incident.acknowledged (PagerDuty, matched by incident_key) or Acknowledge (Opsgenie, matched by alias) for a linked page stamps the case’s acknowledgement once; the timeline event names the pager and its agent, and Opsgenie’s acknowledging user becomes the actor when their email matches an active Warden user. Unknown keys, repeats and closed cases return 202 with page_ack saying why nothing changed. Ticket webhooks always ingest a valid remote comment; ticket_status_sync reports any separately audited status-mapping refusal without returning a provider-disabling non-2xx response. 202 on success.

  • POST /webhooks/slack-interactions — Slack-signed /warden-link and interactive approval callbacks. Configure the Slack app’s slash-command and interactivity request URL to this endpoint; the connection secret is the Slack signing secret.

  • GET /cases/{id}/tickets · GET /tickets/{link_id} — team/private-case-scoped external ticket links. Local case comments and mapped statuses mirror to linked Jira/ServiceNow tickets; inbound mirrored comments carry a loop marker and are not echoed back. When webhooks don’t arrive, the worker polls each link on a case that isn’t closed (Jira GET /rest/api/2/issue/{key}?fields=status,comment; ServiceNow the incident row plus its sys_journal_field comments) once per connection config.poll_interval_s (default 300, minimum 60). A changed remote status is applied once, and a remote comment lands once whichever path saw it first, matched by the provider’s comment id (Jira comment id, ServiceNow journal sys_id; a ServiceNow business rule can send it as result.comment_sys_id). A failed poll is kept on the link as last_poll_error and retried next interval.

  • GET /related-cases — pull-model enrichment for CHAD’s alert detail (actor-scoped warden:cases:read, viewer+); query observable_value + optional observable_field/exclude_case_id → 200 {contract_version,open_cases:[{key,severity,status,url}], observable_hits} on a match, else 204. Versioned contract veltro-related-cases/v1 (committed schema+vectors in backend/contracts/veltro-related-cases/v1/): the response always carries contract_version: 1, and a consumer may pin it via the contract_version query param — an unsupported explicit version is refused with 422 (a missing param is the legacy unversioned call, served the current shape). Live/uncached and team-scoped. Replaces the removed POST /enrich/alert-context push webhook — that endpoint, the X-Warden-Enrich-Token header, and the WARDEN_ENRICH_TOKEN env var no longer exist.

  • GET /suite-context/{suite_correlation_id} — authenticated, read-only resolver for Warden alerts and cases sharing one opaque suite correlation UUID. Existing team scope is applied independently to both record types; an identifier with no visible records returns the same 404 {"detail":"Suite context not found"} whether records are absent or inaccessible. The response contains navigation metadata only and does not duplicate product records.

  • POST /peer/v1/mcp · GET /peer/v1/mcp — Warden MCP tools require an exchange-issued, audience-bound actor envelope. Native role, permission, team scope and case visibility are checked before dispatch. Effectful calls are idempotent per idempotency_key and audited as mcp.tool_called.

  • GET /peer/v1/nav — Warden’s section of the Veltro chrome, read by identity with its suite-status service token (warden:peer:status:read) to build each viewer’s nav manifest. Returns {product, sections, settings, actions} with no-store; actions feed the suite’s header search (New runbook, New case). Each entry’s requiredRole is the least role on viewer < analyst < approver < admin from which every stronger role holds the page’s permission in the current role matrix; no requiredRole means any role.

  • GET /peer/v1/exchanges — the (audience, scope) pairs Warden mints through identity’s workload exchange (veltro#1359), read by identity with its suite-status service token (warden:peer:status:read, same refusals as nav) so promotion fails on a pair its reviewed matrix or Warden’s stored credential refuses. Returns {contract_version: 1, product, exchanges} with no-store; exchanges maps each audience to its sorted scopes. The list is app/core/exchange_declaration.py, and the exchange client refuses any other pair with UNDECLARED_EXCHANGE_SCOPE.

  • GET /peer/v1/home — Warden’s part of Home (veltro#1140), read install-wide by identity with its suite-status service token (warden:peer:status:read). Returns {product, items, strip, setup} with no-store. Items are open cases with an owner, up to 20 per owner, each carrying the owner’s email as assignee, the case’s identity team (null for org-wide) and an Open action; a private case is listed only when its owner is a member. The strip counts team-visible open cases (the cases stage) and live runbook runs started in the last 24 hours (the actions stage) per team. Setup lists two steps, open the first case and finish an approved response, marked done from the org’s milestone stamps; it’s empty once both are stamped.

  • GET /peer/v1/notifications?user=<subject> · POST /peer/v1/notifications/read — Warden’s share of the suite bell (veltro#1143). Only identity’s suite-status service token (warden:peer:status:read) is accepted; a human token, even a realm admin’s, gets 403. user is identity’s subject, matched against users.identity_sub only; an unknown or disabled subject gets an empty answer. GET returns {product, unread, items} with no-store: that user’s in-app inbox, newest first, at most 50 items of {id, kind, severity, title, context, at, read, href?} (severity is always info; href is /warden/cases/{id} when the notification is about a case). POST takes {user, ids: [1..100]} or {user, all: true}, marks only that user’s items read (other ids are ignored), and returns {product, unread}. Anything else is 400 in the peer error shape.

  • GET /peer/v1/search?user=<subject>&q=<query>[&team=<suite team id>] — Warden’s share of the suite header search (veltro#1142). Same token and subject rules as the bell. q is trimmed and must be 2..100 characters. Returns {product, items} with no-store: at most 10 cases and runbooks of {id, kind, title, context, href, severity?}, best match first (exact case number such as 42 or WARDEN-42, case id or runbook slug; then a title, name or slug prefix; then one containing q). Cases are scoped like the case list: the user’s teams, org-wide cases, private cases only for members and team admins, merged cases skipped. team narrows cases to that team when the user holds it and is ignored otherwise. Runbooks are org-wide.

  • GET /peer/v1/approvals?user=<subject> — the paused run steps one person may decide, for identity’s Approvals page, Home and MCP list (veltro#1161). Takes the warden:peer:status:read token: identity’s status service reads install-wide, a realm admin install-wide, a team admin only that team’s runs; anyone else gets 403. user must be the only parameter and a lowercase UUID, else 400. Warden asks identity for the user’s live Warden grants and answers 503 upstream_unavailable when it can’t. A gate is listed when its run is waiting_approval, it hasn’t expired, and a grant covering the run’s team (realm, or that team; an org-wide run needs a realm grant) has a role with approve_runbooks that the gate’s required_role accepts. The user’s own runs show up only while identity holds a four-eyes exception for them (self_requested: true). Returns {contract_version, product, user, observed_at, truncated, approvals} with no-store, sorted by expires_at then run id, at most 100. Each item is {id (run), kind: "run_step", step_id, title, summary (masked preview target), environment, team_id (suite team), requester_email, requester_subject, self_requested, requested_at, expires_at, href: "/warden/runs/{id}", decide_tool: "warden_approve_run"}.

  • GET /peer/v1/account-section?user=<subject> · POST /peer/v1/account-section — Warden’s section of identity’s one Account page (veltro#1156): the user’s Slack identity status plus link/unlink actions. GET takes identity’s suite-status service token only, resolves user like the bell, and returns {product, title, description, fields} with no-store (fields: [] when Warden has no such active user). POST takes a warden:peer:account:write token identity mints for the signed-in person (warden:peer:status:read is refused, veltro#1211); it must carry a signed actor_sub and must not be the status service. The user is the active one whose identity_sub equals actor_sub, else 404; the body names no user. The body is {action: "link" | "unlink"}, only an action the section offers right now. It returns {product, section, notice?}; link puts the one-time /warden-link <token> command in notice.code, and GET never shows it. Anything else is 400 in the peer error shape.

  • Outbound MCP (mcp connection kind) — base_url is the JSON-RPC 2.0 endpoint, the sealed secret is the bearer credential, and config.allowed_tools is the complete set of tools any Warden caller may invoke on that server. config.read_only_tools (a subset) is the set an unattended ai.agent loop may call on its own; every other allowed tool is write intent and only runs after a human approval. The remote server’s own tools/list/readOnlyHint is discovery, never authority: POST /connections/{id}/test uses it solely to report allowlisted tools the server does not advertise.

  • GET/PUT /response-policy (admin) — the containment guardrail document: protected_targets, ttl bounds, automatic_grants, and the enforcement adapters that make response.ip_block actually enforce. PUT is append-only (a new version plus a pointer flip, audited as response_policy.publish) and refuses a document the runtime could not evaluate, or a binding naming an unregistered, arbitrary-tier, or itself-managed action. GET also returns bindable_actions — what a binding may legally name.

  • GET /metrics/gate → {approved_executions, denied, dry_runs, window_days} (the response-loop gate counter).
  • GET /metrics/overview → open-cases-by-severity, MTTA/MTTR seconds, SLA at-risk.
  • GET /metrics/soc?days=&team= → weekly SOC metrics (MTTA/MTTR by severity/team, opened/closed, top rules, automation rate, approval turnaround) plus ai_triage_agreement: {total_feedback, agreed, disagreed, agreement_rate} — agreement_rate is null when no analyst judged an AI verdict in the window.
  • GET /metrics — Prometheus text exposition for the API process. If METRICS_TOKEN is set, send it as Authorization: Bearer <token>. The worker exposes process-local run and heartbeat series on private port 9101 with the same credential.
  • GET /audit (admin) — hash-chained audit log. GET /audit/verify → {valid, checked, first_broken_index}. Warden has no audit page of its own; people read the log in the suite’s Settings > Audit, which pulls it through /peer/v1/audit.
  • GET /retention (admin) — run-retention policy + durable sweep state → {policy:{enabled, class, contract_version, realm, resource, retention_days, default_days, min_days, max_days, provenance, override_days, cause, sweep_interval_hours, batch_limit}, last_sweep_at, last_deleted, total_deleted, backlog, last_error, last_error_at}. retention_days is null (never 0) when an out-of-bounds override made the window unavailable; backlog: true means the last sweep filled its batch and the next one is due immediately.
  • GET /diagnostics (admin) — veltro-diagnostics/v1 support document → {schema, module, generatedAt, versions:{module, dependencies}, migrations:{current, pending}, health:{<component>: state}, configShape:{<SETTING>:{present, type}}, errors:[{type, count, firstSeenAt, lastSeenAt}]}. Safe to hand to support as-is: configuration is reported as presence + declared type only (never a value, masked or otherwise) and errors are counts by code-named class, never a recorded message, action output or target host. The key set is closed and built by allowlist (app/services/diagnostics.py), so a new setting or dependency stays out until it is classified there; two captures of unchanged state differ only in generatedAt.
  • WS /ws — case presence, run, alert, approval and notification updates. The gateway forwards a browser assertion for the connection. Inbound frames are limited to presence.join, presence.ping, presence.leave and ping, capped at 2 KiB. Each case join checks visibility.
  • GET /notifications (unread_only, limit) · POST /notifications/read ({ids?}; omit ids for all) — the caller’s own in-app inbox (mentions, task assignment, task comments). Every route is scoped to the authenticated user, so one analyst can neither read nor clear another’s inbox.