Skip to content
browse the docs

HTTP API Reference

vanzik v2.0updated view on github

Every endpoint Vanzik exposes, with auth requirements, request/response shapes, and status codes. Source: pkg/proxy/server.go, pkg/proxy/auth.go, pkg/proxy/tenant.go.

Base URL: http(s)://<host>:<port>
Default:  http://localhost:8080

URL families: legacy vs tenant-aware (v0.5+)#

Every operational endpoint is exposed at both of these paths:

Family Pattern Tenant resolution
Legacy /v1/<suffix> Always evaluates against tenant local.
Tenant-aware /v1/t/{tenant}/<suffix> {tenant} is extracted from the URL and validated by the engine's PolicyProvider.

{tenant} is validated against the policy registry: local is always recognised (the bundled file policy), and additional tenants are registered with vanzik tenant put <id> --policy <yaml> (requires --persist). An unregistered tenant returns 404 with body {"error":"tenant not found"}; a store infrastructure failure during tenant resolution returns 500 {"error":"policy provider error"}. /v1/check and /v1/t/local/check remain identical in responses, audit entries, and headers.

Tenant ID format constraints:

  • Non-empty after URL-decoding.
  • URL-safe characters only — SDKs URL-encode the value before building the path, so reserved characters (/, spaces, etc.) are escaped to %XX.
  • Must be registered (or local) — unknown tenants 404.

Runtime state is partitioned by tenant. Each tenant gets isolated approvals (no cross-tenant existence oracle), rate-limit buckets (scope:tenant:agent keys), session-cost accumulators, SSE feeds, and tenant-scoped audit reads — /v1/t/<tenant>/audit only returns that tenant's entries.

The wire format (docs/WIRE_PROTOCOL.md) does not change between families: the same request body and response shape (schema_version: "v1") flow through both.

The legacy paths are not deprecated; both families are supported.


Auth model#

Vanzik uses two orthogonal auth schemes:

  1. Bearer token (Authorization: Bearer <key>) — for machine-to-machine access. Comes from the --api-key flag on the server.
  2. Session cookie + CSRF — for browser (/dashboard) access. POST /auth/login with the same API key sets an HttpOnly vanzik_session cookie and a JS-readable vanzik_csrf cookie (double-submit). State-changing endpoints additionally require X-CSRF-Token: <vanzik_csrf value>.

When the server is started without --api-key, auth middleware is a pass-through and the server binds to 127.0.0.1 only.

Auth posture is identical across both URL families — the middleware is URL-agnostic, so a token / session that works against /v1/audit works against /v1/t/local/audit and vice versa.

Endpoint (legacy) Endpoint (tenant-aware) Auth State-changing (CSRF)?
POST /v1/check POST /v1/t/{tenant}/check open by design n/a
POST /v1/approve/{id} POST /v1/t/{tenant}/approve/{id} Bearer or session yes
POST /v1/deny/{id} POST /v1/t/{tenant}/deny/{id} Bearer or session yes
GET /v1/status/{id} GET /v1/t/{tenant}/status/{id} Bearer or session no
GET /v1/audit GET /v1/t/{tenant}/audit Bearer or session no
GET /v1/sessions GET /v1/t/{tenant}/sessions Bearer or session no
GET /v1/sessions/{id} GET /v1/t/{tenant}/sessions/{id} Bearer or session no
POST /v1/audit POST /v1/t/{tenant}/audit Bearer or session yes
GET /api/pending GET /v1/t/{tenant}/api/pending Bearer or session no
GET /api/stream GET /v1/t/{tenant}/api/stream Bearer or session no
GET /api/stats GET /v1/t/{tenant}/api/stats Bearer or session no
GET /v1/health GET /v1/t/{tenant}/health open no
POST /auth/login — open (validates key) no
POST /auth/logout — open no
GET /health — open no
GET /metrics — open no
GET /dashboard — open (HTML based on session) no

/v1/check is intentionally unauthenticated so local agents can call it with zero setup. Gate it at the network layer (reverse proxy allowlist, firewall, service mesh) if your threat model requires.

/api/pending, /api/stream, /api/stats and their /v1/t/{tenant}/… mirrors are registered only when the server runs --dashboard; without it they return 404.


POST /v1/check · POST /v1/t/{tenant}/check#

The core policy query. The SDKs wrap this. Both URL families produce identical responses; pick whichever matches your deployment topology.

Request#

POST /v1/check HTTP/1.1
Content-Type: application/json

{
  "schema_version": "v1",
  "agent_id": "my-agent",
  "scope": "shell",
  "command": "rm -rf ./old_data",
  "session_id": "sess-abc",
  "est_cost": 0.12,
  "meta": {"run_id": "123"}
}

The tenant-aware variant is exactly the same body sent to a different path:

POST /v1/t/local/check HTTP/1.1
Content-Type: application/json

{ "schema_version": "v1", "scope": "shell", "command": "ls -la" }

schema_version may be omitted (the server defaults to "v1"); any other value is rejected with 400 and an error body {"error":"unsupported schema_version; expected v1","received":"..."}. See docs/WIRE_PROTOCOL.md for the full versioning contract.

Field Type Required Notes
agent_id string no (strongly recommended) Keys per-agent overrides, rate-limit buckets, and audit filtering. Omitting it is not rejected — the policy just evaluates without per-agent context.
scope string yes* shell, filesystem, network, browser, cost, data, mcp_tool, or any custom string. *Not enforced with a 400 — an empty scope matches no rules and falls through to default deny (200).
command string — For shell scope (required to match pattern).
action string — For filesystem scope (required alongside path).
path string — For filesystem. Normalized with filepath.Clean + ToSlash.
domain string — For network / browser.
url string — For network / browser.
session_id string — For cost scope accumulator. Groups entries in GET /v1/sessions.
parent_session_id string — The session that started this one, e.g. an orchestrator's session for a sub-agent. Policy ignores it; the audit log keeps it so GET /v1/sessions can link parent and child sessions.
est_cost float — For cost scope. Negative values denied.
meta object — Free-form string→string map; logged as-is.
approval_id string — Replay of a previously issued approval (see APPROVAL_WORKFLOW.md). Honored only when the request's operationally-meaningful fields match the original, the resolution is younger than --approval-validity (default 5m), and — for ALLOW — at most once: approvals are one-shot capabilities, so a second replay of the same id falls through to fresh policy evaluation and re-enters the approval flow under a new id. Refusals are counted in vanzik_approval_replay_refused_total.

Body is limited to MaxRequestBodyBytes (default 1 MB; configurable via proxy.request.max_body_bytes). Exceeded → 413.

Response#

{
  "schema_version": "v1",
  "decision": "ALLOW",
  "reason": "Allowed by shell rule",
  "matched_rule": "allow:shell:ls *"
}
Field Present when
schema_version always — "v1".
decision always — ALLOW, DENY, or REQUIRE_APPROVAL.
reason always.
matched_rule always except the default-deny fall-through, where it is omitted.
approval_id / approval_url approval_url only when decision == REQUIRE_APPROVAL. approval_id then too, and also on the ALLOW / DENY a replayed approval returns (matched_rule allow:approved / deny:approved); omitted otherwise.

Response headers#

Header Unit What it measures
X-Vanzik-Policy-Ms ms (3-decimal) Time in Engine.Check.
X-Vanzik-Audit-Ms ms (3-decimal) Time in Logger.Log.
X-Vanzik-Total-Ms ms (3-decimal) End-to-end server processing.

Status codes#

Code Meaning
200 Policy evaluated (ALLOW / DENY / REQUIRE_APPROVAL — inspect body).
400 Malformed JSON or unsupported schema_version.
405 Method other than POST.
413 Body exceeds MaxRequestBodyBytes.
500 crypto/rand failure while generating an approval ID. Never silently returns a deterministic ID.
503 A REQUIRE_APPROVAL decision arrived while every approval-queue slot holds an unresolved approval: approval queue full; retry later, with Retry-After: 30.

Rate-limit denials return 200 with decision: "DENY" and matched_rule: "deny:ratelimit:<scope>" — clients should treat that as a logical deny, not a transport error.


POST /v1/approve/{id} · POST /v1/deny/{id} · tenant-aware mirrors#

Tenant-aware mirrors live at POST /v1/t/{tenant}/approve/{id} and POST /v1/t/{tenant}/deny/{id}. Auth posture and request body are identical.

Resolve a pending approval. Resolution is write-once: the first decision stands forever. Re-sending the same decision returns 200 (idempotent no-op, safe for retried requests); sending the opposite decision returns 409 with {"error":"already resolved","id":"ap_…","status":"approved"|"denied"} and mutates nothing. A wrong decision is corrected by re-running the action through /v1/check (fresh approval id) — never by editing history. Each resolution also records how it arrived (resolved_via: bearer / session / open, plus the peer host) for incident reconstruction, and the first one writes an audit entry with matched_rule: approval:resolved:<via>.

Request (Bearer)#

POST /v1/approve/ap_1a2b…890 HTTP/1.1
Authorization: Bearer $KEY

Request (session + CSRF)#

POST /v1/approve/ap_1a2b…890 HTTP/1.1
Cookie: vanzik_session=…; vanzik_csrf=xyz…
X-CSRF-Token: xyz…

Response#

{ "id": "ap_1a2b…", "status": "approved" }    // or "denied"

Status codes#

Code Meaning
200 Resolved.
401 Missing Bearer or invalid session.
403 Session valid but CSRF token missing/mismatched.
404 No such approval ID.
409 Already resolved with the opposite decision (write-once). Body carries the standing decision: {"error":"already resolved","id":"ap_…","status":"approved"|"denied"}.

GET /v1/status/{id} · tenant-aware mirror#

Tenant-aware mirror: GET /v1/t/{tenant}/status/{id}. Auth posture and response body are identical.

Poll for approval resolution. This is what guard.wait_for_approval / guard.waitForApproval hits.

Response#

{ "id": "ap_1a2b…", "status": "pending" }                              // still pending

{ "id": "ap_1a2b…", "status": "resolved", "decision": "ALLOW" }        // resolved
Field Present when
id always.
status always — "pending" or "resolved".
decision only when status == "resolved" — "ALLOW" or "DENY".

status: "pending" → still pending. Poll every 2 s (SDK default). The original approval request and created_at are not returned here — use GET /api/pending for those.

Status codes#

Code Meaning
200 Found.
401 Unauthenticated.
404 No such approval ID (possibly evicted or lost to restart).

GET /v1/audit · tenant-aware mirror#

Tenant-aware mirror: GET /v1/t/{tenant}/audit. Auth posture and query parameters are identical. The tenant-aware URL is scoped to its tenant's entries; the legacy /v1/audit serves the local tenant.

Query the audit log.

Query parameters#

Param Type Default Notes
agent_id string — Exact match.
session_id string — Exact match.
decision string — ALLOW, DENY, REQUIRE_APPROVAL.
scope string — Exact match on request.scope.
limit int auditDefaultLimit (default 100) Silently clamped at auditMaxLimit (default 1000). <1 or non-integer → 400.
offset int 0 Skip N matching entries. Must be ≥ 0. With order=desc it counts from the newest entry.
order string asc asc returns the oldest matches first; desc returns the newest first. Anything else → 400.
parent_session_id string — Exact match on request.parent_session_id: the calls of the sessions started by that session.
since RFC 3339 time — Entries at or after this time (inclusive). Any time zone; compared as an instant. In a query string a + means a space, so write a positive offset as %2B (2026-10-14T11:00:00%2B02:00); an unencoded + before the offset is still read as one. The same applies to every since/until below.
until RFC 3339 time — Entries before this time (exclusive). until before since, or a value that is not RFC 3339, → 400.
transport string — Filter on the Entry.Transport audit field. Recognised values: sdk, mcp_gateway, llm_api_proxy, and the coding-agent hooks' claude_code, cursor, gemini_cli, codex_cli. Entries with no transport tag count as sdk: transport=sdk matches them, any other value leaves them out.

Response#

Array of entries (JSON Lines rows as JSON objects). A query that matches nothing returns null, not []; treat both as an empty result. On a sealed log the entries also carry seq, prev_hash and hash (FILE_FORMATS):

[
  {
    "timestamp": "2026-04-19T12:03:44Z",
    "agent_id": "researcher-01",
    "request": {"scope":"shell","command":"rm -rf /","agent_id":"researcher-01"},
    "result":  {"decision":"DENY","reason":"…","matched_rule":"deny:shell:…"}
  }
]

Pagination#

Use limit + offset for pagination. The default order is oldest first; add order=desc for the most recent entries:

# the 100 most recent decisions
curl -s -H "Authorization: Bearer $K" "http://localhost:8080/v1/audit?order=desc&limit=100"

# stable export pages, oldest first
curl -s -H "Authorization: Bearer $K" "http://localhost:8080/v1/audit?limit=100&offset=200"

For large exports, prefer curl over the CLI subcommand — the CLI doesn't expose offset. With order=desc, pages shift as new entries arrive; use the default order for exports.

To page by time, use since and until. since is inclusive and until is exclusive, so back-to-back windows such as [09:00, 10:00) and [10:00, 11:00) return every entry once. Both compare to the nanosecond, on both audit backends:

# one hour of decisions, oldest first
curl -s -H "Authorization: Bearer $K" \
  "http://localhost:8080/v1/audit?since=2026-10-14T09:00:00Z&until=2026-10-14T10:00:00Z&limit=1000"

If a window holds more than limit entries, page inside it with offset, or start the next request at the last entry's timestamp plus one nanosecond.


GET /v1/audit/head · tenant-aware mirror#

Tenant-aware mirror: GET /v1/t/{tenant}/audit/head. Same auth as GET /v1/audit: Bearer or session.

Returns the head of the sealed audit log (vanzik server --audit-seal): the newest sealed entry's seq and hash, the segment_id of the file that holds it, and when the head was read. Copy it somewhere the audit host can't rewrite; vanzik audit verify --anchor <seq>:<hash> checks it later. See OPERATIONS § Sealing and anchoring.

{"segment_id":"5f3d3bd62a0c92261d2c7de09991f4c0","seq":1200,"hash":"3f9a…","at":"2026-10-13T17:20:00Z"}

seq is 0 and hash empty until the first entry is sealed. With the buffered audit logger (the default), entries still in its queue are not in the head yet. The chain covers the whole audit file, every tenant included, so the tenant mirror returns the same head.

Code Meaning
200 The head.
401 Missing/invalid Bearer token or session.
404 Sealing is off ({"error":"audit sealing is off; …"}), or a tenant-aware route with an unregistered tenant.

GET /v1/sessions · tenant-aware mirror#

Tenant-aware mirror: GET /v1/t/{tenant}/sessions. Same auth as GET /v1/audit: Bearer or session.

Lists the tenant's sessions: the audit entries grouped by session_id, newest first. Entries without a session_id are not part of any session. The dashboard's Sessions view is built on this route.

Param Type Default Notes
since RFC 3339 time 24 hours ago Count entries at or after this time.
until RFC 3339 time — Count entries before this time (exclusive).
agent_id string — Sessions in which this agent made a call in the window.
parent_session_id string — Sessions started by this session.
limit int 100 Silently clamped at 1000. <1 or non-integer → 400.

A session's summary covers its entries in the window. agent_id and parent_session_id pick sessions; they don't trim them.

{
  "since": "2026-10-13T10:00:00Z",
  "sessions": [
    {
      "session_id": "sub-7",
      "parent_session_id": "orch-1",
      "agents": [{"agent_id": "claude-code", "subagent": "Explore"}],
      "transports": ["claude_code"],
      "first_seen": "2026-10-14T09:12:03.5Z",
      "last_seen": "2026-10-14T09:14:40Z",
      "counts": {"total": 12, "allow": 10, "deny": 1, "require_approval": 1}
    }
  ],
  "truncated": false
}
  • since and until echo the window that was searched (until is omitted when open).
  • truncated is true when the window held more sessions than the file backend keeps in memory while it scans (50,000, or ten times limit if that is more). The list then has the sessions most recently written to, each counted in full; older sessions, and with agent_id or parent_session_id some matching ones, may be missing. Ask for a narrower since/until. Session ids come from /v1/check callers, so a client can make this happen by sending a new id on every call. The store backend never truncates.
  • agents holds at most 32 entries and transports at most 16, the first ones seen; lists_truncated: true says some were left out. The counts always cover every entry.
  • agents lists each distinct agent_id and meta.subagent pair (the Claude Code hook sets meta.subagent for calls made by a sub-agent).
  • parent_session_id is the parent named by the session's first entry that names one.
  • Entries with no transport count as sdk.

The file audit backend scans the live audit file (not rotated archives), like GET /v1/audit: one pass, or two with agent_id or parent_session_id, plus one more when it truncated. The store backend aggregates in SQL. Neither runs on the /v1/check path.

Code Meaning
200 The list (possibly empty).
400 Bad since, until or limit.
401 Missing/invalid Bearer token or session.
404 Tenant-aware route with an unregistered tenant.

GET /v1/sessions/{id} · tenant-aware mirror#

Tenant-aware mirror: GET /v1/t/{tenant}/sessions/{id}. Same auth as GET /v1/audit.

Returns one session: its summary (same shape as in the list), the sessions it started, and its entries oldest first. URL-encode the id: session ids are chosen by the caller and may contain / or spaces (sub%2F1). The ids . and .. can't be read this way, because clients and the server collapse them as path segments; read such a session with GET /v1/audit?session_id=..

Param Type Default Notes
since RFC 3339 time — Narrow the summary, children and entries to entries at or after this time.
until RFC 3339 time — ...and before this time (exclusive).
limit int 1000 Max entries returned. Silently clamped at 5000.
{
  "session": {"session_id": "orch-1", "agents": [...], "transports": ["sdk"], "first_seen": "...", "last_seen": "...", "counts": {...}},
  "children": [{"session_id": "sub-7", "parent_session_id": "orch-1", ...}],
  "children_truncated": false,
  "entries": [{"timestamp": "...", "session_id": "orch-1", "request": {...}, "result": {...}}],
  "truncated": false
}

truncated is true when the session has more entries than limit. Fetch the rest with since set to the last entry's timestamp plus one nanosecond, or with GET /v1/audit?session_id=…&offset=…. children holds at most 1000 sessions, newest first; children_truncated is true when there are more, or when the file backend stopped tracking them (as truncated on the list). The file backend reads the live audit file once, and a second time only when the session has children.

Code Meaning
200 The session.
400 Bad since, until or limit, or no id.
401 Missing/invalid Bearer token or session.
404 The tenant has no entry for this session in the window ({"error":"no audit entries for this session; …"}), or an unregistered tenant. Another tenant's session is a 404 too.

POST /v1/audit · POST /v1/t/{tenant}/audit#

Records a DENY that was decided outside the policy engine, without running the engine. vanzik-llm-proxy uses it when it refuses a tool call on its own — for example deny:llm_api_proxy:malformed_tool_call — so the central audit log records the verdict the client actually received.

Request#

POST /v1/audit HTTP/1.1
Authorization: Bearer $KEY
Content-Type: application/json

{
  "schema_version": "v1",
  "rule": "deny:llm_api_proxy:malformed_tool_call",
  "reason": "malformed tool call arguments — refused",
  "request": {"scope": "shell", "command": "bash", "agent_id": "llm-proxy", "meta": {"transport": "llm_api_proxy"}}
}
Field Type Required Notes
rule string yes Recorded as matched_rule. Empty or blank → 400 rule is required.
reason string no Recorded as reason.
request object no The action the refusal was for, in the /v1/check request shape. meta.transport sets the entry's transport tag (default sdk).
schema_version string no Defaults to "v1"; any other value → 400.

The decision is always DENY: the endpoint never takes a decision from the caller (a "decision" field in the body is ignored), so it cannot write an ALLOW into the trail.

Response#

Same shape as /v1/check. The entry goes through the same audit-write path, so it also appears on /api/stream as a check event and counts toward the decision metrics.

{"schema_version":"v1","decision":"DENY","reason":"malformed tool call arguments — refused","matched_rule":"deny:llm_api_proxy:malformed_tool_call"}

Status codes#

Code Meaning
200 Recorded.
400 Malformed JSON, unsupported schema_version (JSON error body), or missing rule.
401 Missing/invalid Bearer token or session.
403 Session valid but X-CSRF-Token missing or mismatched.
404 Tenant-aware route with an unregistered tenant: {"error":"tenant not found"}.
413 Body exceeds MaxRequestBodyBytes.

POST /auth/login#

Exchange an API key for a browser session.

Request#

{ "api_key": "<key>" }

Response#

{
  "csrf_token": "xyz…",
  "expires_at": "2026-04-19T13:00:00Z"
}

Sets cookies:

Cookie Flags Purpose
vanzik_session HttpOnly, Secure (if TLS or --tls-terminated-upstream), SameSite=Strict, Path=/ Opaque session ID.
vanzik_csrf Secure (same rules), SameSite=Strict, Path=/ — not HttpOnly so JS can read it Double-submit CSRF token.

Status codes#

Code Meaning
200 Session created.
400 Body isn't valid JSON (or exceeds 4 KiB): invalid request.
401 Invalid API key.
503 MaxSessions=1024 reached (Retry-After: 5), or the server runs without --api-key, where login is disabled: login disabled: server has no API key configured.

Constant-time compare is used on the submitted key (subtle.ConstantTimeCompare).


POST /auth/logout#

Destroys the current session and expires both cookies.

POST /auth/logout HTTP/1.1
Cookie: vanzik_session=…

Returns 204 No Content. Safe to call with no session (no-op).


GET /health#

{ "status": "ok", "version": "2.0.0" }

Always 200 once the HTTP server is accepting connections. Use for liveness probes (see DEPLOYMENT.md). For the richer operator probe see /v1/health below.


GET /v1/health · GET /v1/t/{tenant}/health#

Operator-grade health endpoint. Richer than /health: includes the resolved tenant, uptime, last-request and last-policy-load timestamps, and a warnings array.

Response#

{
  "status": "ok",
  "version": "2.0.0",
  "tenant": "local",
  "last_request_at": "2026-05-05T19:04:54.646Z",
  "last_policy_load_at": "2026-05-05T19:04:53.549Z",
  "uptime_seconds": 1,
  "warnings": []
}
Field Notes
status "ok" or "degraded". Degraded means the process serves but a durability signal is unhealthy — currently an audit buffered-overflow backlog (entries durable on disk but not yet queryable). The HTTP code stays 200 either way so liveness probes don't flap; act on the field, not the code.
tenant Echoes the resolved tenant — "local" on /v1/health, {tenant} from the path on the tenant-aware variant.
last_request_at RFC 3339 with millisecond precision; omitted (undefined) when no traffic since boot.
last_policy_load_at RFC 3339 with millisecond precision; stamped by the engine on every successful provider load.
warnings "no traffic in 5m+", "policy not reloaded in 24h+", plus metrics-derived signals: "audit: N corrupt line(s) skipped during queries", "audit: N entry(ies) in buffered overflow backlog", "notify: N notification(s) dropped (queue_full)". The metrics-derived signals are process-wide, not per-tenant.

Status codes#

Code Meaning
200 Tenant resolved; body returned.
404 Tenant unknown (only on the /v1/t/{tenant}/health variant; the tenant must be local or registered via vanzik tenant put). Body: {"error":"tenant not found"}.

GET /metrics#

Prometheus text exposition format. Unauthenticated. Full metric catalog in OBSERVABILITY.md.

curl -s http://localhost:8080/metrics | head
# HELP vanzik_checks_total Total number of /v1/check requests processed.
# TYPE vanzik_checks_total counter
# vanzik_checks_total 12034
# ...

GET /api/pending#

JSON array of unresolved approvals. Same auth as other dashboard endpoints.

[
  {
    "id": "ap_1a2b…",
    "request": {"scope":"shell","command":"rm -rf /","agent_id":"researcher-01"},
    "created_at": "2026-04-19T12:03:44Z",
    "resolved": false
  }
]

GET /api/stats#

O(1) atomic counter snapshot. Cheap; safe for per-page-load polling.

{
  "total": 12034,
  "allowed": 10998,
  "denied": 842,
  "approvals": 194
}

For the pending-approval count, use the length of GET /api/pending.

Prefer /api/stream (SSE) for real-time deltas — only call /api/stats on page load and on SSE reconnect.


GET /api/stream#

Server-Sent Events. Used by the dashboard for live updates.

GET /api/stream HTTP/1.1
Authorization: Bearer $KEY
Accept: text/event-stream

Response headers set:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no

Events:

data: {"type":"check","timestamp":"2026-04-19T12:03:44Z","request":{…},"result":{…}}\n\n
type Fires on
check Every response from /v1/check (any decision, including rate-limit denies) and every POST /v1/audit record. REQUIRE_APPROVAL and DENY decisions arrive as check events — read result.decision.
resolved The first approve / deny of a pending approval. result.decision is ALLOW or DENY, result.reason is manually allow / manually deny. Repeating the same resolution does not send it again.

Events are unnamed SSE messages (no event: field), so an EventSource receives them through onmessage. Each carries transport (sdk, mcp_gateway, llm_api_proxy, or a coding-agent hook's claude_code, cursor, gemini_cli, codex_cli) and, for tenants other than local, tenant, alongside request and result. approval_required and denied are notifier event types (webhook / Slack / console), not stream events — see APPROVAL_WORKFLOW.md.

Subscriber channel buffer is 64 (SSEChannelBufferSize). Slow consumers drop events — counted in vanzik_sse_events_dropped_total{reason="slow_consumer"}.

The client must handle disconnects. There is no server-side auto-reconnect; use EventSource in the browser (which auto-reconnects) or handle io.EOF in a custom consumer and refetch /api/stats on reconnect.


GET /dashboard#

Renders loginHTML if no valid session, else dashboardHTML. Sets security headers:

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Cache-Control: no-store

The dashboard JS loads /api/stats, /api/pending, /v1/audit?order=desc&limit=200 (the newest 200 entries), and subscribes to /api/stream. CSRF token is read from document.cookie['vanzik_csrf'] and echoed as X-CSRF-Token on approve/deny.


CORS#

withCORS middleware reflects Origin:

  • Strict mode (--allowed-origin https://app.example): only the exact origin is reflected.
  • Permissive-localhost mode (default, --allowed-origin unset): reflects any http://localhost:* / http://127.0.0.1:* origin. The trailing : is mandatory — localhost.evil.com does not match.

Vary: Origin is always set. OPTIONS preflight short-circuits 204.

Safe because session cookies are SameSite=Strict and state-changing endpoints require X-CSRF-Token.


Error envelope#

Most error responses are text/plain from http.Error (e.g. "Method not allowed\n"). The structured JSON envelope is used by:

  • Tenant-routing 404 (/v1/t/<unknown>/<any>): {"error":"tenant not found"}
  • Tenant-routing 500 (provider infrastructure failure): {"error":"policy provider error"}
  • Health 404 (/v1/t/<unknown>/health): {"error":"tenant not found"}
  • Recovered panic 500: {"error":"internal server error"}
  • /v1/check and POST /v1/audit schema mismatch 400: {"error":"unsupported schema_version; expected v1","received":"v2"}

Other error responses (auth 401/403, /v1/check 400 for bad JSON, audit 400 for bad ?limit) are plain-text and may differ between releases — programmatic clients should rely on the HTTP status code rather than message-text matching.


Common error patterns#

Symptom Cause Fix
401 on every gated endpoint Server has --api-key; client sending none. Set Authorization: Bearer … or log in.
403 on POST approve/deny from dashboard Session valid, CSRF missing. Echo document.cookie['vanzik_csrf'] as X-CSRF-Token.
404 on every /v1/t/<x>/... Tenant <x> not registered. Register it (vanzik tenant put <x> --policy <yaml>, requires --persist) or use /v1/... / /v1/t/local/....
413 on /v1/check Body > 1 MB. Trim meta payloads or raise proxy.request.max_body_bytes.
503 on /auth/login 1024 concurrent sessions. Probing bot; firewall it.
SSE connection drops every minute Idle proxy timeout upstream. Set proxy_read_timeout 3600s on reverse proxy.

See TROUBLESHOOTING.md for the full symptom-keyed catalog.


SDK tenant_id (v0.5+)#

Both SDKs accept an optional tenant identifier that switches every HTTP call to the tenant-aware URL family.

Python:

from vanzik import Guard

# Default — legacy /v1/... URLs.
g = Guard("http://localhost:8080", agent_id="my-agent")

# Tenant-aware — /v1/t/acme/... URLs.
g = Guard("http://localhost:8080", agent_id="my-agent", tenant_id="acme")

# Env var fallback. Empty string explicitly suppresses the env var.
# Env: VANZIK_TENANT_ID=acme
g = Guard("http://localhost:8080", agent_id="my-agent")  # → /v1/t/acme/...
g = Guard("http://localhost:8080", agent_id="my-agent", tenant_id="")  # → /v1/...

tenant_id="local" is treated as an alias for the legacy URL family.

TypeScript:

import { Vanzik } from "@lictorate/vanzik";

// Default — legacy /v1/... URLs.
const local = new Vanzik({ baseUrl: "http://localhost:8080", agentId: "my-agent" });

// Tenant-aware — /v1/t/acme/... URLs.
const acme = new Vanzik({
  baseUrl: "http://localhost:8080",
  agentId: "my-agent",
  tenantId: "acme",
});

Same env-var fallback (VANZIK_TENANT_ID) and same "local" alias semantics as Python.