Runs
Every time a job is triggered, novem records a run. Each run keeps its own description, log, output, stats, status and steps, reachable under the job's runs directory.
AI-assisted, human-approvednovem uses AI to review and keep our documentation up to date.
A run is one execution of a job's chain,
whether you triggered it by hand, a schedule
fired, or an inbound e-mail kicked it off. Runs live under the job's runs
directory:
daily_report
├── data => POST here to trigger a run
├── log => Latest run's log (shortcut)
├── stats
│ └── runs => Run history (time, trigger, created_by, …)
└── runs
└── <run> => One entry per run
├── description => Markdown summary published for this run
├── hold => POST "continue" or "declined" while the run waits
├── log => The run's log
├── output => The run's result file(s)
├── stats => Run metadata (timings, trigger, …)
├── status => processing / waiting / success / failed / canceled
│ POST "canceled" here to stop the run
└── steps => Each chain step's status and timing
Triggering a run
Trigger a run by posting to the job's data endpoint (this is what the CLI's
-R flag does). What you send becomes the run's input, mounted at /input
inside the first chain step:
- A JSON body (
Content-Type: application/json) is stored as/input/input.json. The body must be a JSON document; an empty or non-JSON body is rejected with400. - A
multipart/form-dataupload stores each file as/input/<filename>, original names preserved.
# trigger a run (input files each prefixed with @)
novem -j daily_report -R
novem -j daily_report -R @data.csv
# save the run's output to disk
novem -j daily_report -R -o ./outThe request stays open while the chain executes, and the response body is the
run's result: a single output file is returned as-is, several are bundled
into one .zip. See how your code runs for the
/input / /output contract.
Triggering requires write access to the job; reading runs requires read access.
Listing runs
GET /v1/code/jobs/<job>/runs returns the 30 most recent runs as a JSON directory listing, newest
first. Each entry's name is the run id, <YYYYMMDD>-<HHMMSS>-<request id>,
with created_on set to when the run started and last_modified to when
it completed (or started, while still running).
[
{
"name": "20260613-081502-f3a9c2d4...",
"uri": "/v1/code/jobs/daily_report/runs/20260613-081502-f3a9c2d4...",
"type": "dir",
"permissions": ["r"],
"actions": ["OPTIONS", "GET"],
"created_on": "2026-06-13T08:15:02Z",
"last_modified": "2026-06-13T08:15:31Z"
}
]
GET .../runs/<run> lists the five per-run files described below.
Per-run endpoints
| Endpoint | Returns |
|---|---|
runs/<run>/description | A Markdown summary of the run, optionally rendered as HTML |
runs/<run>/status | The run's state as plain text; POST here to stop it |
runs/<run>/hold | POST here to answer a run waiting at a hold; listed only for callers with execute access |
runs/<run>/stats | Run metadata — timings, trigger, who started it |
runs/<run>/log | The run's timestamped log |
runs/<run>/output | The run's result file(s) |
runs/<run>/steps | Each chain step's status and timing, and the edges between steps |
status
A single plain-text value: processing while the chain executes, then
success or failed. A run that never started because a permission check
blocked it shows rejected. A run you asked to stop reports canceling
while it winds down, then canceled — see stopping a run.
A run stopped at a hold reports waiting until
someone answers it.
A chain run is never started a second time. If the worker running it is lost
after the chain was submitted, the run ends failed with "This run was
interrupted and cannot be continued. Run the job again.", and its unfinished
steps are settled canceled.
Stopping a run
POST the desired end state to a run's status to stop it:
curl -X POST -H "Authorization: Bearer $NOVEM_TOKEN" \
-H "Content-Type: text/plain" \
--data 'canceled' \
https://api.novem.io/v1/code/jobs/daily_report/runs/<run>/statusThis records an intent, not an outcome. novem tears the run down — killing the container or stopping the chain — but a run that was already finishing may get there first, so the end state is whichever actually happened:
processing ──POST canceled──▶ canceling ──▶ canceled (stopped in time)
├─▶ success (it finished first)
└─▶ failed (it broke first)
The run reports canceling in the meantime, and the request answers 202.
Asking again while a cancel is pending is a no-op (200); asking for a run
that already finished answers 409 with its final status. Stopping requires
the same execute access as triggering a run — if you can start it, you can
stop it.
A stopped run keeps whatever log it produced, is not counted as a failure (so it never contributes to a job being auto-paused), and produces no output.
The original triggering request stops waiting as soon as the run is torn down:
the blocked HTTP call to data answers 409, and a mail-triggered run gets
the error reply, which names the cancel. To find out why a run ended, read its
status and log — the trigger's own error body is deliberately generic.
A run waiting at a hold is not stopped here: asking
answers 409 with "This run is waiting for a person. Answer its hold to stop
it." Decline the hold instead.
Note: Runs of a composed chain are stopped
through their top-level run. Cancelling a nested child run alone would leave
the parent waiting for output that will never arrive, so it answers 409.
stats
Run metadata. Plain text key: value pairs by default; send
Accept: application/json for a JSON object:
| Field | Description |
|---|---|
name | The run id |
shortname | The run's auto-generated shortname |
status | Same value as the status endpoint |
origin / origin_shortname | The job the run belongs to |
started_on / completed_on | UTC timestamps; completed_on is null while running |
duration | Seconds, two decimals — the runtime so far while the run is still going; null for a run that never started |
trigger | api, email or schedule |
source | The client that triggered it (cli, webpage, scheduler, …) |
has_output | Whether output has anything to download |
created_by | Username of whoever triggered the run |
children | Nested runs for any composed jobs, [] when none |
steps
What each step of a chain is doing, as JSON.
Every step the run will attempt is listed from the moment it is submitted, so
a step that has not started yet shows as pending rather than being absent.
The run page draws its live progress graph from this endpoint.
{
"steps": [
{
"name": "prep",
"ordinal": 0,
"replica_index": null,
"status": "success",
"started_on": "2026-10-01T09:00:00+00:00",
"completed_on": "2026-10-01T09:00:12+00:00",
"fan_out": null,
"duration": 12.00
},
{
"name": "report",
"ordinal": 1,
"replica_index": null,
"status": "running",
"started_on": "2026-10-01T09:00:12+00:00",
"completed_on": null,
"fan_out": null,
"duration": 3.50
}
],
"edges": [["prep", "report"]]
}
| Field | Description |
|---|---|
name | The step's name in the chain. Steps spliced in from a referenced job are prefixed with the step that referenced it, as in leaf__main |
ordinal | The step's position in the plan |
replica_index | null for an ordinary step. A fan-out step has one row with null for the step itself, followed by one row per replica numbered from 0 |
status | pending, running, success, failed or canceled |
started_on / completed_on | UTC timestamps; both null for a step that never started |
fan_out | per_file for a fan-out step, otherwise null |
duration | Seconds, two decimals — the time so far while the step runs; null for a step that never started |
edges lists [from, to] pairs from the run's plan, so a branching chain
keeps its shape rather than being read as a straight line from ordinal.
A chain with holds also returns a
holds array, one entry per possible stopping point in the plan:
| Field | Description |
|---|---|
step / phase | The marked step, and before or after it |
ordinal | The marked step's position in the plan |
waiting_since | When the run parked here; null until it does |
settled_at / outcome / settled_by | When it was answered, continued or declined, and by whom; null while it waits |
folder | The space holding the parked files, as /u/<owner>/s/<space>; null before the run parks here and after it ends |
duration | Seconds the run has waited, or waited in total once answered |
The steps after a hold stay pending while the run waits.
When a step fails, the steps that were still pending or running are settled
canceled: they were stopped, not broken. A run that is not a chain, or one
that ran before steps were recorded, returns {"steps": [], "edges": []}.
log
The run's log entries in chronological order. Plain text by default. With
Accept: application/json you get an array of
{ "log_time", "severity", "message" } objects with UTC ISO timestamps. A
run with no log entries returns an empty 200.
A chain run opens its log with the pipeline and then one line per step, naming the image each step resolved to and the commit it was built from:
Chain: prep -> report
prep: @alice/data_fetcher:latest @ 1a2b3c4d5e6f7890abcdef1234567890abcdef12
report: @alice/report_builder:latest @ fedcba0987654321fedcba0987654321fedcba09
The commit is omitted for an image with no recorded one. The step reads the
same values from its environment as
NOVEM_SOURCE_COMMIT; the log
is where they survive the run.
description
A run can publish a Markdown description of its result while it executes. A
plain GET returns the Markdown source; request application/json to receive
both forms:
{
"raw": "# Daily report\n\nCompleted successfully.",
"html": "<h1>Daily report</h1><p>Completed successfully.</p>"
}
Write it with POST and clear it with DELETE. The caller needs write access
to the run, so this is normally done from inside the job with a
NOVEM_TOKEN:
curl -X POST -H "Authorization: Bearer $NOVEM_TOKEN" \
-H "Content-Type: text/plain" \
--data-binary @run-summary.md \
"https://api.novem.io/v1/code/jobs/daily_report/runs/$NOVEM_JOB_RUN/description"
Markdown rendering is asynchronous. A JSON read immediately after a write may
briefly contain the new raw value with the previous (or empty) html; poll
when a caller needs the rendered form before continuing.
The run detail page shows a description above the log and starts the log collapsed when a description is present.
The description is not versioned. Asking for X-History: list therefore
returns an empty revision list.
output
Downloads the run's result with its original filename and content type, the
same payload the triggering request received. A run that produced no output
returns 204 No Content.
Runs waiting at a hold
A chain step marked with a hold stops the run and waits for a person. Up to that point the run behaves as usual. When it reaches the hold:
- The files at that point are parked in a new space named
<job>-<run>-<step>-<phase>, owned by the job's owner and shared read and write with everyone who can execute the job. A hold before the first step parks the trigger's input; any other hold parks the output of the steps that just ran. - The run's status becomes
waiting. A request todatathat is waiting on the run answers202with{"status": "waiting", ...}instead of output. - The run page shows Review files, Continue past
<step>and Stop, and the space shows a notice linking back to the run.
Edit, add or delete files and folders in the space, then answer the hold:
curl -X POST -H "Authorization: Bearer $NOVEM_TOKEN" \
-H "Content-Type: text/plain" \
--data 'continue' \
https://api.novem.io/v1/code/jobs/daily_report/runs/<run>/holdThe body is one word. continue (or resume, proceed, or an empty body)
lets the run go on; declined (or decline, reject) stops it. Any other
word answers 400 and leaves the hold open. Answering needs the same
execute access as triggering the run.
- Continue answers
202and the run goes back toprocessing, with the space's current files as the next step's input. - Declined answers
202and ends the runcanceled, settling its remaining stepscanceledtoo. - A run that is not waiting answers
409with its current status. When two people answer at once, one gets the202and the other the409.
The space is deleted when the run ends, whichever way it ends. A held run waits
until someone answers: there is no timeout, and nobody is notified, so keep an
eye on the run page or poll its status.
Composed jobs: the run tree
When a chain references another job, each referenced job invocation gets its own run, nested under the run that triggered it. The result is a run tree: the top-level run owns its steps, and every composed job hangs beneath it as a child run with its own status, timings and duration.
The stats endpoint carries this tree in its children array. Each entry
names the child run, the job it came from, the step it fulfils, and its own
timing, so cost rolls up from the parts:
{
"name": "20260617-081502-f3a9c2d4...",
"status": "success",
"duration": 42.5,
"children": [
{
"name": "20260617-081507-9b1e...",
"shortname": "kPq7T",
"job": "nightly_rollup",
"job_shortname": "VA0PG",
"parent_step": "rollup",
"status": "success",
"started_on": "2026-06-17T08:15:07Z",
"completed_on": "2026-06-17T08:15:29Z",
"duration": 22.0
}
]
}
Child runs nest under their parent and are not listed as standalone runs of the referenced job: the runs listing and history for a job show only its top-level runs. Whoever can read the top-level run can read its descendants.
Retention
Run logs and output are retained for 30 days. After that, log and
output answer 410 Gone; the run listing, stats and status remain
available indefinitely. The run description remains with that metadata.
Job-level shortcuts
Two conveniences live directly on the job:
| Endpoint | Returns |
|---|---|
GET .../jobs/<job>/log | The latest run's log — same formats as a run's log |
GET .../jobs/<job>/stats/runs | The full run history: one row per run with time, trigger, created_by, duration and status (plain-text table, or JSON with Accept: application/json). created_by is the triggering user — @name in the table, the bare username in JSON |
stats/runs is not capped at 30 entries, so it's the place to look when the
runs listing has rotated past what you're after. A run still processing
reports the runtime so far as its duration, so it grows between requests.
Note: Runs are never public. Even when a job is shared with public,
its runs, logs and output are only readable by authenticated users with
read access to the job: the owner and explicit share grantees.
Next steps
- Jobs overview — the
/input//outputcontract. - Schedule — trigger runs on a cron schedule.
- Config — every job configuration key.