WARDEN HTTP API
Source of truth:
warden/docs/api.md(synced at build time — edit the original, not this copy). Public base path:/warden/apibehind 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 areadmin | analyst | approver | viewer, assigned by Veltro identity grants and checked against Warden’s permissions.
Errors
Section titled “Errors”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.
Identity
Section titled “Identity”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-sectionbelow. Configure/warden-linkagainst/api/webhooks/slack-interactions.
Cases (manage_cases to mutate)
Section titled “Cases (manage_cases to mutate)”GET /cases— filterstatus/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). Stampsacknowledged_at+acknowledged_byonce (idempotent; a repeat returns the first one and adds no event), appends anacknowledgedtimeline event and acase.acknowledgeaudit entry, and 422s a never-acknowledged closed case. No automatic path (webhook intake, escalation, template create) acknowledges.- Case detail responses carry
acknowledged_at/acknowledged_byplus the derivedack_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 carriesmentions—{notified, unresolved, not_notified: [{email, reason}]}: an@user@example.commention 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 carriescross_case_hits) ·DELETE .../observables/{oid}.GET/POST /cases/{id}/tasks·PATCH /cases/{id}/tasks/{tid}(status/assignee/due; an explicitnullclearsassignee_id/due_at). Assignment appends atask_assignedtimeline 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 acceptsattack_techniques— MITRE ATT&CK ids (T1059,T1059.001), validated for shape, de-duplicated, upper-cased, capped at 50. A real change appends anattack_techniques_updatedevent withadded/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 with409 {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/wsroster).POST /cases/{id}/attachments(multipartfile; 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 (ornull):{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, andsuggested_runbookis only a name (the runbook still needs its normal trigger and approvals). A refusal is422 {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). Feedsai_triage_agreementinGET /metrics/soc.
Alerts
Section titled “Alerts”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 CHADGET /api/external/alertswith anexternal:readtoken from the Veltro workload exchange, dedups.
Runbooks & runs
Section titled “Runbooks & runs”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.
AI authoring (proposals only)
Section titled “AI authoring (proposals only)”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}, wherediffis a unified diff againstbase_yaml. The draft is validated with the same checks asPOST /runbooks/validatebefore you see it, and it is not saved — the author applies it to the builder canvas and saves throughPOST /runbooks/{slug}.POST /ai/runs/{run_id}/explanation— team-scoped likeGET /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: falsemarks a step the model named that the log does not contain. Requires thealertsandcasesdata 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 returnsai_draft_invalid_runbookwith the per-fielderrors. 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 as422 {"detail":{"code","message"}}. Both run throughapp/services/ai_runtime.py, Warden’s single AI seam. Audit convention: exactly one row per call — a call that reached the provider writes oneai.invocationrow (digests only) carryingrefusal_codewhen its answer was refused, and a gate refusal that never reached a provider writes oneai.refusalrow ({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 isprovider_request_failed.
Actions & connections
Section titled “Actions & connections”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. Achadconnection 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 inboundgeneric,chad,jira,servicenow,mailbox,pagerduty, andopsgenieendpoints; 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 byWARDEN_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 throughPOST /custom-actionsand arrive disabled withrisk: 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) withsignature_state,verified, install state, andavailable: false+refusalfor a pack that fails verification.POST /integration-packs/registry/{id}/preview|install|upgradereuse the upload path’s plan and consent gates (preview_token,accepted_overwrite_ids,acknowledge_danger);POST /integration-packs/registry/{id}/testruns the pack’s own fixtures and executes only against a test-mode connection.
Integration endpoints
Section titled “Integration endpoints”-
POST /webhooks/chad— CHAD alert intake (token = thechadsource’s token, orWARDEN_CHAD_WEBHOOK_SECRETas a bootstrap fallback); correlation-dedup → case create/append → trigger matcher enqueues runs. 202. -
POST /webhooks/{slug}— any registered source (headerX-Warden-TokenorAuthorization: Bearer <token>);chaduses its native shape,genericmaps viafield_map(rule_title/severity/external_id/external_url/correlation_root, flat-or-dotted paths),jira/servicenowupdate an existing TicketLink,mailboxaccepts normalized email payloads, andpagerduty/opsgenieacknowledge the case a page was sent for. Apagerdutysource authenticates withX-PagerDuty-Signature(v1 HMAC-SHA256 of the raw body, keyed by the subscription’s signing secret, set as the source’s write-onlysigning_secret) instead of the token;opsgeniesends the token in a custom header. Anincident.acknowledged(PagerDuty, matched byincident_key) orAcknowledge(Opsgenie, matched byalias) 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 withpage_acksaying why nothing changed. Ticket webhooks always ingest a valid remote comment;ticket_status_syncreports any separately audited status-mapping refusal without returning a provider-disabling non-2xx response. 202 on success. -
POST /webhooks/slack-interactions— Slack-signed/warden-linkand 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 (JiraGET /rest/api/2/issue/{key}?fields=status,comment; ServiceNow the incident row plus itssys_journal_fieldcomments) once per connectionconfig.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 commentid, ServiceNow journalsys_id; a ServiceNow business rule can send it asresult.comment_sys_id). A failed poll is kept on the link aslast_poll_errorand retried next interval. -
GET /related-cases— pull-model enrichment for CHAD’s alert detail (actor-scopedwarden:cases:read, viewer+); queryobservable_value+ optionalobservable_field/exclude_case_id→ 200{contract_version,open_cases:[{key,severity,status,url}], observable_hits}on a match, else 204. Versioned contractveltro-related-cases/v1(committed schema+vectors inbackend/contracts/veltro-related-cases/v1/): the response always carriescontract_version: 1, and a consumer may pin it via thecontract_versionquery 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 removedPOST /enrich/alert-contextpush webhook — that endpoint, theX-Warden-Enrich-Tokenheader, and theWARDEN_ENRICH_TOKENenv 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 same404 {"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 peridempotency_keyand audited asmcp.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}withno-store;actionsfeed the suite’s header search (New runbook, New case). Each entry’srequiredRoleis the least role onviewer < analyst < approver < adminfrom which every stronger role holds the page’s permission in the current role matrix; norequiredRolemeans 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}withno-store;exchangesmaps each audience to its sorted scopes. The list isapp/core/exchange_declaration.py, and the exchange client refuses any other pair withUNDECLARED_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}withno-store. Items are open cases with an owner, up to 20 per owner, each carrying the owner’s email asassignee, the case’s identity team (nullfor 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 (thecasesstage) and live runbook runs started in the last 24 hours (theactionsstage) 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.useris identity’s subject, matched againstusers.identity_subonly; an unknown or disabled subject gets an empty answer. GET returns{product, unread, items}withno-store: that user’s in-app inbox, newest first, at most 50 items of{id, kind, severity, title, context, at, read, href?}(severityis alwaysinfo;hrefis/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.qis trimmed and must be 2..100 characters. Returns{product, items}withno-store: at most 10 cases and runbooks of{id, kind, title, context, href, severity?}, best match first (exact case number such as42orWARDEN-42, case id or runbook slug; then a title, name or slug prefix; then one containingq). 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.teamnarrows 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 thewarden:peer:status:readtoken: identity’s status service reads install-wide, a realm admin install-wide, a team admin only that team’s runs; anyone else gets 403.usermust be the only parameter and a lowercase UUID, else 400. Warden asks identity for the user’s live Warden grants and answers 503upstream_unavailablewhen it can’t. A gate is listed when its run iswaiting_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 withapprove_runbooksthat the gate’srequired_roleaccepts. 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}withno-store, sorted byexpires_atthen 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 pluslink/unlinkactions. GET takes identity’s suite-status service token only, resolvesuserlike the bell, and returns{product, title, description, fields}withno-store(fields: []when Warden has no such active user). POST takes awarden:peer:account:writetoken identity mints for the signed-in person (warden:peer:status:readis refused, veltro#1211); it must carry a signedactor_suband must not be the status service. The user is the active one whoseidentity_subequalsactor_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?};linkputs the one-time/warden-link <token>command innotice.code, and GET never shows it. Anything else is 400 in the peer error shape. -
Outbound MCP (
mcpconnection kind) —base_urlis the JSON-RPC 2.0 endpoint, the sealed secret is the bearer credential, andconfig.allowed_toolsis the complete set of tools any Warden caller may invoke on that server.config.read_only_tools(a subset) is the set an unattendedai.agentloop may call on its own; every other allowed tool is write intent and only runs after a human approval. The remote server’s owntools/list/readOnlyHintis discovery, never authority:POST /connections/{id}/testuses it solely to report allowlisted tools the server does not advertise. -
GET/PUT /response-policy(admin) — the containment guardrail document:protected_targets,ttlbounds,automatic_grants, and the enforcementadaptersthat makeresponse.ip_blockactually enforce. PUT is append-only (a new version plus a pointer flip, audited asresponse_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 returnsbindable_actions— what a binding may legally name.
Metrics & audit
Section titled “Metrics & audit”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) plusai_triage_agreement: {total_feedback, agreed, disagreed, agreement_rate}—agreement_rateisnullwhen no analyst judged an AI verdict in the window.GET /metrics— Prometheus text exposition for the API process. IfMETRICS_TOKENis set, send it asAuthorization: Bearer <token>. The worker exposes process-local run and heartbeat series on private port9101with 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_daysisnull(never 0) when an out-of-bounds override made the window unavailable;backlog: truemeans the last sweep filled its batch and the next one is due immediately.GET /diagnostics(admin) —veltro-diagnostics/v1support 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 ingeneratedAt.
Live updates
Section titled “Live updates”WS /ws— case presence, run, alert, approval and notification updates. The gateway forwards a browser assertion for the connection. Inbound frames are limited topresence.join,presence.ping,presence.leaveandping, capped at 2 KiB. Each case join checks visibility.GET /notifications(unread_only,limit) ·POST /notifications/read({ids?}; omitidsfor 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.