Skip to content

API conventions

These rules hold for every Deedglass endpoint. The code that enforces them is internal/httpx; the machine-readable description is api/openapi.yaml (OpenAPI 3.1.1), served at GET /api/openapi.yaml.

Where Base URL
Local (make dev, deedglass serve) http://127.0.0.1:8080
Cloud beta https://api.deedglass.com

Endpoints live under /v1. The OpenAPI document is the one exception, at /api/openapi.yaml. The server speaks plain HTTP; in production, TLS terminates at a proxy in front of it.

Terminal window
curl -si http://127.0.0.1:8080/v1/health

Each endpoint declares one of four kinds of authentication. The OpenAPI document shows it as x-deedglass-auth.

Kind What it accepts
public Nothing needed. Health, version and the OpenAPI document.
agent_key An API key.
session A Console session.
session_or_key A Console session or an API key.

API keys go in the Authorization header:

Authorization: Bearer dg_live_…

Test-mode keys start with dg_test_. Any other scheme on an endpoint that takes keys is 401 invalid_api_key. A key can also need scopes (actions:execute, actions:read, deeds:read, admin, actions:undo); a key without one gets 403 insufficient_scope.

Console sessions use the __Host-deedglass_session cookie, plus CSRF protection: the __Host-deedglass_csrf cookie, echoed in the Deedglass-CSRF header. The Console handles this for you; the cookie is __Host- prefixed, so it only travels over HTTPS to the exact host.

API keys arrive in S08 and Console sessions in S07. Until then, every endpoint that isn’t public answers 401 authentication_required. It never lets a request through without credentials.

When a request sends a Bearer token to an endpoint that takes keys, Deedglass checks the key and doesn’t fall back to the session cookie. Endpoints that take keys send WWW-Authenticate: Bearer realm="deedglass" with their 401 answers.

Send Deedglass-Version: 2026-10-01 to choose a dated version, or leave it out to use your org’s pinned version (the latest, for public endpoints). Every response echoes the version that applied. Errors raised before the version check (404 not_found, 405 method_not_allowed, 413 request_too_large, 415 unsupported_media_type and 401 answers) echo the version you sent if it is valid, else the latest. A pinned version never changes behaviour; breaking changes ship only under a new date. The rules and the changelog are in Versions.

Every error has the same body:

{
"error": {
"type": "invalid_request_error",
"code": "validation_failed",
"message": "`limit` must be between 1 and 100.",
"param": "limit",
"request_id": "req_01j9zr3m5k8q2w7x4t6v0b1c9d",
"doc_url": "https://docs.deedglass.com/errors/validation_failed"
}
}

type is one of six: invalid_request_error, authentication_error, permission_error, policy_error, rate_limit_error and api_error. code is one of 41 codes; branch on it, not on message. param is always present, and null when no single field is at fault. Every code has a section in Errors, which is where doc_url points. Two codes add a field: policy_invalid carries diagnostics, and a refusal of POST /v1/actions carries the sealed action (Actions and the Gate).

Unknown paths and wrong methods get the same envelope: 404 not_found (with a suggestion when a path is two edits or fewer from a fixed endpoint path) and 405 method_not_allowed (with an Allow header). A panic in a handler is a 500 internal_error; the stack goes to the server log and never into the body.

Send an Idempotency-Key with a POST, and retrying it is safe: the work runs at most once, and every retry gets the first response back.

Terminal window
curl -s -X POST https://api.deedglass.com/v1/agents \
-H 'Authorization: Bearer dg_live_…' \
-H 'Idempotency-Key: 5b0c6d2e-8f4a-4c1e-9a53-2f7d1e0b8c44' \
-H 'Content-Type: application/json' \
-d @agent.json

The key. 1 to 255 printable ASCII characters, sent once; a UUID works. Anything else is 400 idempotency_key_invalid. Each endpoint says whether a key is optional or required; a required key that is missing gets the same code. Only POST requests use keys; other methods ignore the header.

The window and the scope. A key is remembered for 24 h from its first use. It is scoped to your org and the principal that sent it:

  • the agent (agt_…) for an agent’s API key
  • the person (usr_…) for a Console session
  • the key itself (key_…) for an org admin key

Two agents can use the same key string without affecting each other. Rolling an agent’s API key keeps its stored responses, so a retry sent with the new key still replays. After 24 h the key is treated as new.

The fingerprint. Deedglass hashes the method, the route pattern, the path and the body bytes (SHA-256). A retry must match all four. Bodies are compared byte for byte, so the same JSON with different spacing or member order counts as a different request.

What happens on a retry:

Situation Answer
First request with the key The handler runs; the response is stored, then sent.
Same key, same request, first one finished The stored status and body, plus Deedglass-Idempotent-Replayed: true. The handler doesn’t run.
Same key, same request, first one still running 409 idempotency_key_in_use, with Retry-After: 1.
Same key, different request 422 idempotency_conflict, with param: "Idempotency-Key".
The first request’s lock ran out (its timeout plus 5 s) The retry takes over and runs; the first attempt is presumed dead.

What is stored. Every completed response, 4xx and 5xx included, so a retry never runs a side effect twice. A handler that panics is stored as the same 500 the client got. Nothing is stored when:

  • the key was in use (409 idempotency_key_in_use)
  • the handler answered 429 or 503: those mean the work didn’t run, so a retry runs it
  • the response body was larger than 1 MiB: the key is released, and a retry runs again
  • the request was refused before its handler ran (credentials, version, body size, content type, a bad key)

A timeout is a completed response too. If the handler runs past its timeout, the 504 timeout is stored, and a retry with the same key replays that 504 for 24 h without running the work again. The work may have partly run: fetch the object to see its state, and send a new key if you need to run it again.

What a replay carries. The stored status, body, and these headers when the first response had them: Content-Type, Retry-After, Allow, Location and WWW-Authenticate. It also carries Deedglass-Idempotent-Replayed: true, a fresh Deedglass-Request-Id for the retry, and the Deedglass-Version the retry resolved. The body is the stored bytes, so a replayed error envelope keeps the first request’s request_id, while the Deedglass-Request-Id header is the retry’s own. Quote the header’s id for the retry, and the body’s id for the original request.

Responses with a secret. Some endpoints show a secret once, such as creating or rolling an API key (S08). They are marked Sensitive. Their response is stored with the secret field set to null, so the secret never reaches the database. A replay returns the stored object with "secret": null and Deedglass-Idempotent-Replayed: true: you learn the key’s id, and can roll or revoke it.

Public endpoints. A public POST has no principal to scope a key to. Deedglass checks the key’s format, then ignores it.

The one exception. POST /v1/actions doesn’t use this mechanism. From S13 the Gate owns its idempotency, keyed on your org, the agent and the key. There, a duplicate that arrives while the first is running waits for the original result instead of getting 409. Replays and conflicts answer the same way as above: Deedglass-Idempotent-Replayed: true, and 422 idempotency_conflict.

Every list has one shape:

{
"object": "list",
"data": [],
"has_more": false,
"next_cursor": null
}
  • data is always an array, never null.
  • limit is 1–100 and defaults to 20. Anything else, including an empty ?limit=, is 400 validation_failed with param: "limit".
  • When has_more is true, next_cursor holds a string. Pass it back as ?cursor= for the next page. When has_more is false, it is null.
  • A cursor is opaque. Don’t build or edit one; pass the value you got back unchanged. A cursor this API didn’t issue is 400 validation_failed with param: "cursor".

Cursors are unsigned on purpose. Row-level security scopes every query to your org, so a crafted cursor can only move within data you can already read.

The Ledger lists (GET /v1/actions, GET /v1/deeds) default to 25 and bind their cursor to the filters it was issued with (LEDGER.md §3).

Route Scope Returns
GET /v1/actions actions:read a list of action objects, newest first, with filters
GET /v1/actions/{id} actions:read the action with request, result, witness.detail, events, deeds, approval; ?wait= long-polls (S13)
GET /v1/deeds deeds:read a list of deed envelopes by seq
GET /v1/deeds/{id} deeds:read the stored canonical bytes; ETag = body_hash; Accept: application/cose for COSE_Sign1
GET /v1/deeds/export deeds:read a streamed export bundle (application/gzip)
GET /v1/ledger/check deeds:read a ledger_check (intact, broken or incomplete)

All six are SessionOrKey, viewer and up. Since S07 they take a Console session (the session cookie; GETs need no CSRF header); API keys arrive in S08. Without either the answer is 401 authentication_required. Examples, filters and error copy: LEDGER.md. The schemas are in api/openapi.yaml (Action, ActionDetail, ActionEvent, ActionList, DeedList, LedgerCheck).

Terminal window
curl -s -b cookies.txt 'http://127.0.0.1:8080/v1/actions?verdict=FAILED'
{ "error": { "type": "invalid_request_error", "code": "validation_failed",
"message": "verdict must be VERIFIED, MISMATCH or UNVERIFIABLE. You sent \"FAILED\".",
"param": "verdict", "request_id": "req_01j9zv…", "doc_url": "https://docs.deedglass.com/errors/validation_failed" } }

POST /v1/deeds/files seals the admin deed file.sealed from a file’s sha256, bytes, media_type (application/pdf, text/markdown, application/json, text/plain or text/html) and label (1 to 120 characters, no line breaks). The file itself never reaches the server: deedglass deeds seal-file hashes it locally. An admin+ session or an org admin key; agent keys get 403 insufficient_scope, viewers 403 insufficient_role. Idempotency-Key is optional; the body is at most 4 KiB. The answer is 201 with a sealed_file object: the four fields and deed, the deed envelope. The deed carries the label as admin.reason and data_digest over the four fields, so deedglass verify <deed.json> --file <path> checks the file offline. It seals the file’s hash, size, type and label. It says nothing about whether the file’s contents are right. SEALING_FILES.md.

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/deeds/files -H "Authorization: Bearer $DEEDGLASS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"sha256":"sha256:3f9a…","bytes":214388,"media_type":"application/pdf","label":"Full scan report · Acme"}'

Every tool call an agent makes goes through POST /v1/actions, and every action the Gate records ends as a sealed deed. How the Gate works inside is in GATE.md; this section is the API.

Route Who Returns
POST /v1/actions an agent key with actions:execute the action; a refusal is an error envelope with the sealed action in it
GET /v1/actions/{id}?wait=0..25 actions:read (the agent’s own actions), or a Console session the action, after a long poll
POST /v1/actions/{id}/cancel the agent’s own key, an admin+ Console session or an org admin key the action, cancelled and sealed; only from pending_approval
POST /v1/actions/{id}/result the same agent’s key the action, witnessed and sealed; proxy mode only, once

The Gate answers with the same action object as the Ledger, so an agent, the CLI and the Console read one shape. The fields an agent needs first:

Field What it says
status What happened, in one word (the table below). Branch on it.
state The exact lifecycle state (GATE.md, the state machine).
livemode true for a dg_live_ key, false for a dg_test_ key. Recorded, not enforced.
decision The policy’s answer: outcome, code, source (rule, default, budget, stop or builtin), rule_id, reason, the message the agent sees (verbatim), the policy and policy_version that decided, and the shadow decision when a shadow policy is active.
approval The approval it waits for, or waited for: id, status, expires_at. null when no rule asked for one.
execution status (succeeded, failed or unknown), the times, error_code, error_message, and, on the Gate’s own responses only, result.
witness level, verdict, method and reasons. In v1 the level is L0 and the method is reported (the tool reported success) or edge_reported (the edge did, in proxy mode). L0 is not an independent check.
next_action What to do next: await_approval (with poll_url) or execute_at_edge (with report_url and deadline). null otherwise.
deed The deed’s id once the action is sealed.

A client that only checks for 2xx never mistakes “not run” for “done”: every refusal is 4xx.

status HTTP on POST /v1/actions Meaning
executed 200 The tool ran and the action is sealed, whatever the verdict.
processing 202 Still executing or witnessing when wait ended. Poll GET /v1/actions/{id}?wait=25.
authorized 200 Proxy mode: run the tool at the edge now, then report the result.
pending_approval 202 Waiting for a person. next_action.poll_url waits for the decision.
denied 403 or 429, with error.action A policy, a budget or Stop refused it. Nothing ran. The refusal is sealed.
failed 200 The tool certainly didn’t apply the call (it refused it, or it was never sent).
expired (later) No one decided before the approval expired.
cancelled (later) Cancelled while it waited for approval.

The refusal codes: policy_denied and policy_unavailable (403 policy_error), budget_exceeded (429 rate_limit_error, with Retry-After), agent_stopped, agent_suspended and org_stopped (403 permission_error), and tool_unknown (404). A malformed request is 400 validation_failed, and an identity failure (intent_required, intent_invalid, intent_replay) is 403: neither creates an action. Every code is in Errors.

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/actions \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" \
-H 'Idempotency-Key: 7d1e0b8c-44a2-4c1e-9a53-5b0c6d2e8f4a' \
-H 'Content-Type: application/json' \
-d '{"tool":"fake.echo","args":{"message":"hello"},"wait":10}'
{
"object": "action",
"id": "act_01k6w3p8r2t4v6x8z0b2d4f6h8",
"livemode": false,
"tool": "fake.echo",
"mode": "gateway",
"state": "sealed",
"status": "executed",
"decision": { "outcome": "allow", "code": null, "source": "rule", "rule_id": "allow-fake", "message": null, "…": "…" },
"approval": null,
"execution": { "status": "succeeded", "latency_ms": 1, "error_code": null, "result": { "echo": { "message": "hello" } }, "…": "…" },
"witness": { "level": "L0", "verdict": "VERIFIED", "method": "reported", "reasons": [] },
"request_digest": "sha256:c2cb9a8c51c029f97049fed02f985cc49cee22836423d2c9a8e1b74e333a3c4c",
"deed": "deed_01k6w3p9a1c3e5g7j9m1q3s5v7",
"next_action": null,
"…": "…"
}

wait (0 to 25 seconds, default gate.wait_timeout, 10 s) is how long the request waits for the tool. If the tool is still running when it ends, the answer is 202 processing; the action keeps running, and it is recorded and sealed whether or not you are still waiting.

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/actions \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"tool":"fake.write","args":{"rows":301,"table":"orders"}}'
{
"error": {
"type": "policy_error",
"code": "policy_denied",
"message": "Blocked by policy \"no-bulk-writes\": writes over 100 rows need a person, not an agent.",
"param": null,
"request_id": "req_01k6w4a3d5f7h9k1n3q5s7v9x1",
"doc_url": "https://docs.deedglass.com/errors/policy_denied",
"action": {
"object": "action",
"id": "act_01k6w4a2c4e6g8j0m2p4r6t8v0",
"state": "sealed",
"status": "denied",
"decision": { "outcome": "deny", "code": "policy_denied", "source": "rule", "rule_id": "no-bulk-writes", "…": "…" },
"deed": "deed_01k6w4a4e6g8j0m2p4r6t8w0y2",
"…": "…"
}
}
}

The message is the decision’s own sentence, verbatim. error.action is the refusal as the Ledger recorded it, so the agent can name the deed.

In proxy mode the edge (S14’s MCP wrapper, or your own code) runs the tool and Deedglass records what it reports. First ask:

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/actions \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" \
-H 'Idempotency-Key: 2f7d1e0b-8c44-4a2c-9a53-5b0c6d2e8f4a' \
-H 'Content-Type: application/json' \
-d '{"tool":"filesystem.write_file","mode":"proxy","connector_type":"mcp",
"args":{"path":"/tmp/deedglass-demo/allowed/notes.txt","content":"hello"}}'
{
"object": "action",
"id": "act_01k6w5b3d5f7h9k1n3q5s7w9y1",
"mode": "proxy",
"state": "executing",
"status": "authorized",
"next_action": {
"type": "execute_at_edge",
"report_url": "/v1/actions/act_01k6w5b3d5f7h9k1n3q5s7w9y1/result",
"deadline": "2026-10-05T04:30:10.903Z"
},
"…": "…"
}

Then run the tool, and report what it returned, once, before the deadline (gate.edge_result_timeout, 10 minutes by default):

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/actions/act_01k6w5b3d5f7h9k1n3q5s7w9y1/result \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"status":"succeeded","started_at":"2026-10-05T04:20:11.020Z","finished_at":"2026-10-05T04:20:11.061Z",
"result":{"content":[{"type":"text","text":"Wrote 5 bytes to /tmp/deedglass-demo/allowed/notes.txt"}],"isError":false}}'

The answer is the sealed action with witness.method: "edge_reported": the deed records what the edge said, not an independent check. Send result_digest instead of result when the result is too large to send. A second report, or one after the deadline, is 409 conflict; an action whose edge never reported is sealed UNVERIFIABLE with the reason edge_lost.

POST /v1/actions owns its idempotency (GATE.md, Idempotency). Send the same request with the same key again, and you get the original action back; the tool ran once.

Terminal window
curl -si -X POST http://127.0.0.1:8080/v1/actions \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" \
-H 'Idempotency-Key: 7d1e0b8c-44a2-4c1e-9a53-5b0c6d2e8f4a' \
-H 'Content-Type: application/json' \
-d '{"tool":"fake.echo","args":{"message":"hello"},"wait":10}'
HTTP/1.1 200 OK
Deedglass-Idempotent-Replayed: true
…
  • The key names one action of this agent for 24 hours. Another agent’s key is independent.
  • The comparison is the request digest (the tool, the arguments in their RFC 8785 form, the mode and the tool digest) plus Deedglass-On-Behalf-Of and Deedglass-Session. Spacing and member order don’t matter; a new signed intent doesn’t either.
  • A duplicate that arrives while the original is still running waits for it (up to its own wait) and answers with the same action.
  • The same key with a different request is 422 idempotency_conflict.
  • A replayed refusal answers with the same status and message; a budget’s Retry-After counts down to the same reset.

GET /v1/actions/{id}?wait=25 answers when the action is settled (sealed, or waiting for a person), when its state changes, or after 25 seconds, whichever comes first. An action already pending_approval when the wait starts waits for its next change, so next_action.poll_url is how a client waits for a person’s decision.

Terminal window
curl -s "http://127.0.0.1:8080/v1/actions/act_01k6w6c4e6g8j0m2p4r6t8w0y2?wait=25" \
-H "Authorization: Bearer $DEEDGLASS_API_KEY"
  • wait is 0 to 25 seconds, sent once; 0 answers at once. expand[]=request, result or events is accepted; the answer always carries all three.
  • For an agent key, a Stop ends the wait at once with agent_stopped (or org_stopped).
  • Each agent may hold 20 open waits; the 21st is 429 rate_limited with Retry-After: 1. The count is kept per server instance.
  • If the wait ends while nothing changed, the answer is the action as it stands. Poll again.

To cancel an action that is still waiting for a person:

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/actions/act_01k6w6c4e6g8j0m2p4r6t8w0y2/cancel \
-H "Authorization: Bearer $DEEDGLASS_API_KEY"

It answers with the action, cancelled and sealed. Once the action is executing, the answer is 409 conflict: “This action is already executing, so it can’t be cancelled.”

POST /v1/tools/resolve (an agent key with actions:execute, at most 500 tools) answers, for each tool an MCP server lists, whether the enforced policy denies every call to it from this agent. deedglass mcp wrap uses it to leave such tools out of tools/list (MCP.md). Nothing is recorded, and the answer never replaces the decision on POST /v1/actions.

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/tools/resolve \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" -H "Content-Type: application/json" \
-d '{"connector":"scratch","tools":[{"name":"read_text_file"},{"name":"move_file"}]}'
{
"object": "list",
"data": [
{ "object": "tool_visibility", "name": "read_text_file", "tool": "scratch.read_text_file",
"visibility": "visible", "rule_id": "allow-scratch-reads", "reason": null },
{ "object": "tool_visibility", "name": "move_file", "tool": "scratch.move_file",
"visibility": "hidden", "rule_id": null,
"reason": "No rule allows this tool, and the policy default is deny." }
],
"has_more": false,
"next_cursor": null,
"policy_version": "pv_01k6w4a3d5f7h9k1n3q5s7v9x1"
}

The first rule whose match covers <connector>.<name> decides; a rule with a when counts as visible (it can’t be decided ahead of time); with no covering rule, the default decides. Budgets, Stop and MCP annotations are ignored. connector_type defaults to mcp; digest is the wrapper’s pinned definition digest (optional). A malformed connector, name or digest, or more than 500 tools, is 400 validation_failed. When policies can’t be loaded, every tool is visible (the calls are still refused).

GET /v1/tools (an agent key with actions:execute) lists the Gate’s native tools (the ones /mcp/native serves), named <connector>.<tool>, minus the ones the active policy always denies to this agent; ?include_hidden=true adds those with visibility: hidden.

Terminal window
curl -s http://127.0.0.1:8080/v1/tools -H "Authorization: Bearer $DEEDGLASS_API_KEY"
{ "object": "list",
"data": [
{ "object": "tool", "name": "fake.echo", "connector": "fake", "title": "Echo",
"description": "Returns its arguments.",
"input_schema": { "type": "object", "properties": { "message": { "type": "string", "maxLength": 65536 } } },
"side_effect": "read", "visibility": "visible" } ],
"has_more": false, "next_cursor": null, "policy_version": "pv_01k6w4a3d5f7h9k1n3q5s7v9x1" }

POST /mcp/{connector} and POST /mcp/native are MCP Streamable HTTP endpoints: one JSON-RPC message per POST, POST only (GET and DELETE answer 405), sessionless, both MCP eras. They are documented in MCP.md; api/openapi.yaml describes them without modelling JSON-RPC. Their refusals before the message is read (401, 403, 413, 415, 429) are JSON-RPC errors whose data carries the usual code, doc_url and request_id.

Connectors turn an API into governed tools; their credentials are write-only (CONNECTORS.md, VAULT.md). Every route takes a Console session (viewers read, admins change) or an org admin key; agent keys get 403 insufficient_scope everywhere.

Terminal window
deedglass connector credentials set orders --from-env ORDERS_TOKEN # PUT /v1/connectors/{id}/credentials
{ "object": "credential", "connector": "con_01j9zq7c4m8x2v6t0r3n5k1h7d", "set": true, "kind": "bearer", "fields": ["token"],
"hint": "…9f2c", "version": 1, "kek_id": "kek:3f9a1c2b7d8e", "updated_at": "2026-10-06T05:14:02Z", "updated_by": "key_01j9…" }

No route returns a credential value. PUT …/credentials bodies are never logged and never stored for replay. A blocked base_url is 400 destination_blocked with param: "config.base_url". The nine routes, their objects and their errors are in api/openapi.yaml (tag Connectors).

Route Role Returns
GET /v1/policies viewer+ a list of policy objects by name
POST /v1/policies admin+ 201: the policy and its v1 (version), not active; 400 policy_invalid with diagnostics
GET /v1/policies/active viewer+ the policy_active_set: the org’s enforce and shadow
GET /v1/policies/{id} viewer+ a policy ({id} is a pol_ id or the name)
GET /v1/policies/{id}/versions viewer+ a list of policy_version objects, newest first, without source
POST /v1/policies/{id}/versions admin+ 201 a new policy_version; 200 the existing one when the content is identical
GET /v1/policies/{id}/versions/{v} viewer+ a policy_version with its source ({v} is a number or a pv_ id)
POST /v1/policies/{id}/versions/{v}/activate admin+ a policy_activation (pa_ id) with the sealed deeds
POST /v1/policies/{id}/versions/{v}/deactivate admin+ the ended shadow policy_activation with its deed
POST /v1/policies/lint viewer+ a policy_lint_result (200 even when the policy is invalid)
POST /v1/policies/evaluate viewer+ a policy_decision (the dry run; nothing is reserved)
POST /v1/policies/test viewer+ a policy_test_result

Every one takes a Console session (by role; POSTs need the Deedglass-CSRF header) or an org admin key (scope admin, acting as admin). Agent keys are refused on every policy route, reads included (403 insufficient_scope: “Agents cannot read or test policies. Use the Console or an admin key.”), so an agent can’t probe its own policy for gaps. The changing POSTs accept Idempotency-Key; lint, evaluate and test change nothing and take 60 requests a minute per person or key, a 2 s timeout and bodies up to 1 MiB (2 MiB for test). Request and response shapes, with examples: api/openapi.yaml (tag Policies) and POLICIES_GUIDE.md.

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/policies/postgres-safe/versions/1/activate \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" -H "Idempotency-Key: pilot-enforce-1" \
-H "Content-Type: application/json" \
-d '{"mode":"enforce","reason":"Guard the orders database before the pilot."}'
Route Who Returns
GET /v1/overview?window=24h|7d a Console session (viewer and up) or an org admin key; agent keys get 403 insufficient_scope an overview: action, block and verdict counts with their series, pending approvals, the 8 most recently active agents with today’s usage, and up to 8 attention_items
GET /v1/agents?expand[]=usage, GET /v1/agents/{id}?expand[]=usage as the agent routes each agent plus usage (calls_minute, calls_hour, calls_today, spend_today) and last_action_at

Every number, window and attention rule is defined in OVERVIEW_METRICS.md. The overview is cached for 10 seconds per org and window; generated_at says when it was read. expand=usage works too; any other expand value is 400 validation_failed, and without the expansion the agent object is unchanged. The schemas are Overview, OverviewCount, AttentionItem and AgentUsage in api/openapi.yaml.

Replay, stats, shadow disagreements, anomalies and the Insights settings. Insights reads and reports; its only input into decisions is the opt-in anomaly facts (INSIGHTS.md).

Route Role Returns
POST /v1/policies/{id}/versions/{v}/replay admin+ 202 a queued replay (rpl_ id); optional Idempotency-Key; {v} is the version number
GET /v1/replays viewer+ a list of replay objects, newest first (?policy=pol_…&status=…)
GET /v1/replays/{id} viewer+ a replay, with its result once status is succeeded
POST /v1/replays/{id}/cancel admin+ the replay, cancelled; 409 conflict once it finished
GET /v1/insights/stats viewer+ an insights_stats (?window=7d|30d or from/to, by=rule|tool|agent|none, agent, tool, compare=previous)
GET /v1/insights/shadow viewer+ a shadow_report (?shadow_version=pv_…&from=&to=)
GET /v1/insights/anomalies viewer+ a list of anomaly objects plus active_now, anomaly_facts, notice and baseline_building
GET /v1/insights/anomalies/{id} viewer+ an anomaly
POST /v1/insights/anomalies/{id}/acknowledge admin+ the anomaly, acknowledged
POST /v1/insights/anomalies/{id}/resolve admin+ the anomaly, resolved, with the sealed anomaly.resolved deed; {"reason":"…"}, 3 to 280 characters
GET /v1/insights/settings viewer+ the insights_settings
PATCH /v1/insights/settings admin+ the insights_settings, with the sealed insights.settings_changed deed when something changed

Every route takes a Console session (by role; POST and PATCH need the Deedglass-CSRF header) or an org admin key (scope admin, acting as admin). Agent keys get 403 insufficient_scope on every Insights route, reads included: “Agent keys can’t read Insights. Use the Console or an org admin key.” The Insights lists take limit 1 to 100 and default to 25. Stats and the shadow report have a 10 s timeout; every Insights read runs in a read-only org transaction with a 5 s statement timeout.

Errors specific to Insights: 400 replay_window_invalid (a window over 90 days, or from not before to), 400 replay_too_large (over 1,000,000 actions), and 429 rate_limited after 10 replays created by an org in an hour (Retry-After: 300). The rest are canonical codes: not_found, conflict, validation_failed, insufficient_role, insufficient_scope.

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/policies/acme-sim/versions/3/replay \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" -H "Idempotency-Key: replay-v3-week-41" \
-H "Content-Type: application/json" \
-d '{"from":"2026-10-02T00:00:00Z","to":"2026-10-09T00:00:00Z","baseline":{"type":"recorded"}}'
curl -s -H "Authorization: Bearer $DEEDGLASS_API_KEY" \
"http://127.0.0.1:8080/v1/insights/stats?window=7d&by=rule" | jq '.groups[0].approval'
curl -s -X PATCH http://127.0.0.1:8080/v1/insights/settings \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" -H "Content-Type: application/json" \
-d '{"anomaly_facts":true,"reason":"Require approval while an agent spikes"}'

The replay object, the result digest and full examples are in REPLAY.md; every stats definition, the shadow report and the anomaly lifecycle in INSIGHTS.md; the schemas in api/openapi.yaml (tag Insights). anomaly.detected reaches the event stream and webhooks with object: "anomaly", the episode id, agent, kind, tool, severity and status.

Insights proposes policy changes; a person accepts them into shadow, or rejects them (SUGGESTIONS.md). ADR-058 records the shape.

Route Role Returns
GET /v1/suggestions viewer+ a list of suggestion objects, by impact then creation (?status=open|accepting|accepted|rejected|superseded|all, default open and accepting; kind=loosen|tighten|budget|explicit_rule|advisory; limit, cursor)
GET /v1/suggestions/{id} viewer+ a suggestion
POST /v1/suggestions/generate admin+ a learn_run; no body, optional Idempotency-Key, 60 s timeout
POST /v1/suggestions/{id}/accept admin+ the suggestion, accepted, with resulting_version and deeds; body {"reason":"…","replace_shadow":false} (reason optional, at most 280 characters on one line), optional Idempotency-Key, at most 16 KiB
POST /v1/suggestions/{id}/reject admin+ the suggestion, rejected, with its deed; body {"reason":"…"}, 3 to 280 characters, required

The same rules as the other Insights routes: a Console session (POSTs need the Deedglass-CSRF header) or an org admin key, and agent keys get 403 insufficient_scope on every route, reads included.

The suggestion object:

{"object":"suggestion","id":"sug_01k6z…","status":"open","kind":"loosen","generator":"loosen.approval_rate",
"title":"Allow updates to public.orders without approval",
"rationale":"People approved 398 of 403 updates to public.orders by ops-agent in the last 14 days and denied none. Each waited a median of 19 minutes.",
"phrasing":{"source":"template"},
"policy":{"id":"pol_01k6y…","name":"acme-sim","base_version":1,"base_version_id":"pv_01k6y…","base_version_hash":"sha256:5c1e…"},
"target_rule_id":"approve-order-updates",
"evidence":{"window":{"from":"2026-09-25T13:00:00Z","to":"2026-10-09T13:00:00Z"},
"approvals":{"requested":411,"approved":398,"denied":0,"expired":5,"pending":8},
"median_wait_seconds":1140,"approvers":2,"agents":["ops-agent"],"tool":"postgres.execute","rule":"approve-order-updates",
"pattern":{"agents":["ops-agent"],"sql.kind":"update","sql.statements":1,"sql.tables":["public.orders"],"tool":"postgres.execute"},
"sample_actions":["act_01k6x…","act_01k6x…"]},
"evidence_digest":"sha256:7a1c…e9d0",
"diff":{"hunks":[{"old_start":52,"old_lines":6,"new_start":52,"new_lines":14,"lines":[{"op":"+","text":" - id: allow-order-updates"}]}],
"text":"@@ -52,6 +52,14 @@\n …","added":8,"removed":0},
"warnings":[],
"replay":{"id":"rpl_01k6z…","window":{"from":"…","to":"…"},"summary":{"actions":5612,"evaluated":5612,"unchanged":5201,"changed":411,"rule_changed":0,"unknown":0},
"transitions":[{"from":"require_approval","to":"allow","count":411,
"human_outcome":{"approved":398,"denied":0,"expired":5,"cancelled":0,"pending":8}}],
"result_digest":"sha256:c4d2…"},
"risk_note":"ops-agent could update single rows of public.orders without a person looking. Other statements, and writes without a WHERE clause, still go to approval.",
"impact":411,"stale_reason":null,"created_at":"2026-10-09T13:00:02Z",
"decided_by":null,"decided_at":null,"decision_reason":null,"resulting_version":null,"decision_deed":null}
  • phrasing says whose words the title and rationale are: {"source":"template"}, or {"source":"claude","model":"claude-sonnet-5-5",…} with the tokens, the cost in micro-dollars and the template title it replaced; a failed attempt keeps source: template and sets reason (INSIGHTS_PRIVACY.md).
  • diff, replay and target_rule_id are null for an advisory; impact is then 0.
  • warnings are the candidate’s compile warnings (severity W).
  • replay.id is a replay you can read with GET /v1/replays/{id} (purpose: suggestion).
  • stale_reason is base_changed on a superseded suggestion.
  • The accept answer adds deeds (the ids sealed: policy.activated, then suggestion.accepted), replaced_shadow_version when a shadow was replaced, and note when the change already existed as a version. The reject answer adds deeds with the suggestion.rejected deed.

The evidence keys differ by generator (approvals for a loosen, mismatches for a tighten, the days for a budget); every one has window. The numbers above are the seed-7 loosen suggestion’s; ids, digests, the window and the replay’s total are illustrative.

The learn_run object (POST /v1/suggestions/generate):

{"object":"learn_run","kind":"suggest","period":"m-20261009T130000.000000Z-7k2mq",
"policy":{"id":"pol_01k6y…","name":"acme-sim","base_version":1,"base_version_id":"pv_01k6y…","base_version_hash":"sha256:5c1e…"},
"window":{"from":"2026-09-25T13:00:00Z","to":"2026-10-09T13:00:00Z"},"actions":5612,
"created":[{"id":"sug_01k6z…","kind":"loosen","generator":"loosen.approval_rate","target_rule_id":"approve-order-updates",
"target":"approve-order-updates","title":"Allow updates to public.orders without approval","impact":411}],
"superseded":0,"unchanged":0,"waiting":0,
"dropped":[{"generator":"loosen.approval_rate","target":"refund-approval","reason":"its replay would allow 2 actions a person denied"}],
"notice":null,"elapsed_ms":310}

unchanged counts proposals already open with the same fingerprint; waiting those over the cap of 10 open suggestions, which come back on a later pass. With no enforced policy, notice says to activate one and nothing else is filled in.

Errors (each has a section in api/errors.md):

Code Status When
suggestion_stale 409 the enforced version moved on since the suggestion was written; it is now superseded. “acme-sim v2 has been enforced since this suggestion was written for v1. Generate suggestions again: POST /v1/suggestions/generate.”
shadow_slot_taken 409, param: replace_shadow another version runs in shadow and replace_shadow is false; the suggestion stays open
suggestion_decided 409 already accepted, rejected or superseded, or being accepted right now; the message says by whom and when

The rest are canonical: 409 conflict to accept an advisory or to generate while a pass runs for the org on the same instance, 400 validation_failed on reason, 404 not_found, 403 insufficient_role and insufficient_scope.

The event suggestion.created is published in the transaction that stores the suggestion, to the event stream and webhooks: {"object":"suggestion","id":"sug_…","kind":"loosen","status":"open","rule_id":"approve-order-updates"}. rule_id is the target rule and is left out when there is none (budgets, advisories, appended rules). It never carries the title, the evidence or any name beyond the rule id.

Settings. PATCH /v1/insights/settings also takes llm_phrasing (boolean), llm_monthly_cap_cents (0 to 10,000) and digest ({"recipients":"owners"|"admins"|"off"}); the insights_settings object adds those and the read-only phrasing_available. Turning llm_phrasing on while the server has no DEEDGLASS_ANTHROPIC_API_KEY is 400 validation_failed on llm_phrasing (INSIGHTS.md).

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/suggestions/generate \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" | jq '.created'
curl -s -X POST http://127.0.0.1:8080/v1/suggestions/sug_01k6z…/accept \
-H "Authorization: Bearer $DEEDGLASS_API_KEY" -H "Idempotency-Key: accept-orders-loosen" \
-H "Content-Type: application/json" \
-d '{"reason":"Ops approves these every time","replace_shadow":true}'

Every object carries "object", naming its type ("health", "version", "list", and so on), and "id" when it has one. Ids are prefixed, lowercase ULIDs such as agt_01j9zr3m5k8q2w7x4t6v0b1c9d; the prefix says what the id is. See Ids.

Nullable fields are sent as an explicit null, not left out. For example, in GET /v1/health the check fields latency_ms, applied and expected are null when their check didn’t produce a number.

Timestamps are RFC 3339 strings in UTC with milliseconds: 2026-10-01T09:30:00.123Z. When you send one, any RFC 3339 precision and offset is accepted, and Deedglass converts it to UTC.

Money is an object with an integer amount in the currency’s minor units and a lowercase ISO 4217 currency:

{ "amount": 1250, "currency": "usd" }

That is 12.50 US dollars. Amounts are never floats.

Every response carries Deedglass-Request-Id: req_…, set before anything else can fail. Error bodies repeat it as request_id, except in a replayed response, whose body keeps the original request’s id (Idempotent requests). Quote it to support; the server log has the same id.

  • An inbound Deedglass-Request-Id is ignored. Deedglass always mints its own.
  • Send your own id in X-Request-Id (up to 64 printable ASCII characters) and it is logged next to ours as client_request_id. A longer or non-printable value is ignored.

Request headers:

Header Used for
Authorization Bearer dg_live_… or Bearer dg_test_… (S08).
Cookie __Host-deedglass_session and __Host-deedglass_csrf, from the Console (S07).
Deedglass-CSRF The CSRF token for Console sessions (S07).
Deedglass-Version The API version, YYYY-MM-DD. Send it once.
Idempotency-Key Safe retries for POST, 1 to 255 printable ASCII characters.
Content-Type application/json, optionally with ; charset=utf-8, on any request with a body.
X-Request-Id Your own request id, logged as client_request_id.
Deedglass-Intent A signed intent from the agent (S09).
Deedglass-On-Behalf-Of The person an agent acts for (S09 and later).
Deedglass-Session The agent’s session reference (S09 and later).
X-Forwarded-For Read only when the connection comes from a proxy listed in DEEDGLASS_TRUSTED_PROXIES.

Response headers:

Header When
Deedglass-Request-Id Every response.
Deedglass-Version Every response, errors included.
Deedglass-Idempotent-Replayed true on a replayed response, POST /v1/actions included.
Content-Type application/json; charset=utf-8 for JSON; application/yaml for the OpenAPI document.
Content-Length Every response the pipeline writes.
Allow 405 answers.
Retry-After 429 answers (for budget_exceeded, the seconds until the budget resets), and 409 idempotency_key_in_use (1).
WWW-Authenticate 401 answers from endpoints that take API keys.
Cache-Control no-store, unless the endpoint sets its own (public, max-age=300 for the OpenAPI document).
Security headers Every response; see below.

Request bodies are decoded strictly. Each of these is refused, and the body is never echoed in the error:

Body Answer
A syntax error 400 invalid_json, with the line and column (in bytes).
A member name that appears twice 400 invalid_json.
Invalid UTF-8 400 invalid_json.
Data after the JSON value 400 invalid_json.
An empty body 400 invalid_json.
A member the endpoint doesn’t know 400 validation_failed, param set to the member.
A value of the wrong type 400 validation_failed, param set to its path (items.0.amount).

Responses use 2-space indentation and end with a newline.

Limit Value
Request body 1 MiB by default; an endpoint can set its own. Larger is 413 request_too_large.
Body on GET, HEAD and DELETE None, unless the endpoint says otherwise; sending one is 413 request_too_large.
Request time 30 s by default; 5 s for health, version and the OpenAPI document. Past it, 504 timeout.
Request headers 64 KiB.
limit on lists 1 to 100, default 20 (25 on the Insights lists).
Idempotency-Key 1 to 255 printable ASCII characters.
X-Request-Id 64 printable ASCII characters (longer values are ignored).

Every response carries:

X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
X-Frame-Options: DENY
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Cross-Origin-Resource-Policy: same-origin
Cache-Control: no-store

In production with an https base URL, it also carries Strict-Transport-Security: max-age=63072000; includeSubDomains. There is no Server header.

  • No CORS. The API sends no CORS headers, so browsers block cross-origin calls. Call it from your server, not a browser. The Console is same-origin and doesn’t need them.
  • No unsigned webhooks. Every webhook is signed in Deedglass-Signature (S22). Check the signature before you act on one.
  • No uncapped bodies. Every endpoint has a body limit, and every request has a timeout.
  • No plain-HTTP redirects. The server never redirects http to https, or one path to another. Use the https base URL.
  • No path guessing. Paths are exact: /v1/health/ is 404, and so are // and .. forms. A 404 may suggest the right path; it never serves it.