REST API Reference
Complete reference for the DagNats control-plane HTTP API.
Success responses are application/json. Most error responses are plain text with the appropriate HTTP status code; a few 409 and 503 responses carry a JSON body, noted where they occur.
Base URL
The API is served by dagnats serve at the configured HTTP address (default 127.0.0.1:8080, loopback only). The routes below are served at the root of that address, alongside the operator console, the /v1 worker bridge and the probe endpoints.
Workflows
List Workflows
Retrieve all registered workflow definitions.
GET /workflowsResponse: 200 OK
[
{
"name": "code-review",
"version": "1.0.0",
"steps": [...],
"timeout": "30m",
"def_hash": "3f1a...c2"
}
]Each entry includes def_hash: the hex-encoded SHA-256 of the server’s canonical JSON marshal of that definition (dag.DefHash). See Wire Protocol: Workflow Re-registration and def_hash for how a caller uses it to skip re-registering an unchanged definition.
curl:
curl http://localhost:8080/workflowsRegister Workflow
Register or update a workflow definition.
POST /workflowsRe-registering an existing name replaces the name -> latest pointer; in-flight runs stay pinned to the definition they started with and pick up the replacement only on their next fresh run (see Wire Protocol: Workflow Re-registration and def_hash). workflow_defs retains at most 32 immutable versions per name; if every retained version is still referenced by a non-terminal run, registration is refused with 409 rather than evicting one out from under a running run.
Request body: A WorkflowDef JSON object (see Workflow Schema).
{
"name": "code-review",
"version": "1.0.0",
"steps": [
{
"id": "fetch-diff",
"task": "git.fetch-diff",
"timeout": "2m",
"type": "normal"
}
]
}Response: 201 Created
{
"status": "registered",
"name": "code-review",
"def_hash": "3f1a...c2"
}def_hash is dag.DefHash(def) for the definition just registered. A caller that re-registers on every trigger can compare its locally computed hash against the last-seen def_hash and skip this call when they match.
| Status | Condition |
|---|---|
201 | Workflow registered successfully |
400 | Invalid JSON or validation failure |
409 | Every retained def version for this name is still referenced by a live run; response body is {"error": "too many live workflow versions", "live_versions": <n>} |
curl:
curl -X POST http://localhost:8080/workflows \
-H "Content-Type: application/json" \
-d @workflow.jsonDelete Workflow
Delete a workflow definition. Historical run records are not touched.
DELETE /workflows/{name}[?force=true]The request is refused with 409 while a trigger still references the workflow or a run of it has not finished. force=true overrides both checks. Unfinished runs are found through both the active-run markers and the run index (the newest 10,000 runs); when the run index holds more runs than that, the delete is refused with total_is_lower_bound: true. A run left unfinished by force=true cannot advance without its definition, but POST /runs/{id}/cancel still cancels it.
| Status | Condition |
|---|---|
204 | Workflow deleted |
404 | Workflow not found, or name contains a / |
405 | Method other than DELETE |
409 | Non-terminal runs: {"error":"workflow has non-terminal runs","run_ids":[...],"total_non_terminal":N,"total_is_lower_bound":false}. total_is_lower_bound is true when the run-index scan hit its cap before covering every run; N is then a lower bound and may be 0. Referencing triggers: {"error":"workflow has referencing triggers","trigger_ids":[...]} |
curl:
curl -X DELETE http://localhost:8080/workflows/code-reviewRuns
List Runs
Retrieve all workflow runs, optionally filtered by workflow, status, and/or labels. Returns runs sorted by creation time (newest first).
GET /runs[?workflow=NAME][&status=STATUS][&label=KEY=VALUE...][&limit=N]| Query Parameter | Description |
|---|---|
workflow | Filter by workflow name |
status | Filter by run status: pending, running, completed, failed, cancelled, compensated, or compensate_failed. An unrecognized value returns 400 listing the accepted set. |
limit | Max runs to scan and return. Default 1000, capped at 10000. An unparseable or negative value falls back to the default. |
label | Filter by a run label, key=value. Repeatable — every label param given must match (AND semantics). A param with no = returns 400. |
Filters compose: workflow, status, and label narrow the same query together, not as alternatives. The server scans runs newest-first (a creation-ordered index, #659) and applies the filter during that scan, so a filtered result (here, Count, and Bulk Cancel below) is exact for the newest runs and only ever misses matches OLDER than the scanned window — it never misses a run newer than one already found, the failure mode #659 fixed. Cursor-based pagination is not part of this change; the index position enables it later.
Response: 200 OK
[
{
"run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"workflow_id": "code-review",
"status": "running",
"created_at": "2025-01-15T09:00:00Z",
"labels": {"pr": "42", "repo": "dagnats"},
"steps": {
"fetch-diff": {"status": "completed", "attempts": 1},
"lint": {"status": "running", "attempts": 1}
}
}
]curl:
curl http://localhost:8080/runs?workflow=code-review
curl 'http://localhost:8080/runs?status=failed&label=repo=dagnats&label=pr=42'Start Run
Start a new workflow run, optionally with input data. If run_at is provided and is more than 1 second in the future, the run is scheduled for later execution.
POST /runsRequest body:
{
"workflow": "code-review",
"input": {"pr": 42},
"run_at": "2025-01-16T09:00:00Z",
"labels": {"pr": "42", "repo": "dagnats"}
}| Field | Type | Required | Description |
|---|---|---|---|
workflow | string | Yes | Workflow name |
input | JSON | No | Arbitrary input data |
run_at | string | No | RFC3339 time for scheduled execution |
labels | object | No | Key/value metadata stamped on the run (applies to both immediate and scheduled runs). At most 16 labels; keys match ^[a-z0-9_.-]+$ and are at most 64 chars; values at most 256 chars. Use labels to find or bulk-cancel runs later via GET /runs?label= or POST /runs/cancel without a separate lookup table. |
Response (immediate): 201 Created
{
"run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
}Response (scheduled): 201 Created
{
"run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"status": "scheduled"
}| Status | Condition |
|---|---|
201 | Run started or scheduled |
400 | Invalid JSON, workflow not found, or input validation failure |
curl:
curl -X POST http://localhost:8080/runs \
-H "Content-Type: application/json" \
-d '{"workflow":"code-review","input":{"pr":42}}'Get Run
Retrieve the current snapshot of a workflow run.
GET /runs/{id}Response: 200 OK
{
"run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"workflow_id": "code-review",
"status": "completed",
"created_at": "2025-01-15T09:00:00Z",
"steps": {
"fetch-diff": {
"status": "completed",
"attempts": 1,
"output": "eyJmaWxlcyI6IDN9"
}
}
}A step’s output is raw bytes, so JSON carries it as a base64 string (here, {"files": 3}). Each step state also has timing fields (started_at, completed_at, attempt_started_at) and a dispatch_nonce.
The snapshot also carries def_hash: the definition version this run is pinned to (see Wire Protocol: Workflow Re-registration and def_hash). Empty for a run started before this field existed.
| Status | Condition |
|---|---|
200 | Run found |
400 | Missing run ID |
404 | Run not found |
curl:
curl http://localhost:8080/runs/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6Run logs
Read or tail one step’s captured stdout/stderr from the BUILD_LOGS hot
lane (see “Consumer contract: build logs” in the wire protocol
reference). dagnats owns the hot lane only – retention past its TTL
(DAGNATS_BUILD_LOGS_TTL, default 7d) is a consumer’s job.
GET /runs/{id}/logs?step={stepID}&attempt={n}&iteration={n}&cursor={n}&follow={0|1}&from={failure}| Query param | Required | Notes |
|---|---|---|
step | yes | Step ID within the run. 400 if omitted. |
attempt | no | Which attempt’s log lane to read (logs.{runID}.{stepID}.{attempt}.{iteration}, the same numbering as step.started’s AttemptNumber). Defaults to the step’s current Attempts from the run snapshot, or 1 if the step has not started yet – omit it for “the live/most recent attempt”. |
iteration | no | Which agent-loop iteration’s log lane to read within that attempt (0 for non-loop steps, bumped by each Continue without touching attempt). Defaults to the step’s current Iterations from the run snapshot – omit it for “the live/most recent iteration”. |
cursor | no | Opaque paging token – copy the previous response’s next_cursor here to fetch the next page. Omit for the first page. It is a JetStream stream sequence number, not a chunk seq; treat it as opaque. |
from | no | from=failure starts the response at the attempt’s recorded "failed" marker instead of cursor, resolved in O(1) (GetLastMsgForSubject), not by scanning. Strict: if the attempt’s last message isn’t a "failed" marker (it completed, or has no messages yet), this 404s – see below. |
follow | no | follow=1 upgrades the response to Server-Sent Events instead of one JSON page. |
Response (non-follow): 200 OK
{
"chunks": [
{"seq": 0, "attempt": 1, "iteration": 0, "ts": "2026-08-28T12:00:00.100Z", "stream": "out", "data": "cnVubmluZyB0ZXN0cwo="},
{"seq": 1, "attempt": 1, "iteration": 0, "ts": "2026-08-28T12:00:00.350Z", "stream": "out", "data": "MyBwYXNzZWQK"}
],
"next_cursor": 2,
"eof": true
}chunks is capped at 1024 (protocol.LogReadChunksMax) per response –
page again with cursor set to the returned next_cursor for more;
next_cursor is a JetStream stream sequence, opaque to the caller.
eof is true only when the requested attempt is done for good (a
past attempt, or the step’s current attempt reached a terminal status)
AND this page returned everything currently stored – it does not mean
the stream is empty forever, just that nothing more is coming for this
attempt. A step that exists but has produced no chunks yet returns
200 with "chunks": [], not 404, so a UI can attach before the
first line lands. A cursor past the last stored message on a
terminal attempt also returns 200 with an empty page and eof: true.
from=failure on an attempt whose last message isn’t a "failed"
marker returns 404 with {"error":"attempt has no failure marker"}
instead of silently starting at whatever the last message happens to
be.
Response (follow=1): 200 OK, Content-Type: text/event-stream
event: chunk
data: {"seq":0,"attempt":1,"iteration":0,"ts":"2026-08-28T12:00:00.100Z","stream":"out","data":"cnVubmluZyB0ZXN0cwo="}
: keepalive
event: eof
data: {}An event: chunk per protocol.LogChunk, a : keepalive comment
every 15s while idle (so an intermediary proxy doesn’t drop the
connection), and event: eof once the attempt-ending marker is read
off the SAME consumer this connection opened at attach time – exactly
one JetStream consumer is opened per follow=1 connection, for its
whole lifetime, not re-opened on a poll interval. event: eof’s data
carries {"reason":"paused"} or {"reason":"continued"} when the
attempt ended that way (a UI is expected to re-attach with a fresh
attempt for paused, or a fresh iteration at the SAME attempt
for continued – Continue never bumps attempt); it’s {} for
completed/failed and for the rare crash
fallback (a coarse run-snapshot poll, at most every 10s, for the case
where the marker itself never arrives). Hard capped at 1h per
connection (protocol.LogFollowDurationMax); capped at 256 concurrent
follows per API server process (protocol.LogFollowConcurrentMax),
beyond which a new follow gets 503.
If the follow’s own consumer fails mid-connection (deleted out from
under it, a connection problem) the stream ends with event: error and
data: {"reason":"<cause>"} instead of silently looping as idle until
the 1h cap – this is distinct from the ordinary per-wait timeout every
idle keepalive cycle hits internally, which never reaches the client as
an error.
| Status | Condition |
|---|---|
200 | Chunks (possibly empty) or an SSE stream |
400 | Missing step query parameter |
404 | Run not found, run found but step unknown, or (non-follow only) from=failure with no recorded failure marker for the attempt |
503 | follow=1 and the server is already at LogFollowConcurrentMax |
Non-Go (HTTP bridge) workers: POST /v1/tasks/{id}/logs (below)
also emits the attempt-ending marker automatically on
complete/fail/continue/pause resolve actions (see
POST /v1/tasks/{id}/resolve in the bridge reference) – callers never
POST a marker chunk themselves. A log POST for a task that already
resolved is rejected 409 Conflict, distinguishing “already resolved”
from 404 (“never claimed”).
curl (page):
curl "http://localhost:8080/runs/a1b2c3d4.../logs?step=fetch-diff"curl (follow):
curl -N "http://localhost:8080/runs/a1b2c3d4.../logs?step=fetch-diff&follow=1"Step journal
Read an agent step’s journal (ADR-026): the records its handler and the SDK appended, in order. Authorization is that of run logs.
GET /runs/{id}/steps/{step}/journal?from={seq}&follow={0|1}| Param | Required | Description |
|---|---|---|
from | no | First journal sequence to return (inclusive). The sequence is the record’s seq. Omit for the start of the journal. |
follow | no | 1 upgrades to Server-Sent Events. |
Header Last-Event-ID (follow and page; 18446744073709551615 has no successor and is a 400): the last seq the client
received; the response resumes one past it. It wins over from, because
an EventSource reconnect resends its original URL together with the
header.
Page response (200):
{"records": [{"seq": 41, "type": "claim", "iteration": 0, "attempt": 1,
"delivered": 1, "index": 0, "time": "...", "data": {}}],
"next_from": 42, "eof": false}next_from is the from for the next page. A page holds at most 1024
records. eof is true once the step is terminal and the page reached
the end. A step with no journal returns {"records": [], "next_from": 0, "eof": false}, not an error.
Follow (follow=1): each record is an SSE frame
id: {seq} / event: record / data: {record}. Idle connections get
: keepalive comments. The stream ends with event: eof once the step
is terminal and the journal is drained (up to a couple of seconds
later), event: error if the journal stream fails, or when the
connection reaches LogFollowDurationMax. Journal follows are capped at
LogFollowConcurrentMax, counted separately from run-log follows.
| Status | Condition |
|---|---|
200 | Records (possibly empty) or an SSE stream |
400 | Last-Event-ID is the maximum sequence |
404 | Run not found, or step unknown |
405 | Method other than GET |
503 | follow=1 at LogFollowConcurrentMax, or the journal stream is not available |
curl (follow):
curl -N "http://localhost:8080/runs/a1b2c3d4.../steps/agent/journal?follow=1"Cancel Run
Cancel a running workflow by publishing a workflow.cancelled event.
POST /runs/{id}/cancelResponse: 200 OK
{
"status": "cancelled",
"run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
}| Status | Condition |
|---|---|
200 | Cancel event published |
400 | Missing run ID |
500 | Publish failure |
curl:
curl -X POST http://localhost:8080/runs/a1b2c3d4.../cancelSend Signal
Write a named signal with arbitrary data to a running workflow via the signals KV bucket.
POST /runs/{id}/signal/{name}The request body is the signal payload (raw bytes, max 1 MiB).
Response: 200 OK
{
"status": "sent",
"run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"signal": "approval"
}| Status | Condition |
|---|---|
200 | Signal written to KV |
400 | Missing run ID or signal name |
500 | KV write failure |
curl:
curl -X POST http://localhost:8080/runs/a1b2c3d4.../signal/approval \
-d '{"approved": true}'Step inputs
Send input to a running agent step (ADR-027). A steer joins the current turn after the step’s next tool round; a follow_up starts the next turn after a final answer. The step’s handler places inputs with PlaceInputs, which needs the step’s journal. Submitting input is a write into the agent’s context: it carries the same authorization as sending a signal.
POST /runs/{id}/steps/{step}/inputs
GET /runs/{id}/steps/{step}/inputs/{request_id}?wait=30s&after=queued
DELETE /runs/{id}/steps/{step}/inputs/{request_id}Request body (POST):
| Field | Type | Required | Description |
|---|---|---|---|
request_id | string | Yes | Caller-chosen idempotency key, 1-128 characters of [A-Za-z0-9_-]. A repeat returns the original submission, whatever its content |
mode | string | Yes | steer or follow_up |
content | JSON | Yes | The input, at most 256 KiB (it is copied into the step’s journal with its envelope, and that record must fit the server’s 1 MiB max_payload) |
Response: 201 Created for a new submission, 200 OK for a repeat of the same request_id.
{
"run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"step_id": "agent",
"request_id": "msg-42",
"mode": "steer",
"content": {"text": "use the staging database"},
"status": "queued",
"created_at": "2026-10-21T12:00:00Z",
"updated_at": "2026-10-21T12:00:00Z"
}status is queued (waiting for the step’s next turn boundary), placed (in the conversation; iteration says which), done (the iteration that placed it committed) or unanswered (the step finished first). The journal’s input.placed record is the record of placement; a status that disagrees with it is repaired when read.
GET with wait (at most 60s) returns as soon as the status is no longer after (default queued), or the current status at the deadline; an unknown or withdrawn request_id is 404 at once.
A handler woken by an input must tolerate finding nothing to place: step.input.queued can be redelivered, and a withdrawal can land between the wake and the handler’s PlaceInputs. Upgrade engines before workers: the wait on step.continue is new, and a worker that asks an older engine for UntilInput() is sent its next iteration at once, which hot-loops the agent until max_iterations. DELETE withdraws a submission that is still queued and answers 204; the same request_id may then be submitted again.
| Status | Condition |
|---|---|
200 / 201 | Submission read, or created (201) |
204 | Withdrawn |
400 | Invalid request_id, mode, content (including over 256 KiB) or wait |
404 | Unknown run, step or submission (including a withdrawn one) |
409 | The step has finished (POST); the submission is no longer queued (DELETE) |
503 | The submissions bucket is not provisioned |
curl:
curl -X POST http://localhost:8080/runs/a1b2c3d4.../steps/agent/inputs \
-d '{"request_id": "msg-42", "mode": "steer", "content": {"text": "use staging"}}'
curl "http://localhost:8080/runs/a1b2c3d4.../steps/agent/inputs/msg-42?wait=30s"The NATS micro equivalents are api.inputs.submit, api.inputs.get (wait_ms, after) and api.inputs.withdraw, each taking {"run_id", "step_id", "request_id", ...} and answering the submission (submit adds "created"), {"status": "withdrawn"}, or {"error", "kind"} with kind one of invalid, not_found, conflict, unavailable, internal, transport.
Handle Approval
Process an approval or rejection for a human-in-the-loop step. Uses atomic CAS to guarantee exactly-once token consumption.
POST /runs/{id}/approval/{step_id}?action=approve&token=TOKEN
POST /runs/{id}/approval/{step_id}?action=reject&token=TOKEN| Query Parameter | Required | Description |
|---|---|---|
action | Yes | approve or reject |
token | Yes | One-time approval token |
Request body (optional):
{
"comment": "LGTM",
"approved_by": "alice"
}Response: 200 OK
{
"status": "approved",
"run_id": "a1b2c3d4...",
"step": "review"
}| Status | Condition |
|---|---|
200 | Approval processed |
400 | Missing step ID, invalid action |
401 | Invalid token or token not found/expired |
409 | Token already consumed |
curl:
curl -X POST \
"http://localhost:8080/runs/a1b2.../approval/review?action=approve&token=abc123"Scheduled Runs
Get Scheduled Run
Retrieve a scheduled (pending) run by ID.
GET /runs/{id}/scheduledResponse: 200 OK with the scheduled run object.
| Status | Condition |
|---|---|
200 | Scheduled run found |
404 | Not found |
Cancel Scheduled Run
Cancel a pending scheduled run before it executes.
DELETE /runs/{id}/scheduledResponse: 200 OK
{
"status": "cancelled"
}| Status | Condition |
|---|---|
200 | Scheduled run cancelled |
400 | Run not found or already executed |
Bulk Operations
Bulk Run
Start multiple workflow runs in a single request. The workflow definition is loaded once and all inputs are validated atomically before any runs start.
POST /runs/bulkRequest body:
{
"workflow_id": "deploy",
"inputs": [
{"env": "staging"},
{"env": "prod"}
],
"labels": {"batch": "release-42"}
}| Field | Type | Required | Description |
|---|---|---|---|
workflow_id | string | Yes | Workflow name |
inputs | []JSON | Yes | Array of input payloads (max 1000) |
labels | object | No | Applied to every run started in this batch (same bounds as Start Run’s labels) |
Response: 201 Created
{
"run_ids": [
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4"
],
"total": 2
}| Status | Condition |
|---|---|
201 | All runs started |
400 | Invalid request, workflow not found, or input validation failure |
curl:
curl -X POST http://localhost:8080/runs/bulk \
-H "Content-Type: application/json" \
-d '{"workflow_id":"deploy","inputs":[{"env":"staging"},{"env":"prod"}]}'Bulk Cancel
Cancel multiple runs matching filter criteria.
POST /runs/cancelRequest body:
{
"workflow_id": "deploy",
"status": "running",
"after": "2025-01-15T00:00:00Z",
"before": "2025-01-16T00:00:00Z",
"dry_run": false,
"labels": {"batch": "release-42"}
}| Field | Type | Required | Description |
|---|---|---|---|
workflow_id | string | Yes | Workflow name |
status | string | No | running, pending, or all (default: all) |
after | string | No | RFC3339 lower bound on creation time |
before | string | No | RFC3339 upper bound on creation time |
dry_run | bool | No | Preview without cancelling |
labels | object | No | Every key/value must match a run’s labels (AND semantics), composing with workflow_id/status/after/before. Same bounds as Start Run’s labels; a filter with more than 16 labels is a 400. |
Matching runs are found via the same newest-first, creation-ordered scan List Runs uses (#659): exact for the newest matches, missing only runs OLDER than the scanned window — never a run newer than one already found.
Response: 200 OK
{
"cancelled": ["a1b2c3d4...", "e5f6a7b8..."],
"skipped": [],
"total": 2,
"dry_run": false
}| Status | Condition |
|---|---|
200 | Cancel operation completed |
400 | Invalid request or too many matching runs (max 1000) |
curl:
curl -X POST http://localhost:8080/runs/cancel \
-H "Content-Type: application/json" \
-d '{"workflow_id":"deploy","status":"pending","dry_run":true}'Bulk Retry
Retry failed runs of a workflow. Supports two modes:
- rerun: Start fresh runs with the original input
- replay: Re-publish DLQ task messages to resume at the failed step
POST /runs/retryRequest body:
{
"workflow_id": "deploy",
"mode": "rerun",
"after": "2025-01-15T00:00:00Z",
"dry_run": false
}| Field | Type | Required | Description |
|---|---|---|---|
workflow_id | string | Yes | Workflow name |
mode | string | Yes | rerun or replay |
after | string | No | RFC3339 lower bound |
before | string | No | RFC3339 upper bound |
dry_run | bool | No | Preview without retrying |
Response: 200 OK
{
"retried": [
{"original_run_id": "a1b2...", "new_run_id": "c3d4..."}
],
"skipped": [],
"total": 1,
"dry_run": false
}For replay mode, new_run_id is omitted since the original run resumes.
| Status | Condition |
|---|---|
200 | Retry operation completed |
400 | Invalid request, mode, or too many matching runs (max 1000) |
curl:
curl -X POST http://localhost:8080/runs/retry \
-H "Content-Type: application/json" \
-d '{"workflow_id":"deploy","mode":"rerun"}'Workers
List Workers
Retrieve the live worker directory. A worker is considered live if it has sent a heartbeat within the staleness TTL (60s); entries older than that are filtered out before the response is built, so every worker returned here is currently reachable.
GET /v1/workersNote the /v1 prefix — this route is mounted at the top level alongside
the worker-runtime bridge, not under the unprefixed control-plane paths
above.
Response: 200 OK
{
"workers": [
{
"worker_id": "worker-1",
"task_types": ["git.fetch-diff", "code-review"],
"language": "go",
"transport": "nats",
"max_tasks": 10,
"worker_groups": ["gpu"],
"pid": 4821,
"hostname": "build-01",
"last_seen": "2026-08-28T12:00:00Z"
}
],
"count": 1
}metadata and version appear when the worker set them.
workers is always an array ([] when none are registered or live),
never null.
worker_groups lists the worker groups a worker drains. It is omitted
for a worker that drains only the ungrouped queue. A Go SDK worker
reports the groups it was started with (WithGroups); a bridge worker
reports the worker_group it named on connect, or its token’s group
scope if it named none.
curl:
curl http://localhost:8080/v1/workersQueue
Queue depth
Retrieve a snapshot of pending task counts per task type on the
TASK_QUEUES work queue. The source of truth is the stream’s own
per-subject state – TASK_QUEUES is a JetStream work-queue stream, so
an unacked message on task.{taskType} IS the pending task; there is
no separate counter to drift from it.
GET /v1/queueNote the /v1 prefix, same as GET /v1/workers above.
Response: 200 OK
{
"groups": [
{ "task_type": "build", "pending": 3, "oldest_wait_ms": 1523 },
{ "task_type": "test", "pending": 1, "oldest_wait_ms": 402 }
],
"snapshot_at": "2026-08-28T12:00:00Z"
}| Field | Type | Notes |
|---|---|---|
task_type | string | The task type – the task.{taskType} subject with the task. prefix stripped. |
pending | integer | Unacked message count for this subject right now. |
oldest_wait_ms | integer, omitted when unavailable | How long the oldest pending message on this subject has been waiting, in milliseconds. Omitted (not zero) when the server could not read the oldest message for this one subject – a transient per-subject read failure never fails the whole response. |
snapshot_at | RFC3339 timestamp | When this snapshot was taken. |
truncated | boolean, omitted when false | Present and true only when the stream carries more distinct task-type subjects than the server’s cap (256) – groups is truncated to the first 256 in task_type order. |
groups is always an array, sorted by task_type, [] when nothing is
pending – never null.
Labels are not a grouping dimension here. Pending tasks on
TASK_QUEUES don’t carry run labels in their subject or a
cheap-to-read header; grouping by label would mean fetching and
decoding every pending payload, which is unbounded work on a queue
that can hold an unbounded number of pending tasks. task_type
grouping is free (it’s the subject); label grouping is not, so it is
out of scope for this endpoint.
For a live feed instead of point-in-time polling, subscribe to
event.queue.snapshot on the EVENTS stream – see
Consumer contract: run lifecycle events
for its cadence and change-suppression behavior; the payload shape is
identical to this response.
curl:
curl http://localhost:8080/v1/queueTokens
Mint, list, and revoke bearer tokens that scope HTTP-bridge worker access
to specific task-type prefixes. Separate from DAGNATS_BRIDGE_TOKEN,
which remains the single admin/root credential: it authenticates
unscoped, and it is the only credential these three routes accept.
All three routes require Authorization: Bearer <DAGNATS_BRIDGE_TOKEN>.
This is a fail-closed contract: if DAGNATS_BRIDGE_TOKEN is unset,
token management is unavailable (503), never open. A minted worker
token cannot call these routes, no matter its scope. This is
independent of the bridge’s own poll/resolve/connect auth, which
follows the opposite rule: no env token there means an open bridge
(dev mode); set it and every worker needs either the env token or a
minted one.
task_type_prefixes entries are dot-segment prefixes, not raw
byte prefixes: a prefix p matches task type t iff t == p or t
starts with p + ".". "build" matches "build" and "build.deploy"
but not "builder.deploy"; "echo" matches "echo" but not
"echo-admin". Write the segment you want, without a trailing dot.
worker_groups (#695) independently scopes which worker groups a token
may poll on POST /v1/tasks/poll — see docs/wire-protocol.md’s
“Authentication” and “Task Subjects” sections for the poll-side contract.
Mint Token
POST /v1/tokensRequest body:
{
"label": "ci-runner-1",
"task_type_prefixes": ["ci", "build"],
"worker_groups": ["repo-alpha"]
}An empty (or omitted) task_type_prefixes mints a token that is
authorized for no task types — fail closed, not “all types.” Bounds:
label up to 128 bytes, up to 32 prefixes of up to 64 bytes each, and up
to 1000 non-revoked tokens outstanding at once.
worker_groups is optional and follows the OPPOSITE rule from
task_type_prefixes: omitting it (or sending no field at all) mints a
token unscoped by group — it may poll any worker group, including the
ungrouped queue, exactly like a token minted before #695. Sending a
present-but-empty array ("worker_groups": []) is refused with 400 —
it is never treated as “every group”. A non-empty list scopes the token
to exactly those groups; each entry must satisfy the same charset rule as
a StepDef.WorkerGroup (no dots, A-Za-z0-9_- only), and the list is
bounded at 32 entries.
Group scoping is checked against every reading of the subject a poll
actually consumes from, not just the worker_group spelling in the
request body — a dotted ungrouped task type can derive the same
underlying queue as an equivalent (task type, group) pair, so the bridge
authorizes both readings rather than only the one the caller wrote.
Response: 201 Created
{
"id": "6f1c...9a2b",
"token": "dgn_6f1c...9a2b_kQ3z...",
"label": "ci-runner-1",
"task_type_prefixes": ["ci", "build"],
"worker_groups": ["repo-alpha"],
"created_at": "2026-08-28T12:00:00Z"
}token (the bearer a worker presents as
Authorization: Bearer dgn_{id}_{secret}) is shown exactly once,
here. It is not recoverable from GET /v1/tokens or anywhere else — the
server stores only its SHA-256 hash. Save it immediately or mint a new
one.
curl:
curl -X POST http://localhost:8080/v1/tokens \
-H "Authorization: Bearer $DAGNATS_BRIDGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"label":"ci-runner-1","task_type_prefixes":["ci"]}'List Tokens
GET /v1/tokensResponse: 200 OK
{
"tokens": [
{
"id": "6f1c...9a2b",
"label": "ci-runner-1",
"task_type_prefixes": ["ci", "build"],
"worker_groups": ["repo-alpha"],
"created_at": "2026-08-28T12:00:00Z",
"created_by": "admin",
"revoked_at": null
}
]
}Revoked tokens are kept in the listing for audit (revoked_at set) —
they are not deleted. The response never includes the secret or its
hash.
curl:
curl http://localhost:8080/v1/tokens \
-H "Authorization: Bearer $DAGNATS_BRIDGE_TOKEN"Revoke Token
DELETE /v1/tokens/{id}Response: 204 No Content on success, 404 Not Found if id is
unknown.
Revocation latency is bounded by the bridge’s KV-watch reconnect window (capped at 30s), not instant: each bridge process caches minted tokens in memory and only re-syncs immediately on a live watch; during a NATS reconnect it keeps serving its last-known cache rather than failing every poll/resolve outright, so a revoke made during that window takes effect once the watch reconnects.
curl:
curl -X DELETE http://localhost:8080/v1/tokens/6f1c...9a2b \
-H "Authorization: Bearer $DAGNATS_BRIDGE_TOKEN"CI
POST /v1/ci/compile and POST /v1/ci/validate compile a .dagnats/ci.yml
spec into a dag.WorkflowDef – see the
CI Module Reference for the full request/response contract,
the diagnostic shape, and why this is the only place .dagnats/ci.yml
awareness enters the control plane (core POST /workflows above stays
dag.WorkflowDef-only).
POST /v1/ci/compile # {"name","spec","register","task_namespace"} -> {"workflow","def_hash","registered","warnings","on"} | 422 {"diagnostics"}
POST /v1/ci/validate # {"name","spec","task_namespace"} -> 200 {"valid","diagnostics","on"}, never registersHealth
Telemetry Health
Check service health and telemetry stream status. The health endpoint never returns unhealthy; telemetry information is advisory.
GET /health/telemetryResponse: 200 OK
{
"status": "healthy",
"telemetry": {
"stream": {
"messages": 15432,
"bytes": 2048576,
"percent": 12.5
},
"consumers": 1,
"otlp": {"endpoint": "", "configured": false}
}
}The percent field shows telemetry stream storage usage as a percentage of MaxBytes. consumers is the number of consumers on the stream, and otlp reports whether an OTLP export endpoint is configured.
curl:
curl http://localhost:8080/health/telemetryCluster Health
Report embedded-cluster health from the node you ask. Standalone nodes return {"mode":"standalone","ok":true}.
GET /health/clusterResponse: 200 OK when healthy, 503 when degraded.
{
"mode": "cluster",
"ok": true,
"expected_peers": 2,
"connected_peers": 2,
"nodes_expected": 3,
"nodes_up": 3,
"node": "dagnats-prod-4021",
"leader": "dagnats-prod-4021",
"members": [
{"name": "dagnats-prod-4021", "self": true, "route_connected": true, "meta_leader": true, "current": true}
],
"jetstream": {
"leader_elected": true,
"streams": {"TASK_QUEUES": {"replicas": 3, "in_sync": 3}},
"kv_buckets": {"workflow_runs": 3}
}
}node is the server that answered. members lists each node with route_connected, meta_leader and offline; current and lag_ops appear only when the answering node is the meta leader. jetstream.unavailable names replicated streams or buckets that did not answer (the response is 503); jetstream.degraded names unreachable single-replica assets and keeps 200. The same JSON is returned over NATS request/reply on api.cluster.health. The production guide describes each field.
Probes
Liveness and readiness probes, served at the root of the HTTP address.
| Route | Response |
|---|---|
GET /health | 200 with body ok when NATS is connected and JetStream answers an account-info call; 503 with a plain-text reason otherwise |
GET /ready | 200 with body ready once startup has finished; 503 not ready before that |
/health checks only the node you ask. For cluster-wide state use GET /health/cluster above.
The server also serves its OpenAPI document at GET /openapi.json and an interactive explorer at GET /docs.
Error Responses
Errors are returned as plain text with the appropriate HTTP status code:
HTTP/1.1 400 Bad Request
invalid workflow: step "x" depends on non-existent step "y"| Status | Meaning |
|---|---|
400 | Bad request (invalid JSON, validation error, missing fields) |
401 | Unauthorized (invalid approval token) |
404 | Resource not found |
405 | Method not allowed |
409 | Conflict (approval token already consumed, workflow still in use, too many live workflow versions) |
500 | Internal server error |
503 | Service unavailable (probe failing, cluster degraded, token management disabled, log follow limit reached) |