dagron/ docs
Reference

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.

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 gatewayEngine ops API
Prefix/api/… on PORT (default 8080)Root-level, on API_ADDR (dagron dev uses 127.0.0.1:8787)
AuthHS256 session JWT or a personal access tokenNone. It is designed to stay cluster-private
DatastorePostgres only — SSE needs LISTEN/NOTIFYWhatever the engine was built against, SQLite included
Who calls itThe console, your automation, the SDKs, dagron-mcpSidecars, probes, a task reporting its own checkpoint
Unique to itIdentity, workflows, schedules, backfills, environments, GitOps, archive, artifacts, triage, badges, metrics rollupsOpenAPI 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:

The dagron run view: a workflow DAG drawn as nodes coloured by status, with a task side panel showing attempt count and filtered log output
The workflow UI in action — and every region of it is one of the calls below.
  1. Run header — name, short id, status, trigger, elapsed. GET /api/runs/{id}. The same object carries failure, so a red header needs no second call to explain itself.
  2. The Live lamp. GET /api/runs/{id}/stream — SSE off one shared Postgres LISTEN. The page never polls; when it falls behind it is told to resync.
  3. Graph / Timeline / Logs. GET /api/runs/{id}/graph for the {nodes[], edges[]} the canvas draws, and GET /api/runs/{id}/logs for every task's output merged into one attributed stream.
  4. Re-run. POST /api/runs/{id}/rerun (optionally {from}), /resubmit for a clean run off the same spec, and GET /api/runs/{id}/spec behind "re-run with changes".
  5. 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.
  6. Clear + downstream. POST /api/runs/{id}/tasks/{tid}/clear — reset this task and its dependent cone, nothing else.
  7. 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.
  8. Cancel, retry, approve, reject — the control routes under Runs, each firing pg_notify in-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:

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.

Sign in, then call
$ 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.

RoleGroupWhat it can do
ViewerviewerReads 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.
Operatorno groupsThe default. Authors and edits workflows, submits and controls runs, manages schedules and backfills. Not read-only — authoring is the role's purpose.
AdminadminEverything, plus the admin-only routes below.

What admin gates, and why

RouteWhy it is gated
POST / GET /api/usersUser 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-lettersInstance-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}/authA 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}/archiveDestructive: it purges the run from the hot store.
POST /api/artifacts/rotate, /api/artifacts/syncRe-keys or drains the whole artifact store.
POST /api/link/enrolThe one outbound call the instance makes, carrying a join token.
GET /api/audit EnterpriseThe audit trail. Present only in the enterprise build, which records what it let through.

Errors, limits, conventions

Session & identity

MethodPathNotes
POST/api/login{email, password} → sets the dagron_session cookie and returns {token}. 401 on bad credentials.
POST/api/logoutClears the cookie.
GET/api/meThe session claims — {sub, email, name, groups, exp}.
POST/api/usersadmin. {email, password, name, groups[]}201 {id}. 400 password under 8 characters, 409 duplicate.
GET/api/usersadmin. [{id, email, name, groups[], created_at}] — never hashes.
POST/api/tokensMint 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/tokensThe 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 Enterpriseadmin. ?limit=&offset= → newest-first audit entries. Absent from the open build, which has no audit_log table.

Runs

MethodPathNotes
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_runswithout 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}/specThe 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,cThe 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}/graphThe DAG as {nodes[], edges[]} — what the console's graph view draws.
POST/api/runs/{id}/cancel{cancelled: n}.
POST/api/runs/{id}/rerunOptional {from?} to resume from a task rather than the beginning → {run_id, rerun}.
POST/api/runs/{id}/resubmitA fresh run from the same spec → 201 {run_id}.
POST/api/runs/{id}/tasks/{tid}/retryRetry one task → {retried}.
POST/api/runs/{id}/tasks/{tid}/clearClear 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}/approveApprove a type: approval gate — the task succeeds and the DAG proceeds. 409 if it is not awaiting approval.
POST/api/runs/{id}/tasks/{tid}/rejectReject the gate — the task fails and all_success downstream skips.
POST/api/runs/{id}/triageRecord 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}/triageClear the decision, putting the run back in the attention queue.
POST/api/runs/{id}/archiveadmin, 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

MethodPathNotes
GET/api/runs/{id}/logsWorkflow 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}/logsOne 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}/attemptsThe 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.

ParameterMeaning
qKeep lines containing this text. Repeatable — all terms must match.
excludeDrop lines containing this text. Repeatable — none may match.
regexKeep lines matching this regular expression (max 512 bytes).
levelKeep only these levels, repeatable or CSV: error, warn, info, debug, trace, plain.
case1 to match case-sensitively. The default is insensitive.
contextAlso keep N unmatched lines either side of a match — grep -C.
limitMaximum lines returned. Default 2000, hard cap 50000; 0 means the default.
tail1 keeps the last lines when the cap applies, rather than the first.
Filtering from the shell
# 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:

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)

MethodPathNotes
GET/api/runs/{id}/streamOne run's task events. A single shared Postgres LISTEN task_events fans out to per-run streams; each event is JSON.
GET/api/events/streamAccount-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

MethodPathNotes
GET/api/workflowsThe 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}/runOptional {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}/versionsAppend-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}/runsThis 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-gitOpen 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/bundleApply 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

MethodPathNotes
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}/backfillSynchronous backfill: {from, to, max_runs?}{scheduled, skipped, from, to, run_ids}, materialised in one call with a hard cap of 1000.
POST/api/backfillsPaced 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}/cancelStop pacing → {id, cancelled}. 409 if it already finished.

GitOps repositories

MethodPathNotes
GET/api/git-reposAny 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-reposadmin. {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}/syncAny session — it only hurries along the poll auto_sync already performs.
PUT DELETE/api/git-repos/{id}/authadmin. 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

MethodPathNotes
GET POST/api/environmentsList 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.

MethodPathNotes
GET/api/datasetsThe 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/eventsThe 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.

MethodPathNotes
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/rotateadmin. 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/syncadmin. 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.

MethodPathNotes
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}/archiveadmin, 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

MethodPathNotes
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

MethodPathNotes
GET/healthzNo auth. Bare liveness — 200 ok, no database touched.
GET/readyzNo 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/healthRich 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/metricsJSON 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/approvalsEvery 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

MethodPathNotes
GET PUT/api/settings/notificationsadmin. 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/testadmin. 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-lettersAny session. {max_attempts}, or null when unset — the engine then keeps using its DEAD_LETTER_MAX_ATTEMPTS env value.
PUT/api/settings/dead-lettersadmin. {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.

MethodPathNotes
GET/api/fleetThe 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/linkWhether 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/enroladmin. 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.

MethodPathNotes
GET/ · /consoleA 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/healthzok, no database — liveness only.
GET/readyzready after a datastore round trip inside the DAGRON_READY_TIMEOUT_MS budget, else 503 datastore unreachable or 503 datastore probe timed out.
GET/configEffective 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/metricsPrometheus text: process counters (dispatched, succeeded, failed, retried), the reconcile-tick histogram, and live database gauges.
GET/openapi.yaml · /openapi.json · /docsThe embedded spec and Swagger UI.
GET/runs?status=&limit= (default 50, clamped 1–1000).
POST/runsRaw 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}/logsThe 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}/logsOne task's output, with the same ?offset= tailing contract as the gateway.
GET/runs/{id}/tasks/{task_id}/attemptsPer-attempt output for retries and repeat: iterations.
POST/runs/{id}/cancel{run_id, cancelled: true}. 409 if not cancellable.
POST/runs/{id}/rerunOptional {from?}{run_id, rerun}.
POST/runs/{id}/tasks/{task_id}/clearClear a completed task and its downstream cone. 409 if not completed.
POST/runs/{id}/tasks/{task_id}/approve · /rejectResolve an approval gate → {run_id, task_id, resolution}.
POST/runs/{id}/tasks/{task_id}/checkpointUnique 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}/redriveSame 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/eventsThe 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/eventsRecord 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.
Against dagron dev
$ 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_ADDRhttp://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.

The dagron engine's built-in Swagger UI at /docs, showing the management API title, the servers selector, the runs tag group, and an expanded POST /runs operation with its description and Try it out button
The explorer at /docs, with POST /runs opened.
  1. 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.
  2. The /openapi.yaml link 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.
  3. The description states the security posture in the document, not just in these docs: no authentication of its own, bind API_ADDR to localhost or a trusted network, and put dagron-api or a proxy in front before exposing it.
  4. Servers. The base URL requests go to. It follows wherever you set API_ADDR.
  5. Tag groupsruns, dead-letters, datasets, ops — the same grouping as the table above, so the two are read together.
  6. Each operation row carries its method and path. Collapsed, the page is a one-screen index of the whole surface.
  7. An expanded operation gives the prose the endpoint table cannot: here, POST /runs explains that the raw body is stored verbatim as the workflow definition, and that admission control sheds with 429 plus Retry-After once MAX_INFLIGHT_RUNS is reached.
  8. 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".

SDKPackageNotes
Pythondagron-sdkStandard library only — no dependencies to vet.
TypeScript@dagron/sdkESM, zero dependencies, hand-written .d.ts.
Python — build a DAG and drive it
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
TypeScript — the same shape
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.

GroupRepresentative tools
Drive runsdagron_submit_run, dagron_list_runs, dagron_get_run, dagron_wait_run, dagron_cancel_run
Registered workflowsdagron_list_workflows (with tag), dagron_get_workflow, dagron_list_workflow_runs, dagron_list_workflow_versions
Recoverdagron_rerun_run, task retry and clear, dead-letter redrive
Approval gatesdagron_list_approvals, approve, reject
Readdagron_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
Observedagron_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 & archivedagron_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

Synchronous invocation
$ 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

Offset polling
$ 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.