Skip to content
REST API Reference

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 /workflows

Response: 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/workflows

Register Workflow

Register or update a workflow definition.

POST /workflows

Re-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.

StatusCondition
201Workflow registered successfully
400Invalid JSON or validation failure
409Every 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.json

Delete 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.

StatusCondition
204Workflow deleted
404Workflow not found, or name contains a /
405Method other than DELETE
409Non-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-review

Runs

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 ParameterDescription
workflowFilter by workflow name
statusFilter by run status: pending, running, completed, failed, cancelled, compensated, or compensate_failed. An unrecognized value returns 400 listing the accepted set.
limitMax runs to scan and return. Default 1000, capped at 10000. An unparseable or negative value falls back to the default.
labelFilter 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 /runs

Request body:

{
  "workflow": "code-review",
  "input": {"pr": 42},
  "run_at": "2025-01-16T09:00:00Z",
  "labels": {"pr": "42", "repo": "dagnats"}
}
FieldTypeRequiredDescription
workflowstringYesWorkflow name
inputJSONNoArbitrary input data
run_atstringNoRFC3339 time for scheduled execution
labelsobjectNoKey/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"
}
StatusCondition
201Run started or scheduled
400Invalid 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.

StatusCondition
200Run found
400Missing run ID
404Run not found

curl:

curl http://localhost:8080/runs/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6

Run 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 paramRequiredNotes
stepyesStep ID within the run. 400 if omitted.
attemptnoWhich 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”.
iterationnoWhich 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”.
cursornoOpaque 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.
fromnofrom=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.
follownofollow=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.

StatusCondition
200Chunks (possibly empty) or an SSE stream
400Missing step query parameter
404Run not found, run found but step unknown, or (non-follow only) from=failure with no recorded failure marker for the attempt
503follow=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}
ParamRequiredDescription
fromnoFirst journal sequence to return (inclusive). The sequence is the record’s seq. Omit for the start of the journal.
followno1 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.

StatusCondition
200Records (possibly empty) or an SSE stream
400Last-Event-ID is the maximum sequence
404Run not found, or step unknown
405Method other than GET
503follow=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}/cancel

Response: 200 OK

{
  "status": "cancelled",
  "run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
}
StatusCondition
200Cancel event published
400Missing run ID
500Publish failure

curl:

curl -X POST http://localhost:8080/runs/a1b2c3d4.../cancel

Send 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"
}
StatusCondition
200Signal written to KV
400Missing run ID or signal name
500KV 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):

FieldTypeRequiredDescription
request_idstringYesCaller-chosen idempotency key, 1-128 characters of [A-Za-z0-9_-]. A repeat returns the original submission, whatever its content
modestringYessteer or follow_up
contentJSONYesThe 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.

StatusCondition
200 / 201Submission read, or created (201)
204Withdrawn
400Invalid request_id, mode, content (including over 256 KiB) or wait
404Unknown run, step or submission (including a withdrawn one)
409The step has finished (POST); the submission is no longer queued (DELETE)
503The 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 ParameterRequiredDescription
actionYesapprove or reject
tokenYesOne-time approval token

Request body (optional):

{
  "comment": "LGTM",
  "approved_by": "alice"
}

Response: 200 OK

{
  "status": "approved",
  "run_id": "a1b2c3d4...",
  "step": "review"
}
StatusCondition
200Approval processed
400Missing step ID, invalid action
401Invalid token or token not found/expired
409Token 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}/scheduled

Response: 200 OK with the scheduled run object.

StatusCondition
200Scheduled run found
404Not found

Cancel Scheduled Run

Cancel a pending scheduled run before it executes.

DELETE /runs/{id}/scheduled

Response: 200 OK

{
  "status": "cancelled"
}
StatusCondition
200Scheduled run cancelled
400Run 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/bulk

Request body:

{
  "workflow_id": "deploy",
  "inputs": [
    {"env": "staging"},
    {"env": "prod"}
  ],
  "labels": {"batch": "release-42"}
}
FieldTypeRequiredDescription
workflow_idstringYesWorkflow name
inputs[]JSONYesArray of input payloads (max 1000)
labelsobjectNoApplied to every run started in this batch (same bounds as Start Run’s labels)

Response: 201 Created

{
  "run_ids": [
    "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4"
  ],
  "total": 2
}
StatusCondition
201All runs started
400Invalid 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/cancel

Request 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"}
}
FieldTypeRequiredDescription
workflow_idstringYesWorkflow name
statusstringNorunning, pending, or all (default: all)
afterstringNoRFC3339 lower bound on creation time
beforestringNoRFC3339 upper bound on creation time
dry_runboolNoPreview without cancelling
labelsobjectNoEvery 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
}
StatusCondition
200Cancel operation completed
400Invalid 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/retry

Request body:

{
  "workflow_id": "deploy",
  "mode": "rerun",
  "after": "2025-01-15T00:00:00Z",
  "dry_run": false
}
FieldTypeRequiredDescription
workflow_idstringYesWorkflow name
modestringYesrerun or replay
afterstringNoRFC3339 lower bound
beforestringNoRFC3339 upper bound
dry_runboolNoPreview 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.

StatusCondition
200Retry operation completed
400Invalid 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/workers

Note 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/workers

Queue

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/queue

Note 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"
}
FieldTypeNotes
task_typestringThe task type – the task.{taskType} subject with the task. prefix stripped.
pendingintegerUnacked message count for this subject right now.
oldest_wait_msinteger, omitted when unavailableHow 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_atRFC3339 timestampWhen this snapshot was taken.
truncatedboolean, omitted when falsePresent 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/queue

Tokens

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/tokens

Request 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/tokens

Response: 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 registers

Health

Telemetry Health

Check service health and telemetry stream status. The health endpoint never returns unhealthy; telemetry information is advisory.

GET /health/telemetry

Response: 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/telemetry

Cluster Health

Report embedded-cluster health from the node you ask. Standalone nodes return {"mode":"standalone","ok":true}.

GET /health/cluster

Response: 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.

RouteResponse
GET /health200 with body ok when NATS is connected and JetStream answers an account-info call; 503 with a plain-text reason otherwise
GET /ready200 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"
StatusMeaning
400Bad request (invalid JSON, validation error, missing fields)
401Unauthorized (invalid approval token)
404Resource not found
405Method not allowed
409Conflict (approval token already consumed, workflow still in use, too many live workflow versions)
500Internal server error
503Service unavailable (probe failing, cluster degraded, token management disabled, log follow limit reached)