HTTP API - ptweezy/cronstable GitHub Wiki
cronstable exposes an optional aiohttp REST control
API, which you enable by adding a top-level web section to the configuration.
Its endpoints let you query the daemon version, inspect job status, start,
cancel, pause and resume jobs on demand, read per-job run
history, tail captured job output live, and read
Prometheus metrics. With a cluster section
configured, they also report the cluster and fleet views. This page documents
the configuration schema, every endpoint, bearer-token authentication,
Unix-socket permissions, and lifecycle behavior.
The interface is inherited from upstream yacron; web.authToken and
web.socketMode are cronstable additions.
Looking for the browser UI? The same HTTP interface also serves the built-in web dashboard at
/on everyhttp://listener (enabled by default; disable it withui: false). This page documents the REST endpoints; the web dashboard page describes the browser UI.
Resource and recovery endpoints share this API's authentication:
| Method and path | Scope | Operation |
|---|---|---|
GET /pools |
view |
Inspect pool capacity, waiting work, and recent terminal entries. |
POST /pools/{name}/queue/{key}/cancel |
control |
Cancel a waiting entry. |
POST /dags/{name}/runs/{run_key}/recover |
control |
Preview or execute selective recovery. |
POST /dags/{name}/recover |
control |
Preview or execute recovery for failed dates. |
Recovery defaults to a preview. Execution requires dryRun: false and the
preview's planToken. See workflow recovery for request
fields and conflict handling. A pooled job's start endpoint returns 202
with its queue ID; see resource pools. Completed run history
includes verification results when configured.
Add a web section with at least one listen URL:
web:
listen:
- http://127.0.0.1:8080
- unix:///tmp/cronstable.sockWindows:
unix://listeners are not supported on Windows. On Windows the daemon skips such a listen URL and logs the warningIgnoring web listen url <url>: unix-socket listeners are not supported on this platform(aiohttp'sUnixSiteneedscreate_unix_server, which the Windows Proactor loop lacks). Use anhttp://listener instead. Thehttp://listener and the entire HTTP control API otherwise behave identically on Windows. See running on Windows.
The server is created only when web.listen is nonempty. There must be exactly one
web block across the whole configuration: a duplicate web block in an included file
or a second file in a config directory raises a ConfigError. See
includes, defaults, and multi-file config.
The strictyaml CONFIG_SCHEMA in cronstable/config.py parses the web
section. listen is required; the rest are optional (strictyaml Opt(...)).
| Option | Type | Default | Description |
|---|---|---|---|
listen |
sequence of strings | (required) | List of URLs to bind. Each is http://host:port, https://host:port, or unix:///path. An empty list disables the server. |
headers |
map of string→string | (none) | Extra HTTP headers added to every 200 success response (all routes, including /cluster and /job-set-id) and to the 409 conflict body, but not the 404 or 401. |
allowedOrigins |
sequence of strings | [] |
Extra exact-match browser Origins allowed to call the mutating POST endpoints (see cross-site request defense). |
authToken |
map (value/fromFile/fromEnvVar) |
(none) | When set, requires bearer-token authentication on all routes as an all-scopes token (see authentication). |
authTokens |
sequence of maps (value/fromFile/fromEnvVar + scopes + optional label) |
[] |
Additional per-device scoped bearer tokens (view/control/approve). To revoke one, drop its entry and reload (see scoped tokens). |
anonymousScopes |
sequence of strings (view only) |
[] |
Scopes granted to requests presenting no credential, turning the instance into a public read-only board. Requires at least one configured token, and the schema refuses mutating scopes (see public read-only access). |
socketMode |
string (octal) | (none) | File mode applied with chmod to unix:// listen sockets (see Unix socket permissions). It applies only to unix:// sockets, so it does nothing on Windows, where the daemon skips unix-socket listeners with a warning. |
tls |
map (cert/key/clientCa) |
(none) | Certificate and key served by every https:// listen address, plus an optional CA that makes those listeners require a client certificate (mutual TLS). cert and key are required together. The config loader validates the block and the https:// addresses against each other at load. See listener TLS. |
ui |
bool | true |
Serve the web dashboard page at / (see GET /). ui: false exposes only the REST endpoints. |
metrics |
bool or map | true |
Serve the Prometheus exposition at /metrics. The map form tunes buckets or exempts the endpoint from authToken (see GET /metrics). |
nodeHistory |
bool or map | true |
Background node CPU/memory sampling that feeds GET /node/history. The map form tunes cadence and window size (see web.nodeHistory). |
| Scheme | Form | Requirements |
|---|---|---|
http |
http://host:port |
Both host and port are required. For an http URL missing either, the daemon logs a warning (Ignoring web listen url ...: http url needs host and port) and skips it. |
https |
https://host:port |
The same web.TCPSite, wrapped in an SSLContext built from web.tls. Both host and port are required (no port is assumed), and web.tls.cert/web.tls.key must be set, which the config loader enforces. If the material turns out to be unloadable at startup, the web API stays down with a logged error and is retried on the next reload. An https address is never downgraded to plaintext. See listener TLS. |
unix |
unix:///path/to/socket |
Binds an aiohttp UnixSite at the given filesystem path. POSIX-only: on Windows UnixSite is unavailable (no create_unix_server on the Proactor loop), so the daemon skips such a URL with the warning Ignoring web listen url <url>: unix-socket listeners are not supported on this platform. Use an http:// listener instead. |
Any other scheme is logged (scheme ... not supported) and skipped. Binding maps to
web.TCPSite for http and https and web.UnixSite for unix
(web_site_from_url in cronstable/cron.py).
TLS support is built in: the daemon serves an https:// address in-process
from the certificate and key in web.tls, and setting web.tls.clientCa
makes those listeners require a client certificate signed by that CA. One
listen list may mix schemes, so the same app can answer plaintext on loopback
and over TLS on a routable address. unix:// listeners are always plaintext,
with socketMode as their access control. Certificates, mutual TLS, rotation
behavior, and the client-side flags are documented in
listener TLS.
A machine-readable OpenAPI 3.0 contract
for this API ships in the repository at
docs/openapi.yaml.
It is the source for a generated client, such as one from
swift-openapi-generator.
Two CI checks keep it honest. .github/scripts/check_openapi.py
(tox -e openapi) validates the document itself (schema, refs, duplicated
keys), and tests/test_openapi.py diffs the spec's paths and methods against
the daemon's route table in both directions, so the spec and the served surface
cannot drift apart.
The spec documents each endpoint's path, parameters, auth, and response shape.
Fast-moving nested payloads are typed at the top level and left open below
that, with this page as the field-by-field reference. The /mcp endpoint
appears in the spec with its surface and auth only. The JSON-RPC protocol it
speaks is documented under MCP.
The authoritative route list, with every path, method, per-route status code,
and request/response schema, is docs/openapi.yaml,
which CI pins against the daemon's served route table in both directions. The
following sections carry what the spec deliberately leaves to prose: field
meanings, defaults, edge cases, and behavior.
Every error body the web API and the loopback state endpoint serve is one JSON
envelope: {"error": "<reason>"} (Content-Type: application/json), across
the job, DAG (directed acyclic graph), schedule, state, and push routes alike.
That includes the 401 from the auth middleware and the router's own responses
(405 on a wrong method, 404 on an unmatched path). docs/openapi.yaml
declares the same envelope as the Error schema.
A 404 for a named subject says what was not found, for example
{"error": "job 'nightly' not found"} or, on the routes that also accept a
DAG's schedule (/schedule/why, /jobs/{name}/calendar.ics),
{"error": "no job or DAG schedule named 'nightly'"}. The 401 is the one
error whose reason says nothing: its error is the generic
401: Unauthorized, the same bytes for a missing header, a wrong scheme, and
an unknown token, so the response cannot be used to tell them apart.
aiohttp answers requests that fail before they reach the application, as
text/plain. A malformed request line, an unparseable method token, and
request headers past 8190 bytes are each a 400, and an unrecognized
Expect: header is a 417. The envelope covers every response from the
routing layer inward.
One listener is outside this contract: the cluster peer transport
(clustering and leader election), a separate
mTLS listener running its own application, which answers its own 4xx with no
body at all, because its only client is another cronstable daemon.
Returns the cronstable version as text/plain (the value of cronstable.version.version).
Returns the status of every configured job. The response format depends on the
request's Accept header:
- If
Acceptlistsapplication/jsonamong its media ranges (compound headers likeapplication/json, */*and;q=parameters included), the response is a JSON array (application/json). - Otherwise the response is
text/plain, one job per line. The*/*andapplication/*wildcards keep the text default, so curl's defaultAcceptkeeps returning the classic text form.
Each job has one of three statuses, determined in this order:
| Status | When | Fields |
|---|---|---|
running |
One or more instances are currently running. |
job, status, pid (list of the PIDs of running instances whose subprocess has been started, that is, runjob.proc is not None). |
disabled |
The job is not running and enabled: false. |
job, status. |
scheduled |
The job is not running and is enabled. |
job, status, scheduled_in. |
For scheduled jobs, scheduled_in is the number of seconds until the next run
(a float, computed from the job's crontab in the job's time zone). For an @reboot
schedule, scheduled_in is the literal string "@reboot".
A scheduled job whose crontab has no future occurrence (a fixed past
year, an impossible date) reports scheduled_in: null plus never_fires: true, and the text form says never fires (schedule has no future occurrence). See schedule linting. A running row for
such a job carries the same never_fires: true flag, so the dead schedule
stays visible while an instance is still running.
A disabled job's status is disabled, not an inapplicable
scheduled (in N seconds).
$ http get http://127.0.0.1:8080/status
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
test-01: scheduled (in 14 seconds)
test-02: running (pid: 12345)
test-03: disabledIn the text form, scheduled_in appears as a human-readable relative time
(in N seconds / minutes / hours / days), running jobs show
running (pid: <comma-separated pids>), and disabled jobs show disabled.
The JSON form is an array of {job, status} objects carrying the per-status
fields from the preceding table.
One batched, at-a-glance overview of the whole daemon, for a client that wants
a single small poll instead of pulling and folding the whole /jobs array:
a home-screen widget, a status tile, a wallboard header. Always
application/json.
Every count is derived from the same live scheduler state /jobs and /status
read, so the surfaces never disagree.
| Field | Meaning |
|---|---|
version |
The daemon version (same value as GET /version). |
node_name |
The cluster node name when clustered, else the hostname (the same identity GET /node reports). |
generated_at |
ISO-8601 instant the summary was built. |
jobs |
Fleet counts: total, enabled, disabled, running (jobs with a live instance), paused, failing (jobs whose last finished run failed), and never_fires (enabled jobs whose schedule has no future occurrence; see schedule linting). |
next_fire |
The soonest upcoming scheduled fire across the fleet, {job, in, at} where in is seconds from now and at is the absolute ISO-8601 instant, or null when nothing is due (every job disabled, running, @reboot, dead-scheduled, or paused through its next fire). A paused slot is skipped at the gate, so it is never reported as the next fire. |
dags |
{total}; present only when DAGs are configured. |
cluster |
{enabled: false} with no cluster section; otherwise {enabled: true, distribution, quorate, is_leader, leader}, the compact leadership view (full detail is GET /cluster). |
failing is a last-outcome verdict (the same signal the dashboard's failing
badge shows), not a count of jobs mid-retry.
Parses, describes, previews, and lints one cron expression with the daemon's own engine, the single source of truth behind the dashboards' sandboxes, so a preview can never disagree with what the scheduler does.
Query parameters:
| Parameter | Meaning |
|---|---|
expr |
Required. The expression to decode (URL-encoded). 400 when missing or blank. |
tz |
Optional IANA zone for the preview frame and the DST lint checks (default UTC). 400 for an unknown name. |
limit |
Optional number of upcoming fires to return, clamped to 1–60 (default 12). count is its legacy alias, read when limit is absent (every capped listing on this API takes limit). |
seed |
Optional hash key (a job name, real or prospective) that resolves H items. Without it, an H expression comes back valid: false with the engine's own error. With it, the response echoes seed and adds resolved, the expression with every H replaced by its hashed values. |
For an expression the engine accepts, the response carries valid: true,
the whitespace-normalized form, the plain-English description, the next
fires as ISO-8601 instants in the requested frame, never_fires (no
remaining occurrence), and the schedule linter's
findings. For a rejected expression it carries valid: false and the
parser's error (including the Quartz dialect hints). @reboot returns
valid: true, reboot: true with no fires. The cron_validate_schedule
and cron_explain_schedule MCP tools serve this same payload to
agents.
$ http get "http://127.0.0.1:8080/schedule/preview?expr=*/7 * * * *&count=2"
{
"expression": "*/7 * * * *",
"timezone": "UTC",
"valid": true,
"reboot": false,
"normalized": "*/7 * * * *",
"description": "At minutes 00, 07, 14, 21, 28, 35, 42, 49, 56 past every hour, every day",
"fires": ["2026-07-18T15:42:00+00:00", "2026-07-18T15:49:00+00:00"],
"never_fires": false,
"lint": [
{
"code": "uneven-step",
"level": "warning",
"message": "'*/7' in the minute field: 7 does not divide the field's span of 60, so one interval at the wrap is only 4 minutes"
}
]
}The fleet's forward-looking collision heatmap: every enabled schedule's
fires over the next hours (1 to 168, default 24), enumerated with the
scheduler's own engine and bucketed by civil hour and minute in tz
(default UTC, and 400 for an unknown name).
The payload carries the 24x60 grid, the 60-bin
by_minute_fires/by_minute_jobs histograms, by_hour, the
busiest_minute headline, empty_minutes, top_cells (each naming up to
ten jobs), and an excluded count of disabled and @reboot jobs. For the
full field reference, see schedule load.
Groups of jobs whose schedules fire on the identical instants, by the
engine's semantic equality (*/5 equals 0-59/5; grouping includes the
resolved time zone). Each group carries the most common source
expression, a plain-English description, timezone, count, and the
member jobs, sorted largest group first. See
duplicate schedule detection.
The least-loaded slot for a new job, scored on the same 24-hour fire walk
as /schedule/pressure. period is hourly (pick a minute) or daily
(pick a minute and hour; 400 otherwise), tz frames the daily pick.
Returns the winning expression, its fires_in_window, the busiest
slot for contrast, two alternatives, and a hash_hint naming the
H spelling that spreads jobs without this endpoint.
See suggest a slot.
Explains field by field why one job's schedule did or did not select one timestamp, decomposing the same match test the scheduler runs.
Query parameters:
| Parameter | Meaning |
|---|---|
job |
Required. The job name; a DAG's synthetic dag:<name> schedule job resolves too. 404 for an unknown name. |
at |
Required. An ISO-8601 timestamp. With a UTC offset (2026-07-14T09:00:00+02:00, trailing Z accepted) it converts into the job's resolved time zone. A naive timestamp reads as wall time there. 400 when missing or unparseable. |
The response carries one checks row per cron field (field, the
probed value with its human label, the field's accepted values as
prose in allowed, and matched), the overall matches verdict with
the failed field names in field order, and the nearest real
previous_fire / next_fire around the probe, computed with the
scheduler's own occurrence walk in the job's zone.
notes flags the semantics that make an answer genuinely surprising:
day-fields-and-rule (both day fields restricted, exactly one matched, so
classic Vixie cron would have fired; see
schedule linting) and dst-skipped-time /
dst-repeated-time for a matching wall time a DST transition skips or
repeats.
An H schedule reports its resolved spelling and
checks against the resolved slots. @reboot jobs answer reboot: true
with no checks; a disabled job still explains its timetable and reports
enabled: false. A paused job adds a notes entry with
code paused naming the expiry, the actor, and the note, because a
schedule match says nothing about whether the fire would launch. The
cron_why_no_run MCP tool serves the same payload to agents. See
Why Didn't It Run? for a walkthrough.
The upcoming fires as a standard iCalendar (RFC 5545) feed, fleet-wide or
per job, Content-Type: text/calendar: one VEVENT per fire, enumerated by
the scheduler's own engine in each job's resolved time zone and emitted as
UTC instants with stable UIDs, so subscribed calendar apps update in place.
Query parameters days (window, default 14, clamped 1 to 60) and limit
(event cap per job, default 100, clamped 1 to 1000; per_job is its legacy
alias, read when limit is absent) are clamped rather than erroring.
Disabled and @reboot jobs carry no events. An unknown job name on the
per-job route is a 404.
With web.authToken set, the .ics
paths (only) also accept the token as a token query parameter, because
calendar clients cannot send a bearer header. For the full event anatomy,
privacy rationale, and subscription notes, see
calendar export.
curl "http://localhost:8080/calendar.ics?days=30"
curl http://localhost:8080/jobs/nightly-backup/calendar.icsReturns this node's cluster view as JSON.
When no cluster section is configured, it returns
{"enabled": false, "peers": []}.
When a cluster section is present it returns enabled: true plus a backend
field naming the active leadership backend (gossip, kubernetes, etcd, or
filesystem) and the node's view: its node_name and
job_set_id, the computed cluster_size and quorum, whether
elect_leader is on, the distribution mode (single-leader or spread),
whether any conflict is pausing Leader jobs (conflict, the umbrella flag),
and whether this node is quorate.
It also returns the elected leader
(null when this node is not quorate, and always null in spread mode),
is_leader (always false in spread mode), and a peers array (each with
host, status, node_name, job_set_id, last_seen, last_error,
mismatch_streak, and node_stats).
Under distribution: spread, per-job owners instead appear as a
clusterOwner field on each leader-gated job in GET /jobs.
A top-level node_stats object carries this node's own live CPU/memory
(the same shape as GET /node's resources), always present (it
is local and free). Each peer's node_stats is its last-shared load: null
unless the cluster shares node stats through
cluster.observability. The
dashboard's cluster panel renders this node's load in the summary and a per-peer
Load column when peers share.
Any of three triggers sets the umbrella conflict flag. Each trigger has its
own detail list, and all three stand Leader jobs down:
- A duplicate
nodeName, the offending names inconflict_names. - A reachable peer declaring a different cluster size (
size_conflict: true, the divergent sizes inconflicting_sizes), whatever job set it runs. - A reachable peer advertising a different
distributionorelect_leadersetting, a coordination-policy conflict surfaced aspolicy_conflict: truewith the differing descriptors inconflicting_policies.
The lease backends (kubernetes / etcd / filesystem) have no static
peer set, so their view is lease-shaped: backend names the backend, peers
is empty, cluster_size/quorum are 1, elect_leader is true,
distribution is single-leader, all three conflict flags (conflict,
size_conflict, policy_conflict) are always false, and an extra lease
block carries the backend-specific detail.
This is an endpoint-reference view. The field semantics (what quorate means
for a lease backend, the lease block contents, and the expiry rules that
follow) are documented once in
clustering and leader election.
For kubernetes the block is {name, namespace, identity, holder, expiry},
for etcd {electionName, identity, holder, leaseId, expiry}, and for
filesystem {path, electionName, identity, holder, fence, expiry}. fence
is the store's monotonic takeover counter: it bumps every time the lease
changes hands (a renew by the same holder keeps it).
For kubernetes and etcd the expiry is populated only while this node
holds the lease: a follower reports expiry: null (for kubernetes it is the
local lease deadline while this node leads; for etcd the current lease
deadline). For filesystem it is the written expiry of the last lease this node
observed, follower included. quorate is whether the node has a fresh
successful read of the lease store (stale → Leader fails closed; the
never-skip PreferLeader default then runs the job anyway).
$ http get http://127.0.0.1:8080/cluster Accept:application/json
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"enabled": true,
"backend": "gossip",
"node_name": "node-a",
"job_set_id": "v1:…",
"cluster_size": 3,
"quorum": 2,
"elect_leader": true,
"distribution": "single-leader",
"conflict": false,
"conflict_names": [],
"size_conflict": false,
"conflicting_sizes": [],
"policy_conflict": false,
"conflicting_policies": [],
"quorate": true,
"leader": "node-a",
"is_leader": true,
"peers": [
{"host": "cronstable-b.internal:8443", "status": "agreed", "node_name": "node-b", "job_set_id": "v1:…", "last_seen": "2026-06-23T19:00:00+00:00", "last_error": null, "mismatch_streak": 0}
]
}The per-peer status values (agreed, syncing, drifted, unreachable,
untrusted, self, conflict, unknown) are documented in
clustering and leader election.
The separate
GET /peerattestation endpoint is not part of this web API. It is served only on the cluster's own mutual-TLSlistenaddress (default port8443), never on the publicweblisteners. When node stats are shared, each/peerresponse carries the node's live load as anX-Cronstable-Node-Statsresponse header (on200and304responses alike, never in the body), so sharing preserves that exchange's conditional304optimization. See clustering and leader election.
Returns the cluster-wide per-job run view that backs the dashboard's
fleet view: one entry
per node, each carrying that node's per-job run summaries. This node answers
it entirely from state it already holds. Every gossip node piggybacks a compact
summary of its own jobs (running / enabled / seconds to next fire / last
finished run) on its mutual-TLS /peer response, so the summaries arrive with
the peer polls this node is already making. Serving /fleet triggers no peer
traffic. Any node can therefore serve the whole fleet's picture, at most one
gossip interval stale per peer.
When no cluster section is configured, or the backend is a lease backend
(kubernetes / etcd / filesystem, which carry only a lease and know
nothing about what other nodes run), it returns
{"enabled": false, "nodes": []}.
For the gossip backend it returns enabled: true, the serving node's
node_name, the distribution and elect_leader policy, the gossip
interval in seconds (the peer-data freshness bound), and a nodes array.
The serving node is always first (self: true, status self, as_of stamped
at request time). Each configured peer follows with the status and host
from the peer table and the summaries absorbed from its last successful poll
(as_of = last_seen). The serving node skips self-listings and deduplicates
two addresses that answer as the same process.
Per node: jobs maps each job name to
{running, enabled, scheduled_in, last}, where last is
{outcome, finished_at, duration, exit_code} or null for a job that has not
run since that node started. jobs: null (as opposed to {}) means no
snapshot is held for that node at all: it was never reached, or it runs an
older build that does not gossip summaries.
Each node also carries node_stats, its whole-node CPU/memory (the same shape
as GET /node's resources), when the cluster shares node load
through cluster.observability;
null when that node shares none. truncated: true flags a node with more
jobs than the per-payload cap (512), whose advertised set is the sorted-name
prefix. A briefly unreachable peer keeps its last-known summaries with the old
as_of rather than being blanked, so stale data is visibly stale instead of
silently missing.
The summaries are observability data only: they never feed leader election or any run/skip decision, and a malformed or hostile peer payload degrades to "no data for that node" rather than poisoning the view.
The serving node's live CPU and memory, sampled fresh per request (this
drives the dashboard header's node meter). Returns the node identity
(node_name) and a resources object with cpu_percent, cpu_count,
mem_percent, mem_used_bytes, mem_total_bytes, proc_rss_bytes, and
proc_cpu_percent.
node_name is the cluster node name when clustered, else the hostname.
cpu_percent / mem_percent are whole-host usage. proc_rss_bytes /
proc_cpu_percent are the cronstable daemon's own footprint (best-effort, may
be absent if the platform denies the per-process read). resources is null
when host sampling is unavailable (psutil could not read the host), and the
dashboard then hides the meter. CPU percentages are measured since the previous
sample, so the first request after startup reads a priming 0.
Containers. When the daemon runs under a cgroup v2 limit (Docker/Kubernetes
memory or CPU limits, or a systemd slice with MemoryMax/CPUQuota), these
numbers describe its own slice rather than the whole host: mem_total_bytes
is the effective memory limit, mem_used_bytes is the slice's usage with
reclaimable page cache excluded (the same accounting docker stats shows),
cpu_count is the CPU quota rounded up, and cpu_percent is usage of that
quota.
Memory and CPU switch over independently: a container with only a memory limit still reports host-wide CPU. Unlimited cgroups, cgroup v1 hosts, and non-Linux platforms report whole-host numbers as before, and the response shape never changes.
Launches the named job immediately, regardless of its schedule. {name} is the
job's name. Success answers {"started": "<name>"} (the cron_run_job
MCP tool's ack shape).
The 409 for a disabled job is deliberate: a disabled job behaves as if it is
not there, so the API refuses to launch it manually rather than overriding the
config.
Manual launch goes through maybe_launch_job, so the job's concurrencyPolicy
applies. If an instance is already running, Allow starts another, Forbid does
not start a new one (the 200 still returns), and Replace cancels the running
instance(s) first. See concurrency and timeouts.
Stops every currently-running instance of the named job, using the same
graceful terminate-then-kill sequence cronstable uses elsewhere (honoring the
job's killTimeout; see concurrency and timeouts).
The daemon cancels instances concurrently, so a job with several running
instances costs at most one killTimeout, not one per instance. A job with no
running instance is a 409; success answers
{"cancelled": "<name>", "instances": <count>} (the cron_cancel_job
MCP tool's ack shape).
A run cancelled this way is recorded in the job's history with the outcome
cancelled. Cancellation is a deliberate operator action, not a job failure,
so it is not reported (onFailure does not fire) and does not trigger
retries.
Pauses the named job's scheduled fires until a deadline: due slots are skipped
(each recorded as an outcome: "skipped" run with skip_reason: "paused"),
pending retries defer, and catch-up does not run the window's slots, while
manual start, cancel, and already-running instances are unaffected. The full
semantics live on pausing jobs. This section is the endpoint
reference.
The JSON body is optional; every field is optional:
| Field | Type | Default | Description |
|---|---|---|---|
durationSeconds |
int | 3600 |
Pause length in seconds, 1 to 2592000 (30 days). Exclusive with until. |
until |
string | (none) | Absolute ISO-8601 expiry. Must be in the future, at most 30 days away; without a UTC offset it is read as UTC. Exclusive with durationSeconds. |
note |
string | "" |
Audit note, at most 500 characters. |
by |
string | "api" |
Acting operator, at most 100 characters. |
A body that violates any of those constraints (both durationSeconds and
until, an out-of-range duration, a past or over-cap until, an oversized
note/by, a malformed body) is a 400 naming the problem. Success answers
{"paused": {since, until, note, by, channel}} (ISO-8601 instants; channel
is "api" here). Pausing an already-paused job overwrites the window
(idempotent; also how a pause is extended).
Ends the named job's pause immediately. The optional JSON body takes by (a
string, at most 100 characters). Returns 404 Not Found for an unknown job.
Otherwise 200 OK with {"paused": null}, including when the job was not
paused. Resume is a no-op then, but with a state store the
daemon still writes the durable "resumed" record, so a pause taken on another
node that this node has not yet seen is revoked too.
Returns a JSON array describing every job: its schedule and time zone, whether it is enabled and running, the time until its next scheduled run, a summary of its most recent finished run, and a compact tail of recent outcomes. This is the endpoint the web dashboard polls.
| Field | Meaning |
|---|---|
name, enabled, schedule, command
|
The name, the enabled flag, the schedule as a crontab string, and the command (argv lists are joined for display). |
captureStdout, captureStderr
|
Which output streams the job captures, and so which are available from /jobs/{name}/logs. |
utc, timezone
|
The schedule's reference frame: utc (default true) and the IANA timezone name, or null. |
running, pids
|
Whether any instance is running, and the PIDs of running instances whose subprocess has started. |
running_resources |
Present only while a monitorResources job has a running instance: the live CPU/memory of the running instances, summed as {cpu_percent, cpu_seconds, rss_bytes, instances}. Omitted otherwise. cpu_percent is usage since the last sample and can exceed 100 across cores. |
scheduled_in |
Seconds until the next scheduled run (a float), or null when not applicable (disabled, running, or a one-off @reboot schedule). |
never_fires |
true when the job is enabled but its crontab has no future occurrence (a fixed past year, an impossible date), distinguishing the dead-schedule scheduled_in: null from the running/disabled ones. See schedule linting. |
schedule_findings |
The schedule linter's advisory findings for this crontab, each {code, level, message} (empty for a clean schedule). Computed once at config load, in the job's own time zone. |
schedule_resolved |
Present only for H hashed schedules: the plain expression the H items resolved to for this job, so clients can compute previews while displaying the H the user wrote. |
last_run |
The most recent finished run (outcome, exit_code, started_at, finished_at, duration, fail_reason), or null if the job has not run yet. One exception: a run in progress when this host crashed is reported here as unknown even though it never finished. It stands at the instant it started, so a run that finished while it was still going can carry a later finished_at. The crash stays visible instead of hidden behind whatever outlived it. |
history |
Compact oldest-first tail of recent runs (outcome and duration only), sized for the dashboard's inline sparkline. Full per-run detail comes from /jobs/{name}/runs, whose ordering note covers this tail too. |
paused |
Always present: the active runtime pause, {since, until, note, by, channel} (ISO-8601 instants), or null when the job is not paused. |
sla |
Present only for jobs with a configured sla: block: {thresholds, state, breaches}, where thresholds holds the non-null threshold keys, state is "ok" or "late", and breaches lists each latched check as {check, since, observed_seconds, threshold_seconds} (observed_seconds re-measured at payload time). |
retry |
Present only while a retry ladder is armed for the job: {attempt, maxAttempts, nextRetryAt, delaySeconds}. maxAttempts is null for an unlimited ladder (maximumRetries: -1). |
rebootPending |
Present (as true) only for a deferred @reboot one-shot awaiting its boot run (the cluster had not elected an owner at boot, or a pause is holding it), so a client can tell "pending boot run" from "already ran". |
concurrencyScope, slot
|
Present only for concurrencyScope: cluster jobs: the literal scope, and slot as {held, holder, refs}: whether this node holds the job's cluster-wide concurrency slot lease, the holding node's name (null when unheld), and how many live instances reference it. |
priority |
Present only when the job sets a non-default scheduling priority: one of idle, below-normal, above-normal, high. A job at the default level (normal, the one level never applied) carries no key. |
clusterPolicy, clusterOwner
|
Present only when leader election is configured: the job's cluster policy, and, under distribution: spread for leader-gated jobs, the node that owns the job (null when there is no quorum). |
$ http get http://127.0.0.1:8080/jobs
[
{
"name": "test-01",
"enabled": true,
"schedule": "*/5 * * * *",
"command": "echo foobar",
"captureStdout": true,
"captureStderr": true,
"utc": true,
"timezone": "UTC",
"running": false,
"pids": [],
"scheduled_in": 42.1,
"never_fires": false,
"schedule_findings": [],
"last_run": {
"outcome": "success",
"exit_code": 0,
"started_at": "2026-06-21T12:00:00+00:00",
"finished_at": "2026-06-21T12:00:01+00:00",
"duration": 1.02,
"fail_reason": null
},
"history": [
{"outcome": "success", "duration": 0.98},
{"outcome": "failure", "duration": 1.21},
{"outcome": "success", "duration": 1.02}
]
}
]Returns one job in the same format as a GET /jobs entry,
including conditional running_resources, retry, sla, and
schedule_resolved fields. Use it to refresh a single job without fetching
the whole fleet. An unknown name returns 404 Not Found. The
cron_get_job MCP tool returns the same data.
Returns the job's retained run history (oldest first, bounded, and held in
memory, although with a durable state store configured the
daemon rehydrates it from the durable run ledger after a restart) together
with aggregate statistics. Returns 404 Not Found for an unknown job. An
optional ?limit= query caps the runs array (newest kept, clamped to the
retained window; the default serves the whole window); stats always covers
the whole retained window regardless of limit.
"Oldest first" is the order this node observed the runs in, which is not
always finish order. The array is never re-ordered at read time: after a
restart the ring is rebuilt in finish order and then grows in completion
order, so on a shared mount a peer's interleaved append can put an older run
later, and a crash-reconciled row stands where its interrupted run began. The
stats block's last_* fields do fold by finish time.
Each entry in runs carries the same fields as last_run in GET /jobs
(outcome, exit_code, started_at, finished_at, duration,
fail_reason, and resources), plus ranAt on every entry whose outcome is
not skipped: the same instant as finished_at, under the key the run
ledger uses, so ledger-derived and in-memory records read alike. resources
is null unless the job opted into
monitorResources, in which case it is
{cpu_user_seconds, cpu_system_seconds, cpu_total_seconds, max_rss_bytes, samples} for that run.
Besides success, failure, and cancelled, outcome can be unknown: a
crash-reconciled run, recorded when the daemon exited or lost the
state store mid-run so no completion was ever written. It is
a non-verdict: excluded from success_rate, counted only in total, with no
started_at or duration (fail_reason explains the interruption).
outcome can also be skipped: a scheduled slot deliberately not launched,
with skip_reason naming why ("paused"; see pausing jobs).
Skipped rows carry no started_at, exit_code, or duration, and like
unknown they count only in total. stats summarizes them:
stats field |
Meaning |
|---|---|
total, success, failure, cancelled
|
Counts by outcome over the retained history. |
success_rate |
Success rate over runs that ran to completion. Cancellations are user-initiated, not a verdict on the job, so they are excluded; null when no run has completed. |
avg_duration, min_duration, max_duration, last_duration
|
Duration aggregates in seconds, over runs with a recorded duration; null when there are none. last_duration is the newest run in the window by finish time, not the last row of runs. |
avg_cpu_seconds, max_cpu_seconds, last_cpu_seconds
|
CPU-time aggregates over the monitorResources runs in the window; null when none were monitored. last_cpu_seconds reads the same newest-by-finish-time run as last_duration, so it is null when that run was not monitored. |
avg_rss_bytes, max_rss_bytes, last_rss_bytes
|
Peak-RSS aggregates (bytes) over the monitored runs; null when none were monitored. last_rss_bytes reads the same run as last_cpu_seconds. |
The feed behind the activity heatmap on the
web dashboard and the
terminal dashboard. jobs maps each job name to its
retained runs, oldest first, each reduced to the three fields the heatmap
plots (started_at, finished_at, outcome); a job that has never run maps
to [], so a client can tell "no runs" from "unknown job".
The records, the bounds, the ordering note, and the restart behavior are
exactly those of GET /jobs/{name}/runs, without the
per-job fan-out, and one built response is shared across every viewer polling
it (with ETag / If-None-Match and gzip, like GET /jobs). An optional
?limit= query caps the runs per job (newest kept, clamped to the retained
window; the default serves the whole window).
Chart-grade CPU/RSS time series for one job: the heavyweight sibling of the
summary numbers on GET /jobs and GET /jobs/{name}/runs. The dashboard
fetches it lazily when you open a job's Resources tab, never on the poll
loop. The sampler behind these series is documented on
resource monitoring. Returns 404 Not Found for an
unknown job.
$ http get http://127.0.0.1:8080/jobs/test-01/resources runs==5
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"name": "test-01",
"monitored": true,
"interval": 1.0,
"live": [
{
"started_at": "2026-07-07T12:00:00+00:00",
"pid": 4242,
"current": {"cpu_seconds": 8.4, "cpu_percent": 96.2, "rss_bytes": 21430272},
"series": [[1751889600.0, 0.0, 20971520], [1751889601.0, 98.1, 21430272]]
}
],
"runs": [
{
"outcome": "success",
"started_at": "2026-07-07T11:00:00+00:00",
"finished_at": "2026-07-07T11:00:42+00:00",
"duration": 42.1,
"exit_code": 0,
"fail_reason": null,
"resources": {
"cpu_user_seconds": 39.2,
"cpu_system_seconds": 1.1,
"cpu_total_seconds": 40.3,
"max_rss_bytes": 22020096,
"samples": 42,
"series": [[1751886000.0, 0.0, 20971520], [1751886001.0, 97.4, 22020096]]
}
}
]
}A series is a list of [t, cpu_percent, rss_bytes] points, oldest first,
with t in epoch seconds. Points are recorded every
monitorResources.interval seconds and downsampled in
place after a run exceeds its configured history cap (mean CPU%, peak
RSS per merged bucket, so spikes survive), so a series is bounded no matter
how long the run.
live carries the run-so-far series of each currently-running monitored
instance plus its current instantaneous readings; runs the recorded series
of recent finished monitored runs (oldest first, unmonitored runs are
omitted; the ordering note on
GET /jobs/{name}/runs applies to this array too),
capped by the limit query parameter (default 20, clamped to the retained
history; runs is its legacy alias, read when limit is absent).
With a durable state store, run series survive restarts
inside the run ledger records. monitored: false with empty lists means the
job never opted into monitorResources (distinguishable from "monitored but
no data yet").
The serving node's retained CPU/memory history: the time-series companion to
GET /node's live snapshot, driving the dashboard's node chart
(click the header meter). Sampled in the background per
web.nodeHistory (every 5s, last hour, by
default), independent of whether anyone is polling. The response carries
node_name, enabled, the sampling interval in seconds, and points.
points is oldest-first [t, cpu_percent, mem_percent] (epoch seconds; the
same cgroup-aware percentages GET /node reports). A gap between consecutive
points much wider than interval means the daemon was down, not idle. The
ring is in-memory only and resets on restart. enabled: false (with empty
points) means the sampler is off: web.nodeHistory: false, or psutil
cannot read this host.
The long-horizon sibling of GET /jobs/{name}/runs: the same stats object,
computed per time window over the durable run ledger, which
survives restarts and, on a shared mount, merges every node's runs.
Returns 404 Not Found for an unknown job.
The response carries the job name, a source field, a generated_at
timestamp, and a windows map with keys 1h, 24h, 7d, 30d, and all,
each holding the stats object documented under
GET /jobs/{name}/runs for the runs that finished inside
that window.
source is "durable" when the aggregates were computed over the durable
ledger (the horizon is then bounded by state.maxRunsPerJob retention, and
by the 5000 newest records per request on an unbounded-retention store) and
"memory" when the endpoint degraded to the in-memory run history, because
no state: section is configured, or the store could not be read in time.
The endpoint always answers rather than erroring on store trouble. See
durable state for the ledger this
reads.
A Server-Sent Events
stream of a job's captured output. Returns 404 Not Found for an unknown job.
The stream begins with the lines already buffered (from the most recent running
instance, or else the last finished run's retained output), then follows a
running job's new lines live, and finishes with an end event:
- Each line arrives as an
event: linewhosedatais a JSON object{"stream": "stdout"|"stderr", "line": "..."}. - When the run finishes, the server sends
event: endwith data{}. If the job has no captured output at all yet, the stream ends immediately withevent: endand data{"reason": "no-output"}. - During quiet periods the server writes an SSE comment (
: ping) every 15 seconds as a keep-alive, which also detects disconnected clients.
Only the streams a job captures (captureStdout / captureStderr) appear
here; see output capturing. The response carries
X-Accel-Buffering: no so reverse proxies (for example nginx) do not buffer
the stream.
Use these routes to introspect and control the orchestration DAGs (the
dags: section). All are token-gated like the job
endpoints.
The configured DAGs and their tasks:
[{"name": "nightly-etl", "enabled": true, "scheduled": true,
"tasks": [{"id": "extract", "type": "task", "dependsOn": []},
{"id": "load", "type": "task", "dependsOn": ["extract"]}]}]Recent dag_runs (newest first), each with its state and a per-state task
count. name duplicates dag (the generic subject key the job routes use):
{"dag": "nightly-etl", "name": "nightly-etl", "runs": [
{"runKey": "2026-07-04T02:00:00_00:00", "runId": "…", "state": "success",
"kind": "scheduled", "logicalDate": "2026-07-04T02:00:00+00:00",
"taskStates": {"success": 3}}]}The limit query parameter caps the number of runs returned (default 50,
max 500). A missing or unparseable value falls back to the default rather
than erroring. 404 if the DAG is not configured, or if there is no
state: store at all, because run documents only exist in a
durable store; the error says which.
One run's full durable document: every task's state, attempt, timing, XCom
(cross-communication) expansion (mapped), and approval decisions. 404 if
the run is unknown, with the same split between an unknown DAG and a missing
state: store.
Create and start a manual run now. Returns {"dag": …, "name": …, "runKey": …} (name duplicates dag, the generic subject key the job routes use).
404 if the DAG is not configured. A run that could not be durably recorded
(the state backend is unavailable) surfaces as a 500 error rather than a
runKey for a run that does not exist.
Replay a scheduled DAG across a historical range. Body:
{"from": "<ISO>", "to": "<ISO>"}. Idempotent (create-if-absent per date) and
bounded. Returns {"ok": true, "created": <N>}; 400 on a bad range.
Approve or reject an approval gate.
Body: {"decision": "approve"|"reject", "by": "<who>"}. 200 on success,
400 on a bad decision value, 409 if the task is not awaiting a decision.
The XCom outputs the run's tasks published, as a flat list of entries (task,
key, sha256, size, timestamp) with small text values inlined and larger ones
metadata-only. truncated flags a run with more entries than the cap. 404
if the DAG or run is unknown, or if no state: store is
configured; the error says which.
An SSE stream of a running task instance's live captured output, in the
same event shape as GET /jobs/{name}/logs. A finished
instance's buffer is not retained, so the stream then ends immediately with
event: end and {"reason": "no-output"}. 404 if the DAG is not
configured.
Three metadata-only routes feed the dashboard's durable state inspector: record payloads, KV values, and archived output never cross this surface. All are token-gated like the job endpoints.
Store health and topology plus an inventory: per-prefix stream and document
counts, capped scope lists, active leases, the quarantine count, and this
node's live retry ladders and held concurrency slots. Returns
{"enabled": false} when no state: section is configured; an unreadable
store degrades to health-only rather than erroring.
The documents of one kv/, cursor/, or idem/ namespace (400 for any
other namespace). KV values are redacted to a valueSize / valueType
summary; cursor watermarks and idempotency claim metadata are returned
verbatim. Unlike GET /state, which degrades to {"enabled": false} on a
stateless install, this route (and /state/records) returns 404
(state store is not configured) when no state: section is configured.
The newest records of one stream, newest first (default 100, max 500).
Archived-output logs/ streams are refused with 403: they carry raw job
output, which the metadata-only stance keeps off this surface. A missing or
empty stream parameter is a 400; a stateless install is a 404
(state store is not configured), as for /state/documents.
Describes the bearer token that authenticated this request, as
{authenticated, label, scopes, allScopes, pairLinkBase, sealableSuites}:
its label, the scopes it grants (with the implied view expanded),
whether it is an all-scopes token, the base URL of the pairing QR's deep
link (the origin of push.relay.url plus /pair, the hosted landing while
no push: section is applied), and the push sealing suites this daemon can
seal under. sealableSuites is ["x25519"] on a daemon without
cryptography and adds xwing with it; a daemon that omits the field
seals x25519 only. A client picks a fresh pairing's suite from it.
A companion app uses it to show what it may do. The dashboard uses it to warn
when its pairing QR would hand a phone the all-scopes token (see
push notifications). Requires the view scope.
When no token is configured there is no auth middleware and no token to
describe: authenticated is false, label is null, scopes lists every
scope, and allScopes is true (every scope is effectively granted).
A credential-less request served through
web.anonymousScopes is the
third shape: authenticated is false, label is "anonymous" (a
reserved label config load refuses for real tokens), scopes lists the
granted set, and allScopes is false. Branch on allScopes, because the
open daemon described earlier shares authenticated: false.
Lists every device paired for end-to-end encrypted push
alerts, sorted by pairing time. Push tokens are redacted
to their trailing six characters (the token is what lets a third party
address the device through the platform push service); public keys are
returned whole. Requires the view scope.
Each record carries suite, the sealing suite its key belongs to, and
sealableHere, whether the daemon answering this request can seal that
suite. The registry is shared by every node on one state store while the
sealing libraries are per node, so a device that paired against a
node with cryptography reads sealableHere: false on a node without it, and
alerts raised there never reach that device. A false on one node says
nothing about the other nodes.
GET /push/devices is the one view route a
web.anonymousScopes grant
deliberately excludes: it answers 403 to a credential-less request, naming
the scope the method needs, because the registry lists every paired phone.
Like all /push/... routes, it answers 404 until a push: section is
configured (the routes are always registered, so a reload that adds the
section needs no web-app restart), and 503 when the device registry's
store is unavailable. The 503 body says only that: the store's own
diagnostic names its absolute path and quotes the underlying OS error, so
it goes to the daemon log rather than to the caller.
$ curl -H "Authorization: Bearer s3cr3t" http://127.0.0.1:8080/push/devices
{
"devices": [
{
"id": "f1e2d3c4b5a69788",
"name": "parker-iphone",
"platform": "ios",
"publicKey": "jSNlDu28No2itHnvrs6ajHHuNAxvqgOjmGxHJrMo8yg=",
"suite": "x25519",
"sealableHere": true,
"fingerprint": "6acc-efc7-9869",
"pushToken": "…4af1c9",
"createdAt": "2026-07-23T14:00:00+00:00",
"createdBy": "parker-iphone"
}
]
}Pairs a device for encrypted push alerts. The JSON body carries four
required fields: name (up to 64 characters), platform (up to 32),
publicKey (base64, decoding to the length its optional suite requires:
32 bytes for the default x25519), and pushToken (the platform push
token, up to 512 characters). Requires the control scope.
A new pairing answers 201 with {"device": …, "created": true}. Pairing
is keyed on the public key: the same key pairing again answers 200 with
created: false, updating name/platform/pushToken in place while
keeping the record's id and createdAt, so revocation references stay
stable. createdBy records the label of the token that performed the
pairing. A malformed body, a missing or over-long field, or an invalid key
is a 400 naming the problem. No push: section is a 404, and a store that
cannot be written is a 503.
Revokes one paired device; the daemon stops sealing alerts to it. Requires
the control scope. Answers {"revoked": "<id>"}, or 404 when no device
has that id (or no push: section is configured), or 503 when the store
is unavailable.
Seals a test alert to one device and posts it to the configured relay,
returning the round-trip outcome {device, status, error} (status is the
relay's HTTP status), so the dashboard, not the logs, is where you debug a
device that receives no alerts. Requires the control scope. Answers 200
when the relay accepted the alert and 502 when sealing failed or the relay
refused or was unreachable (the error field says which); 404 for an
unknown device id or no push: section; 503 when the registry store is
unavailable.
Gracefully stops this daemon: the same drain Ctrl-C / SIGTERM trigger
(stop scheduling new runs, wait for the running jobs to finish, stop the web
app, exit). This is the stop path for deployments that cannot deliver a
signal: service wrappers and supervisors, and any console-less Windows
daemon, where no SIGTERM exists. In a cluster it stops only the node
addressed.
Unlike every other route, /shutdown is refused (403, with the remedy in
the body) unless a configured bearer token authenticated the request, even on
a deployment that leaves the rest of the API unauthenticated. An open listener
must not hand every process that can reach it a stop switch for the scheduler.
Configure web.authToken (or a web.authTokens entry whose scopes include
control) and present it. On success the response is
{"shuttingDown": true} and the drain has begun; the connection closes when
the web app stops.
$ curl -X POST -H "Authorization: Bearer s3cr3t" http://127.0.0.1:8080/shutdown
{"shuttingDown": true}Returns this instance's job-set ID: the order-independent fingerprint of every
job's effective configuration that replicas compare to confirm they hold the
same set of jobs (see job-set ID).
The response is text/plain by default. When Accept lists
application/json among its media ranges, it is a JSON object that also
carries the job count (wildcards keep the text default, as on
GET /status): {"job_set_id": "v1:…", "jobs": 3}.
Exposes cronstable's built-in Prometheus metrics:
daemon info, per-job run counters and duration histograms, live per-job gauges,
and (when a cluster section is configured) the cluster health series that
mirror GET /cluster. The exposition comes from cronstable itself, with no
exporter sidecar and no extra dependency. This section covers the endpoint
mechanics. The exposition sample and the full metric reference live in
metrics with Prometheus.
The response format depends on the request's Accept header:
- By default the response is the classic Prometheus text format
(
text/plain; version=0.0.4). - If
Acceptadvertisesapplication/openmetrics-text, the response is OpenMetrics 1.0 (terminated by# EOF), as modern Prometheus servers request.
The daemon applies the configured web.headers to the response as on every
other route. As everywhere on this API, Content-Type stays the endpoint's
own, so the exposition format's contract always wins over an
operator-configured header.
The endpoint is enabled by default whenever the web API is on. The
web.metrics option tunes or disables it, accepting either a boolean
shorthand (metrics: false disables the endpoint) or a map:
| Sub-option | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Serve GET /metrics. |
public |
bool | false |
Exempt /metrics (and only /metrics) from web.authToken bearer-token authentication (see authentication). |
durationBuckets |
sequence of floats | 0.1, 0.5, 1, 5, 15, 60, 300, 900, 3600 |
Upper bounds (seconds) of the cronstable_job_duration_seconds histogram. Bounds must be finite, positive, and strictly increasing; anything else raises a ConfigError. |
The daemon owns the metric registry rather than the web app, so counters survive config reloads (including ones that restart the web server) and reset only when the process restarts. Series for jobs removed by a reload are pruned.
Serves the single-page web dashboard. Set ui: false in the
web section to disable the page and expose only the REST endpoints. The page
is served with secure default headers (a strict Content-Security-Policy,
anti-clickjacking, and nosniff) with any operator web.headers merged on top,
so a deliberately-set operator header wins.
When web.authToken is enabled, the page itself loads without a token (it
holds no data). It prompts for the token in the browser and authenticates
every data request with it (see authentication).
When a mcp section sets enabled: true, an
opt-in Model Context Protocol server is
served at POST /mcp on the same listeners, letting an AI agent observe (and,
when readOnly: false, control) jobs, DAGs, the cluster/fleet, metrics and the
durable state store. It is a stateless Streamable-HTTP JSON-RPC 2.0
endpoint (no Mcp-Session-Id; GET /mcp returns 405), pinned to MCP
revision 2025-11-25.
It inherits web.authToken exactly like the data routes (it is never in
the public set, so it always requires the bearer token when one is
configured), and additionally validates the Origin header (403 on a
present, non-allow-listed origin) and caps the request body (413). If
mcp.enabled is set with no web.authToken on a routable listener,
cronstable fails closed at config load (raises a ConfigError) unless
mcp.allowUnauthenticated: true.
Desktop MCP clients reach it over the cronstable mcp stdio bridge. For the
full tool catalog see MCP; for configuration see
mcp.
The web.headers map (merged upstream but never released in yacron 0.19) is a
string→string map applied to every 200 success response across all routes
(/version, /status, /cluster, /job-set-id, the job routes, and the
200 of /jobs/{name}/start) and to the 409 conflict bodies of
/jobs/{name}/start and /jobs/{name}/cancel.
It is not applied to the 404 (unknown job) or 401 (authentication failure)
responses, which are raised without the configured headers. One key is exempt
everywhere: a Content-Type in this map (in any spelling) is ignored, because
every endpoint owns its own content type. Example:
web:
listen:
- http://127.0.0.1:8080
headers:
X-Frame-Options: DENY
Cache-Control: no-storeBy default the API is unauthenticated. Anyone who can reach a listen address
can call every endpoint except POST /shutdown, which always
requires a bearer token. Restrict access at the network or socket level,
enable bearer-token authentication with web.authToken, or require a client
certificate with web.tls.clientCa (see
client certificates later). The latter two
are independent checks and compose: a token authenticates the caller inside
the request; a client certificate authenticates it at the handshake.
"Reachable" includes every local account on a multi-user host: loopback confines the listener to the machine, not to your user, so set a token anywhere other local users matter.
authToken resolves the token from exactly one source, in this precedence order
(_resolve_web_token):
| Sub-option | Type | Description |
|---|---|---|
value |
string or null | Literal token value. |
fromFile |
string or null | Path to a file; the token is the file contents with surrounding whitespace stripped. Read as UTF-8, and a leading byte-order mark is stripped too, so a file written by Notepad or a PowerShell redirect resolves to the token you typed. |
fromEnvVar |
string or null | Name of an environment variable holding the token. |
When authToken is set, an aiohttp middleware (_make_auth_middleware) requires
Authorization: Bearer <token> on every route:
- The auth scheme is compared case-insensitively (
Bearer,bearer, and other capitalizations) per RFC 7235. - The presented token is compared against the configured token in constant
time with
hmac.compare_digest. - A missing/malformed
Authorizationheader, a wrong scheme, or a non-matching token returns401 Unauthorized.
Two settings relax that. web.metrics.public: true exempts /metrics (and
only /metrics) from the bearer token, for scrapers that cannot send
credentials (see GET /metrics). web.anonymousScopes
grants named scopes (view only) to callers presenting no credential,
which is how a public read-only board is served. A wrong token is still
401, and everything outside the grant is 403 (see
public read-only access).
Without either, every route stays gated.
One built-in carve-out: paths ending in .ics (the
calendar feeds) also accept the same token as a token
query parameter, compared in the same constant time, because calendar
clients subscribing to a feed cannot attach a bearer header. Every other
path refuses query tokens, keeping the token out of URLs, access logs, and
referrers where a header suffices.
web:
listen:
- http://127.0.0.1:8080
authToken:
fromEnvVar: CRONSTABLE_WEB_TOKEN$ http get http://127.0.0.1:8080/status "Authorization:Bearer s3cr3t"
$ curl -H "Authorization: Bearer s3cr3t" http://127.0.0.1:8080/statusThe scalar web.authToken described earlier is an all-scopes token: it can
read, control, and approve. To hand out a narrower credential (a phone that
should never carry the all-scopes token, a wallboard that only reads, a CI job
that only triggers), add web.authTokens, a list of per-device tokens each
carrying a scopes list and an optional label:
web:
listen:
- http://0.0.0.0:8080
authToken: # optional: the legacy all-scopes token
fromEnvVar: CRONSTABLE_WEB_TOKEN
authTokens:
- label: parker-iphone
scopes: [view, control, approve]
fromEnvVar: IPHONE_TOKEN
- label: wallboard-ipad
scopes: [view]
fromFile: /run/secrets/wallboard-token
- label: ci-trigger
scopes: [control]
value: "…"Each entry resolves its secret from the same value/fromFile/fromEnvVar
sources as authToken, and fails closed the same way: if a configured entry
resolves to an empty token, the daemon refuses to start the web API.
authToken and authTokens compose: every configured token is accepted, and
a request authenticates if it matches any of them (constant-time
compared). Two tokens resolving to the same secret are refused at startup
(naming the two labels, never the secret): matching is by secret, so only one
entry's scopes could ever apply, and a scoped entry repeating the all-scopes
authToken would otherwise silently downgrade it.
There are three scopes:
| Scope | Grants |
|---|---|
view |
Every read-only GET: jobs, runs, DAGs, cluster/fleet, schedule intelligence, the state inspector, the SSE log tail, the calendar feeds, and /metrics. |
control |
The mutating actions: POST start / cancel / pause / resume, DAG trigger / backfill, and the MCP endpoint (POST /mcp). |
approve |
Only the DAG approval-gate decision (POST …/decision). |
control and approve each imply view (an action UI has to read state
first), so a [control] token can also call every GET. approve does
not imply control.
The required scope for a route is the safe-method default
(GET/HEAD/OPTIONS → view, everything else → control) with two
promotions: the approval decision needs approve, and /mcp needs control.
A newly added POST route therefore requires control automatically rather
than slipping through unguarded. The promotion follows the action across
transports: the MCP cron_decide_gate tool also requires the presented
token to hold approve, so a [control] token cannot take the decision
through /mcp that this table denies it over REST.
The two failure modes are distinct:
- A missing/unrecognized token is
401 Unauthorized(as before). - A recognized token that lacks the scope a route needs is
403 Forbidden, with a body naming the token label and the missing scope.
To revoke a device, delete its authTokens entry, or rewrite the
secret file a fromFile entry names. The daemon fingerprints token source
files like TLS certificates, so an in-place rewrite counts.
The daemon notices either change on its next housekeeping pass (within a
minute) and rebuilds the web app against the current config, so the
dropped token stops working while the others keep their sessions. To
apply the change immediately instead of waiting for the pass, send
SIGHUP (POSIX) or run cronstable service reload (the Windows
service). A Windows console run has no reload signal
and waits for the housekeeping pass.
The label is only an identifier for humans and log/error messages;
matching is by the secret.
The scalar
authTokenis an all-scopes token;authTokensonly adds narrower credentials beside it. These transport scopes are unrelated to the loopback job-state API'sscope(a key-value isolation boundary); same word, different feature.
Some boards are meant to be read by anyone who can reach them: a wallboard in
the hallway, or a published demo. When the token every viewer would paste is
one you would hand out anyway, it costs them a step and buys you nothing.
Listing scopes in web.anonymousScopes grants them to requests that
present no credential at all:
web:
listen:
- http://0.0.0.0:8080
authTokens:
- label: operator
scopes: [control, approve]
fromEnvVar: CRONSTABLE_OPERATOR_TOKEN
anonymousScopes:
- viewThat instance serves its dashboard, run history, SSE log tails, /metrics, and
calendar feeds to anyone, while start/cancel/pause, DAG trigger and backfill,
approval decisions, device pairing, /shutdown, and /mcp all still require
the operator token.
Four limits are fixed in the code, and no setting relaxes them:
- Only
viewmay be granted. The config schema rejectscontrolandapproveat parse time, so a daemon cannot be configured into anonymous mutation, and the other validators never have to reason about one. - At least one token must also be configured. A daemon with no tokens installs no auth middleware, so every request already holds every scope, and an anonymous grant there would look like a restriction while restricting nothing. That combination is a config error that names the fix.
-
GET /push/devicesis excluded. The registry names paired devices, so it answers403to a credential-less caller even though it is aGET. - A presented credential is judged the same way it is on any other daemon. A
wrong or revoked token still gets
401, so on such a daemon401narrows to "credentials were presented and are invalid", while a caller carrying nothing gets either content or a reasoned403.
GET /whoami tells a client which of the three cases it is in. Branch on
allScopes, never on authenticated:
| Caller | Response |
|---|---|
| Matched token | {"authenticated": true, "label": "operator", "scopes": [...], "allScopes": …} |
| No auth configured | {"authenticated": false, "label": null, "scopes": ["approve","control","view"], "allScopes": true} |
| Anonymous grant | {"authenticated": false, "label": "anonymous", "scopes": ["view"], "allScopes": false} |
anonymous is a reserved label; do not name a real token with it. The bundled
dashboard already keys on this. On an anonymous instance it skips the token
prompt entirely, badges itself view only, and hides the action buttons a
visitor cannot use, while an operator can still enter a token to elevate.
The startup log always names the grant, so an operator can grep for it:
web: requiring bearer-token authentication (1 token; anonymous requests granted scopes: view)
Two things to weigh before enabling it:
-
viewcovers full job output over the log tail, along with schedules, commands, run history and/metrics. If your jobs print secrets, anyone who can reach the listener can read them. - It removes the bearer token as a second barrier against DNS rebinding. A malicious page cannot read a cross-origin response from a daemon that sends no CORS headers, and the Origin gate still refuses forged mutations. But a rebinding attack that makes a browser treat your daemon as same-origin can now read the board without guessing a token. On a loopback or LAN listener, weigh that against how public the board is meant to be.
example/demo-instance/ is a worked example of the whole shape.
Setting web.tls.clientCa makes every https:// listener require a client
certificate signed by that CA: a caller that presents nothing, or presents a
certificate from another CA, fails the TLS handshake and never reaches a
route. Because a server does no hostname verification, that CA file is the
allowlist itself, so it should be a dedicated CA issued only to cronstable
clients.
This is caller authentication at the transport, which is why it also satisfies
the mcp.enabled gate: an enabled /mcp on a routable listener normally
raises a ConfigError without web.authToken, and web.tls.clientCa on an
https:// listener lifts that requirement for those addresses. Plain
https:// does not, because encryption is not authentication (see
MCP).
web.authToken and web.tls.clientCa are enforced independently, so a request
over an mTLS listener still carries the bearer token when one is configured.
cronstable's own clients (cronstable tui, cronstable mcp) present a
certificate with --client-cert/--client-key. See
listener TLS.
Independently of authToken, an always-on middleware refuses cross-site
browser requests to the mutating endpoints (POST /jobs/{name}/start,
POST /jobs/{name}/cancel, POST /jobs/{name}/pause,
POST /jobs/{name}/resume, POST /dags/{name}/trigger,
POST /dags/{name}/backfill, and the task decision route). Those POSTs are
CORS "simple requests" (the browser sends them without a preflight), so
without this gate any web page an operator happens to visit could fire them
at a localhost-bound daemon (classic CSRF, and the DNS-rebinding variant).
The rule, per request:
- Requests without an
Originheader pass untouched: curl, monitoring agents, and other non-browser clients are unaffected (every current browser sendsOriginon cross-site POSTs, which is the attack this defends against). - An
Originwhose authority matches the request's ownHostpasses: the served dashboard keeps working, including behind a TLS-terminating reverse proxy (the scheme is deliberately not compared, only hostname and port). - An
Originonweb.allowedOrigins(exact match) passes, which is how a trusted dashboard served from another origin keeps working. - Anything else, including
Origin: null, is refused403.
GET/HEAD/OPTIONS are never gated (no read here mutates, and the
browser's same-origin policy already hides their responses cross-site), and
/mcp is exempt because it enforces its own mcp.allowedOrigins list with
CORS preflight support (see MCP).
Two overrides for deliberate cross-origin deployments: a specific
origin in a web.headers Access-Control-Allow-Origin header is treated as
allow-listed, and Access-Control-Allow-Origin: "*" disables the gate
entirely (logged as a warning at startup).
The gate complements authToken rather than replacing it: with a bearer token
configured a cross-site page could not authenticate anyway; without one, the
gate is what keeps a browsing operator's localhost daemon from being driven
by arbitrary web pages. Anyone who can reach the listen address directly
(off-browser) is still governed only by authToken and network policy.
One residual remains: a DNS-rebinding page served on the daemon's own port
(so Origin and Host genuinely agree after the rebind) is indistinguishable
from the real dashboard by header comparison alone. Cross-port and cross-host
rebinds are refused, and authToken closes that residual completely.
If authToken is configured but resolves to an empty token, cronstable raises a
ConfigError and refuses to start the web server, rather than silently serving
the control API with no authentication. This happens when:
-
value,fromFile, andfromEnvVarare all empty/absent; -
fromEnvVarnames a variable that is unset (resolves to""); -
fromFilepoints to a file that is empty or contains only whitespace.
If fromFile cannot be read (OSError), cronstable also raises a ConfigError
(web.authToken.fromFile could not be read: ...).
Windows: this entire feature is POSIX-only. It depends on
unix://sockets (which Windows does not support) and onchmod. On WindowssocketModehas no effect andunix://listen URLs are skipped with a warning. Use anhttp://listener. See running on Windows.
web.socketMode is an octal-string file mode applied with chmod
to each unix:// listen socket after it starts (_apply_socket_mode):
web:
listen:
- unix:///run/cronstable/cronstable.sock
socketMode: "0660"- The mode is parsed as base-8 (
int(socketMode, 8)). - It is applied only to
unix://sockets; non-unixaddresses are ignored. - An invalid mode (not an octal integer, raising
ValueError) or achmodfailure (OSError) is logged as a warning (web: could not set socketMode <mode> on <path>: ...) and does not stop startup.
When using a Unix socket on a read-only-root container, point the socket at a small writable volume. See production and container deployment.
The control API lifecycle is driven by start_stop_web_app, called on each config
reload from the scheduler loop:
- If a server is running and the new
webconfig is absent or differs from the running one, the running server is stopped (web_runner.cleanup()) before any new one is started. A change to anywebfield (includingheaders,authToken, orsocketMode) thus triggers a restart of the server on reload. - The server is (re)started only when
webis present,listenis nonempty, and no server is currently running. - A reload also restarts the server when the
web.tlsfiles changed on disk under unchanged config (an in-place certificate rotation), because theSSLContextis built once per start. Open connections, including SSE log streams, are dropped and reconnect. See listener TLS. - Each
listenaddress is bound independently. A bad URL (ValueError) or a bind failure (OSError, for example address already in use) on one address is logged as a warning (web: could not listen on <addr>: ...) and skipped. The remaining addresses still bind, and the config update is not canceled. - The
web: started listening on <addr>log line is emitted only after the bind succeeds. - On shutdown, the running server is stopped after currently running jobs
finish. On Windows, you trigger graceful shutdown with Ctrl-C or Ctrl-Break
rather than
SIGTERMas on POSIX. The daemon still finishes currently-running jobs before stopping the server, identical to POSIX behavior. See running on Windows.
A ConfigError raised while resolving authToken (empty token or unreadable
fromFile) propagates out of start_stop_web_app and is caught by the reload
loop's dedicated web-app handler, which logs Error in the web configuration, so not starting the web API and leaves the web API down until a later reload fixes
it.
The daemon still applies the rest of the new configuration (jobs, cluster,
logging): the web app starts under its own error handling, after the cluster
manager, so a web misconfiguration cannot skip the cluster gate (which would
otherwise fail open and run every Leader job on every node).
Separate from the web control API described earlier, cronstable can run a
second, loopback-only HTTP server that hands its
durable state store to the jobs it runs. It binds
127.0.0.1 on an OS-assigned ephemeral port (or the address in
state.jobApi.listen), and the daemon injects its base URL and a per-run
bearer token into every job's environment, so a job's command line can read
and write durable state, coordinate through a fleet-wide lock, or fetch a
run-scoped secret.
The cronstable state|cursor|lock|artifact|idempotent|secret
CLI commands are thin clients of
this endpoint. The same primitives are also reachable offline against the
store directly, so this server is a front-end, not a second source of truth.
cronstable/jobapi.py serves the surface; the primitives themselves live in
cronstable/jobstate.py.
The endpoint is stood up only when the configuration has a state: section with
jobApi.enabled (default true). A stateless install, or one with a state:
section but jobApi.enabled: false, never starts it and injects nothing.
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Serve the loopback endpoint and inject the CRONSTABLE_* environment into every job. |
listen |
string | (ephemeral) | Address to bind, as host:port, http://host:port, or https://host:port. When omitted it binds 127.0.0.1 on an OS-assigned port. An explicit port must be in 0-65535 (0 = OS-assigned), and a non-loopback host needs allowNonLoopbackBind. https:// needs tls.cert/tls.key and a named host (a wildcard bind is a ConfigError). |
maxValueBytes |
int |
1048576 (1 MiB) |
Reject a KV or cursor value larger than this many bytes (JSON-encoded) with 413. 0 means no limit. |
maxArtifactBytes |
int |
67108864 (64 MiB) |
Reject an artifact payload larger than this many bytes with 413. 0 means no limit. |
lockTtlSeconds |
float | 30 |
Default lease TTL for a job lock (floored at 5). The daemon renews it at a third of the TTL while the job holds it. |
allowNonLoopbackBind |
bool | false |
Explicit opt-in for a non-loopback listen host; without it such a host is a ConfigError (the endpoint serves per-run tokens and staged secrets). Combined with a plaintext http:// listen it logs a warning at startup naming the exposure. |
tls |
map (cert/key/ca) |
(none) | Serves an https:// listen from cert/key (required together). ca is the trust anchor injected into jobs as CRONSTABLE_STATE_CACERT so the job CLI can verify an internally-issued certificate. The context is built once at startup: rotating these files needs a daemon restart. See listener TLS. |
The server derives its own transport-level body cap from these limits (the
larger of the two, plus envelope headroom), so an oversized request body is
refused with 413 rather than silently truncated. Setting either limit to
0 lifts the transport cap too: genuinely unlimited.
On launch the daemon injects these variables into every job's process. All are strings; an unknown scheduled time is the empty string rather than absent, so a job can test the variable instead of guessing whether it was set.
| Variable | Meaning |
|---|---|
CRONSTABLE_STATE_URL |
The endpoint's base URL, for example http://127.0.0.1:54321, or the configured https:// address. |
CRONSTABLE_STATE_TOKEN |
A per-run bearer token, revoked the instant the run ends. |
CRONSTABLE_STATE_CACERT |
Path to the CA the job CLI verifies the endpoint against. Injected only when state.jobApi.tls.ca is set; absent otherwise. |
CRONSTABLE_RUN_ID |
A unique id for this run. |
CRONSTABLE_JOB_NAME |
The job's name (also its default scope). |
CRONSTABLE_ATTEMPT |
The retry attempt number (0 on the first fire). |
CRONSTABLE_SCHEDULED_AT |
The scheduled fire time (ISO-8601), or empty. |
CRONSTABLE_HOST |
The host name. |
Every request must carry Authorization: Bearer $CRONSTABLE_STATE_TOKEN. The
daemon matches the token in constant time against the live run set, so a
missing, malformed, forged, or stale token returns 401 Unauthorized before
any state is touched.
Its body is the same JSON envelope as every other error here, but its error
is the generic 401: Unauthorized, the same bytes whichever of those it was,
so the response tells a caller nothing beyond the status. Other outcomes:
| Status | When | Body |
|---|---|---|
400 |
A caller error: a missing required field, or a body that is not a JSON object. | {"error": "..."} |
409 |
A cursor advanced with a value not comparable to its stored one (a type clash). | {"error": "..."} |
410 |
An artifact record survives but its payload blob was garbage collected. | {"error": "..."} |
413 |
A value or artifact larger than the configured maxValueBytes / maxArtifactBytes. |
{"error": "..."} |
404 |
A get for a key, cursor, artifact, or secret that is not set. The reason names it, and the scoped three name the scope too. |
{"error": "..."} |
500 |
An unexpected failure inside the endpoint. The reason is in the cronstable log, not the body. | {"error": "..."} |
503 |
The state store is unavailable or a backend call timed out. | {"error": "..."} |
Every KV, cursor, idempotency, and artifact call acts in a scope: a namespace
that defaults to the calling job's own name (defaultScope in GET /v1/run), so
one job cannot read another's state by accident. Omit scope for that private
namespace, or pass scope=global (any shared name works) for deliberate
cross-job coordination.
All routes are under /v1/ and registered in JobStateAPI._routes:
| Method | Path | Body / query | Success response |
|---|---|---|---|
GET |
/v1/run |
-- | {runId, job, attempt, scheduledAt, host, defaultScope} |
GET |
/v1/kv/get |
?scope=&key= |
{value, updatedAt}, or 404
|
POST |
/v1/kv/set |
{scope?, key, value} |
{ok, updatedAt} |
POST |
/v1/kv/delete |
{scope?, key} |
{existed} |
GET |
/v1/kv/list |
?scope= |
{scope, keys: [{key, value, updatedAt}]} |
GET |
/v1/cursor/get |
?scope=&name= |
{value, updatedAt}, or 404
|
POST |
/v1/cursor/advance |
{scope?, name, value, force?} |
{value, advanced} |
POST |
/v1/idempotency/claim |
{scope?, key, ttl?} |
{fresh, claimedAt} |
POST |
/v1/idempotency/release |
{scope?, key} |
{released} |
POST |
/v1/artifact/put |
?scope=&name=, raw body |
{sha256, size} |
GET |
/v1/artifact/get |
?scope=&name= |
raw bytes plus X-Cronstable-Sha256 / X-Cronstable-Size, or 404
|
GET |
/v1/artifact/list |
?scope= |
{scope, artifacts: [{name, sha256, size, at}]} |
POST |
/v1/lock/acquire |
{scope?, name, permits?, ttl?, wait?, blockSeconds?} |
{acquired, token?, slot?, fence?, ttl?} |
POST |
/v1/lock/release |
{token} |
{released} |
GET |
/v1/secret/get |
?name= |
{value}, or 404
|
GET |
/v1/secret/list |
-- | {names: [...]} |
cursor/advance is monotonic by default: the stored value only ever moves to
max(current, value), so a replayed or out-of-order batch cannot walk a
watermark backward, and two nodes racing to advance the same cursor converge on
the larger value. advanced is false when the given value was not greater than
the current one (a no-op that is not even a write), and force: true sets the
value unconditionally (a deliberate rewind).
idempotency/claim is a fleet-wide create-if-absent: the first caller gets
fresh: true, every later caller fresh: false, so a retried run can tell
"already did this" from "first time". A positive ttl bounds the dedupe
window (0 is a permanent claim).
The lock is a fleet-wide mutex (or a semaphore, with permits > 1) held as a
TTL lease that the daemon renews for as long as the job holds it and releases
the instant the job releases it or the run ends, so a job that crashes or does
not unlock never leaks a lock. permits outside 1-1024 is rejected up
front with a 400: the acquire pass probes permits sequentially, so the cap
keeps a fully-contended pass bounded.
wait: true retries for up to blockSeconds before giving up. Without it the
call makes a single pass over the permits and returns {acquired: false} when
they are all taken. Like every cronstable coordination primitive the lock is
at-least-once, not exactly-once (the fence token in the reply is there for a
job that needs true fencing).
Run-scoped secrets are staged in memory by the daemon per run and vanish when the run ends. They never touch the store, so only the daemon holds them, and there is no scope on the secret routes: a run sees only its own.
# Inside a job, using the injected env directly.
$ curl -s -H "Authorization: Bearer $CRONSTABLE_STATE_TOKEN" \
"$CRONSTABLE_STATE_URL/v1/run"
{"runId": "…", "job": "nightly-etl", "attempt": 0, "scheduledAt": "2026-07-04T02:00:00+00:00", "host": "node-a", "defaultScope": "nightly-etl"}
# Advance the job's private ETL cursor to the last row processed.
$ curl -s -H "Authorization: Bearer $CRONSTABLE_STATE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name": "rows", "value": 20482}' \
"$CRONSTABLE_STATE_URL/v1/cursor/advance"
{"value": 20482, "advanced": true}In practice a job uses the
job-facing CLI rather than raw curl.
Those commands read the injected environment for you.
- Web Dashboard: the built-in browser UI served by this interface.
-
Push Notifications: the encrypted push channel behind the
/push/devicesroutes and the pairing flow. -
LAN Discovery: advertising this interface as a
_cronstable._tcpmDNS service withweb.bonjour. -
Listener TLS: serving these listeners over
https://, requiring client certificates, certificate rotation, and the client-side flags. -
Pausing Jobs: the semantics behind
POST /jobs/{name}/pauseand/resume. -
Late-Run Detection: the
slapayload field and the monitor behind it. -
Metrics with Prometheus: the full metric reference behind
GET /metricsand Prometheus scrape configuration. -
Clustering and Leader Election: the
GET /clusterview and the separate mTLS/peerendpoint. -
Running on Windows:
unix://listeners andsocketModebehave differently on Windows. - Durable State: the store the job-facing state endpoints expose to the jobs the daemon runs.
- Configuration Reference
- CLI Reference
- Concurrency and Timeouts
- Production and Container Deployment
- Architecture and Internals