HTTP API
dagron exposes two HTTP surfaces with a deliberate boundary: the JWT-gated
dagron-api gateway that browsers, automation and agents use, and the engine's
unauthenticated ops API that stays inside the cluster. This page is every endpoint on both,
the permission model behind them, and the clients that speak them.
On this page
- Two surfaces, one boundary
- What the console calls
- Authentication
- Permissions
- Errors, limits, conventions
- Session & identity
- Runs
- Logs
- The log filter
- Live events (SSE)
- Workflows
- Schedules & backfills
- GitOps repositories
- Environments & secrets
- Datasets & lineage
- Artifacts
- Archived runs
- Dead letters
- Health, metrics, search
- Instance settings
- Fleet & linking
- The engine ops API
- The built-in API explorer
- SDKs
- MCP — the agent surface
- Recipes
Two surfaces, one boundary
There are two APIs because they have different threat models, not because one is a newer version of the other.
dagron-api — the gateway | Engine ops API | |
|---|---|---|
| Prefix | /api/… on PORT (default 8080) | Root-level, on API_ADDR (dagron dev uses 127.0.0.1:8787) |
| Auth | HS256 session JWT or a personal access token | None. It is designed to stay cluster-private |
| Datastore | Postgres only — SSE needs LISTEN/NOTIFY | Whatever the engine was built against, SQLite included |
| Who calls it | The console, your automation, the SDKs, dagron-mcp | Sidecars, probes, a task reporting its own checkpoint |
| Unique to it | Identity, workflows, schedules, backfills, environments, GitOps, archive, artifacts, triage, badges, metrics rollups | OpenAPI 3 + Swagger UI, Prometheus text metrics, effective-config dump, task checkpoints, raw-YAML submit |
Never expose the engine
ops API publicly. It has no authentication by design — anyone who can reach it can submit
and cancel runs. Bind it to localhost or a pod-internal address and put
dagron-api in front for anything that leaves the cluster.
What the console calls
Nothing in the dagron console is privileged: every screen in it is a client of the gateway routes on this page. The quickest way to learn the API is to read a screen you already understand and see which call produced it. This is the run view, mid-flight:
- Run header — name, short id, status, trigger, elapsed.
GET /api/runs/{id}. The same object carriesfailure, so a red header needs no second call to explain itself. - The Live lamp.
GET /api/runs/{id}/stream— SSE off one shared PostgresLISTEN. The page never polls; when it falls behind it is told toresync. - Graph / Timeline / Logs.
GET /api/runs/{id}/graphfor the{nodes[], edges[]}the canvas draws, andGET /api/runs/{id}/logsfor every task's output merged into one attributed stream. - Re-run.
POST /api/runs/{id}/rerun(optionally{from}),/resubmitfor a clean run off the same spec, andGET /api/runs/{id}/specbehind "re-run with changes". - The node you clicked.
GET /api/runs/{id}/tasks/{tid}/logs, tailed with?offset=. The attempt counter beside the status comes from the task row; the passes behind it are…/attempts. - Clear + downstream.
POST /api/runs/{id}/tasks/{tid}/clear— reset this task and its dependent cone, nothing else. - The log filter input and level chips. Not a client-side grep: they are the log-filter query parameters, evaluated on the server. Anything you can click here you can put in a runbook.
- Cancel, retry, approve, reject — the control routes under
Runs, each firing
pg_notifyin-transaction so the engine reacts immediately rather than on its next tick.
The other console screens map the same way: the workflow list is
GET /api/workflows (schedule and run digest already joined in), Runs is
GET /api/runs with its filter parameters, and Metrics is
/api/metrics plus /api/metrics/timeseries. Each screen is walked
through region by region in Get started.
Authentication
Every dagron-api route requires a valid HS256 session JWT, with five exceptions:
/healthz, /readyz, POST /api/login,
POST /api/logout, and GET /api/badges/{name}. Missing, invalid or
expired credentials are a 401.
Two credential types, both presented the same way:
- Session JWT — as the HttpOnly
dagron_sessioncookie (what the browser gets) or asAuthorization: Bearer <jwt>(what an API client sends). - Personal access token — a
dgp_-prefixed string in the sameAuthorization: Bearerheader. It resolves to its owner's live permissions, so revoking a group revokes it everywhere at once.
A token cannot mint its own
replacement. Token management (/api/tokens*) is the one area that requires a
password session: presenting a dgp_ bearer there is a 403. A leaked
token can therefore do the holder's work, but cannot extend its own life or issue siblings.
Plaintext is returned exactly once, at creation — storage keeps only a SHA-256 hash.
$ TOKEN=$(curl -s http://localhost:8080/api/login \ -H 'content-type: application/json' \ -d '{"email":"admin@local","password":"dagron-admin"}' | jq -r .token) $ curl -s http://localhost:8080/api/me -H "Authorization: Bearer $TOKEN" {"sub":"…","email":"admin@local","name":"Administrator","groups":["admin"],"exp":…}
Permissions
Three effective roles, derived from the session's groups claim.
| Role | Group | What it can do |
|---|---|---|
| Viewer | viewer | Reads only. Every mutation is refused by middleware before it reaches a handler — 403 {"error": "viewer role is read-only"}. GET is untouched, and so are login and logout: a viewer who cannot sign in cannot read either. |
| Operator | no groups | The default. Authors and edits workflows, submits and controls runs, manages schedules and backfills. Not read-only — authoring is the role's purpose. |
| Admin | admin | Everything, plus the admin-only routes below. |
What admin gates, and why
| Route | Why it is gated |
|---|---|
POST / GET /api/users | User management. |
/api/settings/notifications* | Defaults hold secret webhook URLs and reroute every run's notifications; the test route makes the server POST outbound. |
PUT /api/settings/dead-letters | Instance-wide retry policy. Reading it needs only a session. |
DELETE /api/workflows/{id} | The one irreversible control action — it cascades to the workflow's schedules. Creating and editing stay open, because a bad edit is recoverable from /versions. |
POST/DELETE /api/git-repos, PUT/DELETE /api/git-repos/{id}/auth | A connected repository writes and (with prune) retires workflows, and its credential is a stored secret nobody can read back. Listing repositories and asking for a sync stay open. |
POST /api/runs/{id}/archive | Destructive: it purges the run from the hot store. |
POST /api/artifacts/rotate, /api/artifacts/sync | Re-keys or drains the whole artifact store. |
POST /api/link/enrol | The one outbound call the instance makes, carrying a join token. |
GET /api/audit Enterprise | The audit trail. Present only in the enterprise build, which records what it let through. |
Errors, limits, conventions
- Error shape. Handlers answer
(status, {"error": "<message>"}). Database failures map to500without leaking internals — the deliberate exception isGET /api/health, which answers200withdb: "error"so that the outage itself stays reportable. - Request bodies are capped at 1 MiB.
- Pagination is
?limit=+?offset=. Lists default to 100 and cap at 500 unless a row below says otherwise; the cap is enforced server-side, so asking for more is clamped, not refused. - CORS is currently permissive — a development posture. Put the gateway behind your own edge before exposing it.
- Timestamps are RFC 3339. Run and task ids are opaque strings; only the search endpoint treats a run id as prefix-matchable.
Session & identity
| Method | Path | Notes |
|---|---|---|
| POST | /api/login | {email, password} → sets the dagron_session cookie and returns {token}. 401 on bad credentials. |
| POST | /api/logout | Clears the cookie. |
| GET | /api/me | The session claims — {sub, email, name, groups, exp}. |
| POST | /api/users | admin. {email, password, name, groups[]} → 201 {id}. 400 password under 8 characters, 409 duplicate. |
| GET | /api/users | admin. [{id, email, name, groups[], created_at}] — never hashes. |
| POST | /api/tokens | Mint a personal access token: {name, expires_in_days?} → 201 {id, name, prefix, token, expires_at}. The token plaintext appears only here. 403 if the caller presented an API token; 400 empty name or an out-of-range expiry. |
| GET | /api/tokens | The caller's own tokens — [{id, name, prefix, created_at, expires_at, last_used_at, revoked_at}]. No hashes, no plaintext. |
| DELETE | /api/tokens/{id} | Revoke → 204, idempotent. 404 unknown or not the caller's. |
| GET | /api/audit Enterprise | admin. ?limit=&offset= → newest-first audit entries. Absent from the open build, which has no audit_log table. |
Runs
| Method | Path | Notes |
|---|---|---|
| GET | /api/runs | ?status=&name=&trigger=&limit=&offset= → [{id, definition_id, status, created_at, finished_at, name, trigger_kind, clock_confidence}]. trigger filters by manual/schedule/backfill; trigger_kind is derived from the schedule stamp or backfill ledger, with no schema change behind it. |
| POST | /api/runs | {yaml, parameters?} → 201 {run_id}. Invalid YAML, cycles, and duplicate or unknown task names are a 400 — validated before anything is persisted. parameters override the spec's declared defaults; keys the spec never references are ignored. Send an Idempotency-Key header and a repeat of the same submit returns the same run_id with 200 instead of creating a second run. 409 if that key was already used for a different body, or an identical submit is still in flight. An explicitly empty key is a 400, so an absent header and an invalid one stay distinguishable. Keys are printable ASCII, ≤255 chars, scoped to the caller, and live as long as the run they name. 429 when the workflow is at its own max_active_runs — without a Retry-After, unlike the engine's ops API: every handler on this path shares one plain error type that carries no headers. A declared task budget or external-call ceiling the spec exceeds is a deliberate 400 naming the budget, not a parse error. |
| GET | /api/runs/{id} | Run detail plus tasks[] ({id, name, status, attempt, output, scheduled_at, finished_at}). failure summarises why the run broke without a second call: {task_id, task_name, attempt, failed_tasks, message, truncated}, or null. It names the earliest-finished failed task — the likeliest cause rather than a downstream casualty — and message is that task's output tail, clipped to 20 lines / 4 KB. A run killed by run_timeout_secs reports its own reason with task_id: null. Present as soon as a task fails, so a still-running run can carry one. |
| GET | /api/runs/{id}/wait | ?timeout_secs= (default 30, clamped 1–600). Long-polls to a terminal state → {run_id, status, finished, result, failure}, where result is the result_from task's output. A timed-out wait is a 200 with finished: false, not an error. The task rows behind failure are read only when there is a failure, so a succeeding wait costs nothing extra. |
| GET | /api/runs/{id}/spec | The stored, un-expanded YAML this run was created from → {yaml, name}. Backs "re-run with changes", and is the only way a caller that submitted ad-hoc YAML recovers what it ran. |
| GET | /api/runs/specs?ids=a,b,c | The same YAML for many runs in one call, grouped by content → [{yaml, name, run_ids}]. Every run snapshots its own definition, so N runs of an unedited workflow are N identical specs; collapsing them is both the saving and the answer to "did this change?". Unknown ids are absent, not a 404, so a page containing an archived run still answers for the rest. 400 for no ids or more than 200. |
| GET | /api/runs/{id}/graph | The DAG as {nodes[], edges[]} — what the console's graph view draws. |
| POST | /api/runs/{id}/cancel | → {cancelled: n}. |
| POST | /api/runs/{id}/rerun | Optional {from?} to resume from a task rather than the beginning → {run_id, rerun}. |
| POST | /api/runs/{id}/resubmit | A fresh run from the same spec → 201 {run_id}. |
| POST | /api/runs/{id}/tasks/{tid}/retry | Retry one task → {retried}. |
| POST | /api/runs/{id}/tasks/{tid}/clear | Clear a completed task and its downstream cone → {run_id, task_id, cleared}. 409 if the task is not completed. |
| POST | /api/runs/{id}/tasks/{tid}/approve | Approve a type: approval gate — the task succeeds and the DAG proceeds. 409 if it is not awaiting approval. |
| POST | /api/runs/{id}/tasks/{tid}/reject | Reject the gate — the task fails and all_success downstream skips. |
| POST | /api/runs/{id}/triage | Record what a human decided about a failed run: {state, note?} where state is acknowledged, resolved or ignored. Three states rather than one flag, because "we have decided not to care" is not the same answer as "fixed". Re-triaging overwrites; the note is what is worth reading in three months. |
| DELETE | /api/runs/{id}/triage | Clear the decision, putting the run back in the attention queue. |
| POST | /api/runs/{id}/archive | admin, destructive. See Archived runs. |
Control mutations fire pg_notify('task_events', run_id) inside the
transaction, so the engine wakes immediately rather than on its next tick.
Logs
| Method | Path | Notes |
|---|---|---|
| GET | /api/runs/{id}/logs | Workflow logs — every task's output merged into one attributed stream → {run_id, tasks[], lines[], total, matched, truncated, eof, filtered, limit}. ?task= / ?status= narrow which tasks are read (name or id, repeatable or CSV); the log filter then narrows which lines survive. total and matched are counted before the line cap, so a truncated view always says how much it hid. |
| GET | /api/runs/{id}/tasks/{tid}/logs | One task, for tailing → {task_id, name, status, attempt, output, offset, next_offset, eof, total, matched, truncated, filtered, lines?}. ?offset= returns only output past that character offset: poll with ?offset=next_offset until eof. The filter applies within the slice while next_offset keeps counting raw text, so filtering and tailing compose. |
| GET | /api/runs/{id}/tasks/{tid}/attempts | The iterations the log view cannot show. task_runs.output is one column every attempt overwrites, so the row above returns the last pass of a repeat: loop and the attempt a retried task finished on. Each entry is {attempt, reason: iteration|failed, output, retention_truncated, finished_at, total, matched, truncated, lines?}, oldest first. current_attempt is the one on the row and is deliberately not in the list; evicted means earlier attempts aged out of DAGRON_ATTEMPT_LOG_KEEP. |
The log filter
Both log endpoints — on the gateway and on the engine ops API — accept the same grammar, applied server-side. A run's captured output can be hundreds of megabytes; shipping all of it so the client can grep is not a filter, it is a download.
The filter is a set of predicates, all of which must hold for a line to survive. Sending none of them returns unfiltered output, byte for byte as before filtering existed.
| Parameter | Meaning |
|---|---|
q | Keep lines containing this text. Repeatable — all terms must match. |
exclude | Drop lines containing this text. Repeatable — none may match. |
regex | Keep lines matching this regular expression (max 512 bytes). |
level | Keep only these levels, repeatable or CSV: error, warn, info, debug, trace, plain. |
case | 1 to match case-sensitively. The default is insensitive. |
context | Also keep N unmatched lines either side of a match — grep -C. |
limit | Maximum lines returned. Default 2000, hard cap 50000; 0 means the default. |
tail | 1 keeps the last lines when the cap applies, rather than the first. |
# Every error line in the run, with a line of context either side. $ curl -s "$API/api/runs/$RUN/logs?level=error&context=1" \ -H "authorization: Bearer $DAGRON_TOKEN" | jq -r '.lines[].text' # Just the extract task, minus healthcheck noise, last 200 lines. $ curl -s "$API/api/runs/$RUN/logs?task=extract&exclude=healthz&limit=200&tail=1" \ -H "authorization: Bearer $DAGRON_TOKEN"
These are gateway routes, so they need a session JWT or a personal access token;
drop the header when running the same query against the engine's own
ops API, which has no auth. Over anything but loopback, use
https:// — a bearer token on plain HTTP is readable and replayable in transit.
Four things matter when reading the response:
- Levels are inferred, not recorded. Task output is whatever the command printed, so
the level comes from scanning the head of each line. A line that never says "error" cannot
be found by asking for errors, and
plain— no recognisable level token — is the common case. - Line numbers are positions in the unfiltered output, so a filtered line can still be located in the raw log.
- Context lines come back with
matched: falseso a client can dim them. Context you cannot tell apart from a hit overstates what the filter found. - An invalid filter is a
400naming the reason — an uncompilable regex, an unknown level, a non-numeric bound. Ignoring it would return an unfiltered wall of text that the caller would read as "nothing was filtered out".
The grammar lives in one parser, so a filter typed into the console, sent by an SDK, or written into a runbook all mean the same thing.
Live events (SSE)
| Method | Path | Notes |
|---|---|---|
| GET | /api/runs/{id}/stream | One run's task events. A single shared Postgres LISTEN task_events fans out to per-run streams; each event is JSON. |
| GET | /api/events/stream | Account-wide activity: every run's task events, each carrying {run_id}, off the same shared listener. This is what the list pages' live mode consumes. |
Handle resync.
When a client falls behind, the broadcast lags and the server sends
event: resync / data: lagged instead of silently dropping events.
That is an instruction, not a warning: refetch current state, then keep streaming. A client
that ignores it will quietly show stale task statuses forever.
Workflows
| Method | Path | Notes |
|---|---|---|
| GET | /api/workflows | The list, each row enriched with its schedule and a recent-run digest, so the console's inventory is one request. Every row carries tags: [], parsed from the stored spec on read so they always reflect the current definition. ?tag= returns only workflows carrying that tag. |
| POST | /api/workflows | {name?, spec, description?} → 201. 409 duplicate name. |
| GET | /api/workflows/{id} | Read one, including its tags. |
| PUT | /api/workflows/{id} | Update. First records the prior definition as a workflow_versions row, then overwrites the head — so an edit is never a loss. 409 on a name clash, or if the workflow is git-managed (see below). |
| DELETE | /api/workflows/{id} | admin. Irreversible, and cascades to this workflow's schedules. Prefer state: retired, which stops it and keeps them — the message on a 403 says so. |
| POST | /api/workflows/{id}/run | Optional {parameters?} → 201 {run_id, workflow_id}. 409 if the workflow is paused or retired — only active starts a run. A request with no Content-Type runs the stored spec as-is. |
| POST | /api/workflows/{id}/state | {state} — active, paused or retired. Both non-active states refuse to run, enforced in the scheduler and at /run, and leave schedules untouched. retired also hides the workflow from the default listing. |
| GET | /api/workflows/{id}/versions | Append-only definition history, newest first → [{id, version, name, spec, created_at, created_by}]. A version is recorded on create, on every PUT, and on every git sync that changes the spec (created_by = git:<repo>@<rev>, or bundle:…), so v1 is the original. |
| GET | /api/workflows/{id}/runs | This workflow's run history, same row shape as /api/runs. Runs are matched by definition name — the only linkage that exists, since each run snapshots its own definition row rather than holding a foreign key. Renaming a workflow therefore starts a fresh history. |
| POST | /api/workflows/{id}/sync-to-git | Open a pull request carrying the spec → {pr_url, branch, path}. 501 until GITHUB_TOKEN and GIT_REPO are set; 502 on forge errors. |
| POST | /api/workflows/bundle | Apply a signed workflow bundle: {manifest_b64, signature_b64, files[]} → {bundle, version, digest, provenance, applied[]}. Verification fails closed — 501 when DAGRON_BUNDLE_PUBKEYS is unset, because there is no unsigned path; 400 on a bad signature, an unlisted or extra file, a hash mismatch, or a spec that does not validate. Nothing is persisted unless the whole bundle is good, and it is applied in one transaction. |
| GET | /api/badges/{name} | Unauthenticated. A shields-style flat SVG of that workflow's latest outcome. Badges live in READMEs that cannot send an auth header, so the response reveals only a status label — and no runs covers both "unknown workflow" and "no runs yet", so it never discloses whether a workflow exists. Always 200. |
Git-managed workflows refuse
edits. A workflow synced from a connected repository answers 409 {error, repo,
sync_to_git} on PUT and DELETE, naming the repository and the
route that opens a pull request — so the change goes through review. An admin may pass
?force=true; the forced edit then counts as that repository's drift
until the next sync puts git's definition back. POST /state and
/run are unaffected.
Schedules & backfills
| Method | Path | Notes |
|---|---|---|
| GET | /api/schedules | ?workflow_id= to scope to one workflow. |
| POST | /api/schedules | {workflow_id, cron_expr, enabled?, catchup?, catchup_window_secs?, catchup_max_runs?}. 400 on a cron expression that does not parse. |
| PUT DELETE | /api/schedules/{id} | Update or remove. |
| POST | /api/schedules/{id}/backfill | Synchronous backfill: {from, to, max_runs?} → {scheduled, skipped, from, to, run_ids}, materialised in one call with a hard cap of 1000. |
| POST | /api/backfills | Paced backfill job: {schedule_id, from, to, max_runs?} → 201 job row, which the engine paces out over time (cap 100k). Use this one for a real catch-up — the synchronous route above would submit the whole range at once. 400 on a bad range, cron or spec, or a window with no fire times. |
| GET | /api/backfills | ?schedule_id=&limit= → {id, schedule_id, status, requested, fired, cursor, …}. |
| GET | /api/backfills/{id} | One job, for monitoring fired against requested. |
| POST | /api/backfills/{id}/cancel | Stop pacing → {id, cancelled}. 409 if it already finished. |
GitOps repositories
| Method | Path | Notes |
|---|---|---|
| GET | /api/git-repos | Any session. Also returns worker_online, credentials_configured, and drift — how many of this repository's workflows were force-edited since the last sync. |
| POST | /api/git-repos | admin. {url, branch?, path?, auto_sync, prune?, auth?} → 201. url may be https://, ssh:// or scp-style git@host:owner/repo; an https URL may not embed credentials. prune (default false): after a sync with no file errors, workflows this repo manages whose file is gone are set to retired — history kept, and the file returning revives them. |
| DELETE | /api/git-repos/{id} | admin. → 204. |
| POST | /api/git-repos/{id}/sync | Any session — it only hurries along the poll auto_sync already performs. |
| PUT DELETE | /api/git-repos/{id}/auth | admin. Set, rotate or remove the credential: {kind: "none"|"token"|"ssh", username?, token?, ssh_private_key?, known_hosts?}. token requires an HTTPS URL and ssh an SSH one — the mismatch is a 400 rather than a credential that could never work. Write-only: the secret is stored AES-256-GCM encrypted and never returned; reads get auth_kind, auth_username, auth_known_hosts and a non-secret auth_hint. Passphrase-protected keys are rejected — the worker has no terminal to be prompted at. 503 when secret encryption is unconfigured. |
Environments & secrets
| Method | Path | Notes |
|---|---|---|
| GET POST | /api/environments | List or create {name, description?, variables{}}. Responses carry variables plus secret_names — values are write-only and never returned. 409 duplicate. |
| PUT DELETE | /api/environments/{id} | Update the description and variables, or delete the environment and its secrets. The name is immutable — specs reference it. |
| PUT | /api/environments/{id}/secrets/{name} | {value} → 204, encrypted immediately with AES-256-GCM under DAGRON_ENV_SECRET_KEY. 503 when no key is configured — there is no plaintext fallback. |
| DELETE | /api/environments/{id}/secrets/{name} | → 204. |
The key must be set identically on dagron-api, which encrypts on
write, and on the engine, which decrypts at dispatch.
Datasets & lineage
Read-only views of the dataset registry and its append-only update ledger — the cross-workflow
trail behind produces: and on_datasets:. A dataset is updated by a
task, never by these routes.
| Method | Path | Notes |
|---|---|---|
| GET | /api/datasets | The registry, newest-updated first → [{uri, updated_at, last_run_id, last_task, updates, consumers[]}]. consumers are the on_datasets: subscriber workflows a producer wakes, resolved in one extra query rather than one per row. |
| GET | /api/datasets/events | The lineage ledger, newest first → [{id, uri, workflow, run_id, task_id, task_name, source, at}]. ?uri= scopes the trail to one dataset. |
Artifacts
The programmatic artifact channel. Bytes are envelope-encrypted at rest when a KEK provider is
configured. All routes need a session, and answer 503 when
DAGRON_ARTIFACT_DIR is unset.
| Method | Path | Notes |
|---|---|---|
| PUT | /api/runs/{run_id}/artifacts/{task}/{name} | Stream the request body into the store, encrypted per chunk when a KEK is set → 201 plus the backend locator. Size-capped by DAGRON_ARTIFACT_MAX_BYTES. |
| GET | /api/runs/{run_id}/artifacts/{task}/{name} | Stream the decrypted bytes → 200 application/octet-stream. A mid-stream decrypt or IO error aborts the connection rather than returning a truncated body as if it were whole. |
| GET | /api/runs/{run_id}/artifacts/{task}/{name}/exists | → {exists: bool}. |
| POST | /api/artifacts/rotate | admin. Re-key every artifact from the previous KEK (*_OLD env) to the current one — it rewraps the per-object data key, with no payload re-encryption → {rotated: N}. 409 if a rotation is already running, 400 if no previous KEK is configured. |
| POST | /api/artifacts/sync | admin. Drain a tiered store to its remote tier now, under the daily uplink budget → {moved: N}. The on-demand path for a unit that has just reconnected; a periodic loop does the same on a timer. {moved: 0} for a store that is not tiered. |
Quiesce writes during a
rotation. The single-flight lock stops two rotations overlapping, but does not coordinate
with normal artifact PUTs. Rotation rewraps each object read-then-write, so a
PUT to the same key landing between the read and the write is overwritten by the
rewrapped older value — a silent lost update. Pause artifact writes, or rotate in a
maintenance window, until a store-level compare-and-swap closes that gap.
Archived runs
History past the hot window. The list reads only the archived_runs index; the
detail endpoint fetches the run's dagron.run-archive.v1 document from the archive
sink — so dagron-api must see the same GC_ARCHIVE_DIR /
GC_ARCHIVE_URL as the engine.
| Method | Path | Notes |
|---|---|---|
| GET | /api/archive/runs | ?name=&limit=&offset=, newest-finished first → [{run_id, name, status, created_at, finished_at, archived_at, compacted_at, parquet_path}]. |
| GET | /api/archive/runs/{id} | The full archive document — {format, run, tasks[], outbox_events[], archived: true, index}. 404 not in the index; 410 once compacted to Parquet, with parquet_path in the body so you query the analytics dataset instead; 502 sink unreachable or unconfigured. |
| POST | /api/runs/{id}/archive | admin, destructive. Archive one terminal run now rather than waiting for the retention window: export, verify it landed, index it, then purge from the hot store → {run_id, archived: true, purged}. 409 if the run is not terminal — archiving a live run would purge state the scheduler is still driving. 501 when no sink is configured, because without one the purge would be a delete wearing a kinder word. 502 if the sink or index write failed, and the run stays in the hot store. Same fail-closed order as the GC sweep: write, index, then purge. |
Dead letters
| Method | Path | Notes |
|---|---|---|
| GET | /api/dead-letters | ?limit= → [{id, payload, error, source, failures, first_seen_at, last_error_at}]. |
| POST | /api/dead-letters/{id}/redrive | → {run_id, redriven_from}. The claim deletes the row first, so a capacity refusal re-parks the payload as a new dead letter and answers with its id: 503 + Retry-After: 1 with {dead_letter_id, workflow, max_active_runs, active_runs} at the workflow's active-run cap, or 507 with {dead_letter_id, free_bytes, min_free_bytes} below the free-disk floor. |
| DELETE | /api/dead-letters/{id} | → 204. |
Retry the returned id, never
the one you sent. Both retryable refusals above hand back a new
dead_letter_id. The id you posted was consumed by the claim and no longer exists,
so a client that retries its original id will keep getting 404 and conclude the
payload was lost when it is sitting in the queue under a new name.
Health, metrics, search
| Method | Path | Notes |
|---|---|---|
| GET | /healthz | No auth. Bare liveness — 200 ok, no database touched. |
| GET | /readyz | No auth. 200 ready only when a pooled database round trip answers inside DAGRON_READY_TIMEOUT_MS (default 500 ms), else 503 with the reason. Point your orchestrator's readiness probe here. The SSE listener's state is advisory: while it resubscribes the body reads ready (event listener degraded) but stays 200, because its failure is fleet-correlated — gating on it would empty the Service rather than reroute. DAGRON_READY_REQUIRE_LISTENER=1 opts into strict gating. |
| GET | /api/health | Rich health for the status widget: {api, edition, config_fingerprint, event_listener, db, scheduler_leader, leader_holder, active_runs, awaiting_approvals, dead_letters}. config_fingerprint is the fleet-drift hash; event_listener is where a degraded live-event bridge is visible. Never 500s — a database outage answers db: "error", because a health endpoint that dies with the thing it reports on is not a health endpoint. |
| GET | /api/metrics | JSON rollup: {runs_by_status[], tasks_by_status[], dead_letters}. The Prometheus text endpoint is on the engine, not here. |
| GET | /api/metrics/timeseries | ?days= (default 14, clamped 1–90) &name= → per-day buckets [{day, succeeded, failed, cancelled, active, avg_duration_secs, max_duration_secs}]. |
| GET | /api/approvals | Every task parked in awaiting_approval, oldest first → [{run_id, task_id, task_name, workflow_name, since}] — the human-in-the-loop worklist. |
| GET | /api/search | ?q=&limit= (per category, default 8, max 20) → {query, workflows[], runs[], schedules[]}. Capped and parameterised: run ids match by prefix, names by substring, and LIKE wildcards are escaped. This is the ⌘K palette's backend. |
Instance settings
| Method | Path | Notes |
|---|---|---|
| GET PUT | /api/settings/notifications | admin. Instance-wide defaults {slack_enabled, slack_webhook_url, slack_on[], webhook_enabled, webhook_url, webhook_on[]}, which the engine merges into every run's notify dispatch. 400 on a bad URL or event name. |
| POST | /api/settings/notifications/test | admin. Send a test message to each enabled target in the body → per-target outcome {slack, webhook}. One failing target never fails the whole call. |
| GET | /api/settings/dead-letters | Any session. {max_attempts}, or null when unset — the engine then keeps using its DEAD_LETTER_MAX_ATTEMPTS env value. |
| PUT | /api/settings/dead-letters | admin. {max_attempts}, minimum 1. Takes effect on the next ingestion failure — the engine reads it on the failure path rather than caching it at startup, so there is no restart and no window where the console disagrees with what is running. Only the retry count is settable: STREAM_DLQ_PATH is a path on the engine's own host and stays deployment configuration. |
Fleet & linking
These three routes exist in every build on purpose. A dead 404 at the
moment someone acquires a second machine teaches nothing, so the open build answers a signpost
naming the edition and the single-unit path instead.
| Method | Path | Notes |
|---|---|---|
| GET | /api/fleet | The units this deployment manages. Unit enrolment, cohorts, staged bundle rollout and selector fan-out are Enterprise; the open build answers 403 with the single-unit path — run the engine on the machine and drive it through this API, SOURCE=dir, or GitOps sync. |
| GET | /api/link | Whether this instance belongs to a fleet, and what its offline licence says. Reads DAGRON_FLEET_URL and the presence (never the value) of DAGRON_FLEET_TOKEN, plus outbox evidence — last delivered, pending, dead — and verifies the licence with the same rule the control plane applies at startup, so a licence this route calls invalid is the reason the plane refuses to boot. |
| POST | /api/link/enrol | admin. Enterprise The one outbound call: POST {control_plane_url}/units/enrol with the join token and serial you supply. Redirects are refused — a 30x would post the join token somewhere the operator did not name — with 5 s to connect and 10 s in total, and the plane's own 401/402/409 passed through with the status it chose. The unit credential is returned once, rendered as env, Helm values and systemd; nothing is stored on the instance, so the operator applies it themselves and restarts. |
The engine ops API
Built with the default ops feature and bound at API_ADDR.
No authentication — keep it on localhost or pod-internal. It is self-describing:
OpenAPI 3 at /openapi.yaml and /openapi.json, Swagger UI at
/docs. Unlike the gateway it works against any datastore the engine was built
with, SQLite included, which is what makes dagron dev possible.
| Method | Path | Notes |
|---|---|---|
| GET | / · /console | A small built-in console served by the engine itself — enough to see and submit runs on a deployment that has no dagron-api in front of it. Mounted unless DAGRON_CONSOLE is off/false/0/no; every API path below is identical either way, so turning it off changes nothing for an existing client. |
| GET | /healthz | ok, no database — liveness only. |
| GET | /readyz | ready after a datastore round trip inside the DAGRON_READY_TIMEOUT_MS budget, else 503 datastore unreachable or 503 datastore probe timed out. |
| GET | /config | Effective configuration: every registered knob's value with secrets redacted, its source (env / file / profile / default), the config file and profile in use, and the fleet fingerprint. The HTTP face of dagron config, and the fastest way to settle "what is this process actually running with". |
| GET | /metrics | Prometheus text: process counters (dispatched, succeeded, failed, retried), the reconcile-tick histogram, and live database gauges. |
| GET | /openapi.yaml · /openapi.json · /docs | The embedded spec and Swagger UI. |
| GET | /runs | ?status=&limit= (default 50, clamped 1–1000). |
| POST | /runs | Raw YAML body, not JSON-wrapped → 201 {run_id}. 400 on an invalid DAG. Four distinct admission refusals, each saying what to wait for rather than just "later": 503 + Retry-After: 60 when the installation's admission gate is closed (nothing the caller does differently will get in, and it will not clear inside a second); 429 + Retry-After: 1 at the in-flight run cap (MAX_INFLIGHT_RUNS), at the in-flight task cap (MAX_INFLIGHT_TASKS, which counts this run's tasks before admitting it), and at a workflow's own max_active_runs; and 507 + Retry-After: 1 when the datastore is under DAGRON_MIN_FREE_BYTES — a full disk is a storage condition, not a rate one, and a client that read it as "slow down" would keep offering work to the unit whose disk needs relief. Each body names its own numbers. ?wait=true (with ?timeout_secs=) makes it synchronous: 200 {run_id, status, finished, result} instead of 201. |
| GET | /runs/{id} | {run, tasks}. |
| GET | /runs/{id}/wait | ?timeout_secs= (default 30, clamped 1–600) → {run_id, status, finished, result}. A timed-out wait is 200 with finished: false. |
| GET | /runs/{id}/logs | The whole run's output merged, attributed and filtered. ?task= / ?status= scope which tasks are read; the log filter scopes which lines survive. |
| GET | /runs/{id}/tasks/{task_id}/logs | One task's output, with the same ?offset= tailing contract as the gateway. |
| GET | /runs/{id}/tasks/{task_id}/attempts | Per-attempt output for retries and repeat: iterations. |
| POST | /runs/{id}/cancel | {run_id, cancelled: true}. 409 if not cancellable. |
| POST | /runs/{id}/rerun | Optional {from?} → {run_id, rerun}. |
| POST | /runs/{id}/tasks/{task_id}/clear | Clear a completed task and its downstream cone. 409 if not completed. |
| POST | /runs/{id}/tasks/{task_id}/approve · /reject | Resolve an approval gate → {run_id, task_id, resolution}. |
| POST | /runs/{id}/tasks/{task_id}/checkpoint | Unique to this surface. A running task reports its committed checkpoint — {uri, marker?} — typically using the DAGRON_RUN_ID / DAGRON_TASK_ID injected into it. The pointer survives retries, and the next attempt receives DAGRON_RESUME_FROM. This is why an eight-hour training step does not restart from zero. 409 if the task is not running. |
| GET | /dead-letters | {dead_letters: […]}. |
| POST | /dead-letters/{id}/redrive | Same contract as the gateway, including the 503/507 capacity refusals that hand back a replacement dead_letter_id. |
| DELETE | /dead-letters/{id} | {id, deleted: true}. |
| GET | /datasets | ?limit= (default 100, clamped 1–1000) → {datasets: [{uri, updated_at, last_run_id, last_task, updates}]}. |
| GET | /datasets/events | The lineage ledger. ?uri= narrows to one dataset; source is task (a produces: success) or external, and id is the monotonic cursor sensors and triggers key off. |
| POST | /datasets/events | Record an external dataset update — a producer outside dagron such as CDC or an object-store notification — waking dataset sensors and on_datasets: triggers. Not implemented in this build, which returns 403 with a signpost; its datasets update via produces: tasks. |
$ curl -s -X POST localhost:8787/runs --data-binary @examples/simple_dag.yaml {"run_id":"…"} $ curl -s localhost:8787/runs/<run_id> | jq .run.status # or in one call, synchronously $ curl -s -X POST "localhost:8787/runs?wait=true&timeout_secs=60" \ --data-binary @examples/simple_dag.yaml | jq
The built-in API explorer
The ops API documents itself. Point a browser at /docs on
API_ADDR — http://127.0.0.1:8787/docs under
dagron dev — and you get a Swagger UI over the engine's own OpenAPI 3 document.
The assets are vendored into the binary, not fetched from a CDN, so the explorer works
on an air-gapped host exactly as it does on a laptop.
/docs, with POST /runs opened.- Title, version and the OAS badge. Read from the live document, so what you are looking at is the spec this binary serves — not a published copy that may be a release behind.
- The
/openapi.yamllink under the title is the machine-readable document itself, also at/openapi.json. Point a client generator at it and the SDK you get matches the running engine. - The description states the security posture in the document, not just in these
docs: no authentication of its own, bind
API_ADDRto localhost or a trusted network, and putdagron-apior a proxy in front before exposing it. - Servers. The base URL requests go to. It follows wherever you set
API_ADDR. - Tag groups — runs, dead-letters, datasets, ops — the same grouping as the table above, so the two are read together.
- Each operation row carries its method and path. Collapsed, the page is a one-screen index of the whole surface.
- An expanded operation gives the prose the endpoint table cannot: here,
POST /runsexplains that the raw body is stored verbatim as the workflow definition, and that admission control sheds with429plusRetry-AfteronceMAX_INFLIGHT_RUNSis reached. - Try it out issues the request against the engine you are pointed at — a real
submit, a real cancel. Convenient on
dagron dev; a reason not to leave this surface reachable from anywhere you would not accept an unauthenticated write.
There is no equivalent
explorer on dagron-api: that surface is authenticated, and its UI is the console.
Use this page, the SDKs, or the MCP tools against the gateway, and keep the
explorer for the engine it ships with.
SDKs
Two clients cover the same ground, method for method: a builder over the engine's whole
TaskSpec — every task kind, fan-out, sensors, approval gates, templates — and a
Client over the whole gateway surface. They emit JSON, which is valid dagron input
because JSON is a YAML subset. Their versions track the API version they speak to, so
0.9.x means "covers the 0.9 gateway".
| SDK | Package | Notes |
|---|---|---|
| Python | dagron-sdk | Standard library only — no dependencies to vet. |
| TypeScript | @dagron/sdk | ESM, zero dependencies, hand-written .d.ts. |
from dagron import Dag, Client, Recipe # An image built by the workflow that needs it, not by a pipeline elsewhere. recipe = Recipe("etl", "python:3.12-slim", pip=["duckdb==1.1.3"]) dag = Dag("nightly") extract = dag.task("extract", image=recipe, command=["python", "/app/extract.py"]) dag.task("report", image=recipe, command=["python", "/app/report.py"], depends_on=[extract]) api = Client.from_env() # DAGRON_API_URL + DAGRON_TOKEN run_id = api.submit_run(dag) result = api.wait_run(run_id) # blocks server-side, not in a poll loop
import { Dag, Client } from "@dagron/sdk"; const dag = new Dag("etl"); const extract = dag.task("extract", { image: "alpine", command: ["echo", "hi"] }); dag.task("load", { image: "alpine", command: ["true"], dependsOn: [extract] }); const api = Client.fromEnv(); const runId = await api.submitRun(dag); await api.waitRun(runId); await api.approveTask(runId, "review-gate"); // type: approval gates
Passing a Recipe wherever a task takes an image adds a build task, makes the task
depend on it, and fills in the image reference — which is known at author time because it is
derived from the recipe. That is also why re-submitting an unchanged recipe finds the image
already built rather than building it again.
MCP — the agent surface
dagron-mcp fronts the gateway — never the engine ops API — over the Model
Context Protocol on stdio. It is the callee, not the agent: it holds no model and runs no
loop. Two variables configure it: DAGRON_API_URL (default
http://localhost:8080) and DAGRON_MCP_TOKEN, a session JWT or
personal access token sent as Authorization: Bearer. It logs to stderr so stdout
carries only protocol messages.
| Group | Representative tools |
|---|---|
| Drive runs | dagron_submit_run, dagron_list_runs, dagron_get_run, dagron_wait_run, dagron_cancel_run |
| Registered workflows | dagron_list_workflows (with tag), dagron_get_workflow, dagron_list_workflow_runs, dagron_list_workflow_versions |
| Recover | dagron_rerun_run, task retry and clear, dead-letter redrive |
| Approval gates | dagron_list_approvals, approve, reject |
| Read | dagron_get_run_logs and dagron_get_task_logs (the full log filter), dagron_get_run_graph, dagron_get_run_spec, dagron_get_artifact, dagron_artifact_exists |
| Observe | dagron_get_health, dagron_get_metrics, dagron_get_metrics_timeseries, dagron_search, dagron_get_run_events — a bounded read of the SSE stream, so an agent can watch without holding a socket open forever |
| Lineage & archive | dagron_list_datasets, dagron_get_dataset_events, dagron_list_archived_runs, dagron_get_archived_run |
Identity, credential material, admin user management and instance settings are deliberately not tools — an agent drives workflows, it does not mint its own tokens or rewrite instance policy. A read-only mode is available for agents that should look but not touch.
Recipes
Run a saved workflow from CI and fail the job if it fails
$ RUN=$(curl -sf $API/api/workflows/$WF/run \ -H "authorization: Bearer $DAGRON_TOKEN" \ -H 'content-type: application/json' \ -d '{"parameters":{"date":"2026-09-22"}}' | jq -r .run_id) $ STATUS=$(curl -sf "$API/api/runs/$RUN/wait?timeout_secs=600" \ -H "authorization: Bearer $DAGRON_TOKEN" | jq -r .status) $ [ "$STATUS" = succeeded ] || { \ curl -sf "$API/api/runs/$RUN/logs?level=error&context=2" \ -H "authorization: Bearer $DAGRON_TOKEN" | jq -r '.lines[].text'; \ exit 1; }
Use a personal access token here, not a password session: it carries its owner's
live permissions, can be revoked on its own, and cannot mint a replacement if the CI runner
leaks it. Point $API at an https:// endpoint, or at a loopback
address inside an already-encrypted tunnel — every call above puts a bearer token in a request
header, and on plain HTTP across a network that token is readable and replayable by anyone on
the path. Plain http:// is for localhost only.
Tail one task's output while it runs
$ OFF=0 $ while :; do R=$(curl -s "$API/api/runs/$RUN/tasks/$TID/logs?offset=$OFF" \ -H "authorization: Bearer $TOKEN") echo -n "$(jq -r .output <<<"$R")" OFF=$(jq -r .next_offset <<<"$R") [ "$(jq -r .eof <<<"$R")" = true ] && break sleep 1 done
Submit safely from a retrying client
Give the submit an Idempotency-Key derived from the work, not from the attempt —
a logical date, a batch id, a content hash. A network timeout then costs you a retry, not a
duplicate run: the second request returns the first run's id with 200.
The endpoint tables on this
page are generated from the routers in crates/dagron-api/src/main.rs and
crates/dagron-engine/src/api.rs. When a route changes, that is the source of
truth — and the engine's own /openapi.yaml is always current for the ops surface.
The full markdown reference, including request and response schemas endpoint by endpoint, is
API.md on GitHub.