Enterprise preview: content, claims and integrations remain subject to review.View launch readiness
Jobs

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 approved — novem 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 with 400.
  • A multipart/form-data upload 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 ./out

The 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

EndpointReturns
runs/<run>/descriptionA Markdown summary of the run, optionally rendered as HTML
runs/<run>/statusThe run's state as plain text; POST here to stop it
runs/<run>/holdPOST here to answer a run waiting at a hold; listed only for callers with execute access
runs/<run>/statsRun metadata — timings, trigger, who started it
runs/<run>/logThe run's timestamped log
runs/<run>/outputThe run's result file(s)
runs/<run>/stepsEach 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>/status

This 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:

FieldDescription
nameThe run id
shortnameThe run's auto-generated shortname
statusSame value as the status endpoint
origin / origin_shortnameThe job the run belongs to
started_on / completed_onUTC timestamps; completed_on is null while running
durationSeconds, two decimals — the runtime so far while the run is still going; null for a run that never started
triggerapi, email or schedule
sourceThe client that triggered it (cli, webpage, scheduler, …)
has_outputWhether output has anything to download
created_byUsername of whoever triggered the run
childrenNested 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"]]
}
FieldDescription
nameThe 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
ordinalThe step's position in the plan
replica_indexnull 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
statuspending, running, success, failed or canceled
started_on / completed_onUTC timestamps; both null for a step that never started
fan_outper_file for a fan-out step, otherwise null
durationSeconds, 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:

FieldDescription
step / phaseThe marked step, and before or after it
ordinalThe marked step's position in the plan
waiting_sinceWhen the run parked here; null until it does
settled_at / outcome / settled_byWhen it was answered, continued or declined, and by whom; null while it waits
folderThe space holding the parked files, as /u/<owner>/s/<space>; null before the run parks here and after it ends
durationSeconds 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 to data that is waiting on the run answers 202 with {"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>/hold

The 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 202 and the run goes back to processing, with the space's current files as the next step's input.
  • Declined answers 202 and ends the run canceled, settling its remaining steps canceled too.
  • A run that is not waiting answers 409 with its current status. When two people answer at once, one gets the 202 and the other the 409.

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:

EndpointReturns
GET .../jobs/<job>/logThe latest run's log — same formats as a run's log
GET .../jobs/<job>/stats/runsThe 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 / /output contract.
  • Schedule — trigger runs on a cron schedule.
  • Config — every job configuration key.