Skip to content

HTTP Endpoints

The page's JavaScript and HTML forms are the intended clients, but the endpoints are plain enough to drive with curl, which is also how the smoke test works. All of them are served by the one runsheets process on loopback.

Authentication

The token is generated when the server starts and printed in the link the banner shows (http://127.0.0.1:4567/?token=…). Every request, GET included, must hold it, either as the cookie a browser gets by opening that link or in the X-Runsheets-Token header. Without it a browser gets a page saying to open the link from the terminal and a script gets {"error": "missing session key: open the link runsheets printed"}, both with 401.

Every non-GET request must also carry the token itself. It is embedded in every page, the start page included:

<meta name="rs-token" content="492a33d8abd63f00ff946cd79bbb2255">

Send it as the X-Runsheets-Token header or as a _token form field. Requests without a valid token get 403 with a JSON body on the execution endpoints and an HTML error page elsewhere.

The Host header must be localhost, 127.0.0.1, ::1 or the bind address, or the request gets 403 before routing. (For a wildcard bind the check is off; see the CLI page.)

Every HTML page is sent with Cache-Control: no-store and a Content-Security-Policy whose script and style sources are a nonce unique to that response; the page's own inline script and stylesheet carry it.

A script copies the token from the banner's link and sends it on every request:

B=http://127.0.0.1:4567
TOKEN=3f9c…                      # the ?token= value from the banner
curl -s -H "X-Runsheets-Token: $TOKEN" $B/session

The session

Until the session has started, every GET (other than /session/new) redirects to /session/new with 302, and every other request with a valid token gets 409. After it has ended, every request but GET /session gets 410, except GET /session/new and POST /session, which still redirect to /session.

Method and path Returns
GET /session/new The start page: who is starting the session, and why. Once the session has started, a 302 to /session.
POST /session Starts the session. Form fields: _token, engineer, why; why becomes the first note. Responds 303 to /library (or / for one runbook); 422 with the start page and a message if either is blank; 303 to /session if a session has already started.
GET /session The session page: engineer, host, start time, notes, runs, the end panel and the last 200 lines of session.log.
POST /session/notes Adds a timestamped note. Form fields: _token, note, and back (optional; a path starting with / to return to). Responds 303 to back, or to /session#notes without it; 422 with the session page and "A note needs some text." for a blank note.
POST /session/end Ends the session: every run is closed with its derived status, anything running is stopped, and the server stops half a second later. Responds 200 with the Session ended page.

Pages

Method and path Returns
GET / The landing page.
GET /steps/:slug A step page. verify and rollback are slugs too. 404 for an unknown slug.
GET /verify The Checks page: every verify step and verify.md. 404 if the runbook has none.
GET /run The transcript of the open run of the runbook on screen, or of its most recent run, with the drift panel for a closed run. 404 if the runbook has never had a run.
GET /runs/:id A transcript of the runbook on screen, by run id. The id must match [\w.-]+. 404 for a bad or unknown id.
GET /files/*path A regular, non-hidden file under the directory runsheets was started on, with its content type, X-Content-Type-Options: nosniff and a Content-Security-Policy that allows no script (see File serving). .png, .jpg, .jpeg, .gif, .webp, .svg, .pdf and .txt are served inline; anything else with Content-Disposition: attachment. 404 otherwise.
GET /docs/*path A markdown file under that directory, rendered as a page with nothing executable. One of the own files of the runbook on screen redirects to its page; another runbook in the library redirects to its library page. 404 for anything that is not a .md file.
GET /search?q= Full-text search over every runbook in the library (or the one runbook), best first. Works before a runbook is selected. An empty q shows the search form.

The library

When the server was started on a directory of runbooks, these routes exist too; otherwise they are 404. Until a runbook is selected every other page redirects to /library, except the session routes, POST /runs, /search, /docs/* and /files/*, which resolve against the library directory so the links in a runbook's library pane work before it is selected.

Method and path Returns
GET /library The library page: the folder tree, and the root folder in the main pane.
GET /library/*slug The same page with that runbook or folder selected. The slug is the path inside the library (platform/database/backup). 404 for an unknown one.

A GET of a library page rescans the directory when anything in it has changed, so a runbook added while the server is up shows on the next visit.

Runs

POST /runs

Selects a runbook: establishes its run, or returns to the run it already has in this session, and puts it on screen. Form fields: _token, slug (the runbook's path in the library; ignored when the server was started on one runbook), and inputs[NAME] for each input. Inputs left out resolve from earlier in the session (non-secret only), the environment and the defaults. When the runbook already has a run, the inputs are ignored. Selecting a runbook finishes nothing; every run stays open until the session ends.

Responds 303 to /; 404 for an unknown slug; 409 before the session has started.

curl -X POST --data-urlencode "_token=$TOKEN" --data-urlencode "slug=hello" \
  --data-urlencode "inputs[NAME]=smoke" $B/runs

POST /run/inputs

Changes the inputs of a run. Form fields: _token, inputs[NAME] for each input, and runbook (optional; see Which run). A secret sent empty keeps its current value. The change is recorded as an inputs event and logged. Responds 303 to /; 409 when the runbook on screen has no run.

POST /steps/:slug/mark

Form fields: _token, status (done or skipped), note (optional), and runbook (optional; see Which run). Responds 303 to the next step or the landing page, with that runbook put on screen; 422 for another status, or for a slug that is not a numbered step (the landing page, verify and rollback cannot be marked); 404 for an unknown slug; 409 when the runbook has no run.

Execution

Which run

Marking a step, executing a block, acknowledging one and changing inputs act on a run. The page says which with a runbook field, the slug of the runbook it shows, so a tab left on one runbook keeps acting on it after another tab has put a different one on screen. Without the field the request acts on the run of the runbook on screen. A runbook that has no run in the session gets 409: <slug> has no run in this session; start it first.

POST /blocks/:id/execute

Starts executing a run, destructive or background block. Responds 202 with the execution as JSON, including its initial (usually empty) output. expected is the comparison with the block's expect block (match and the side-by-side rows) once the execution has ended, and null while it runs, when it could not start, or when the block has no expect block.

curl -s -X POST -H "X-Runsheets-Token: $TOKEN" $B/blocks/010-say-hello-1/execute
{
  "id": "1b6a8f0c2d3e",
  "block_id": "010-say-hello-1",
  "step": "010-say-hello",
  "command": ["bash"],
  "background": false,
  "state": "running",
  "pid": 83412,
  "started_at": "2026-10-07T17:33:48.871-05:00",
  "finished_at": null,
  "duration": null,
  "exit_status": null,
  "signal": null,
  "error": null,
  "cmd": "010-say-hello-1.1.cmd",
  "log": "010-say-hello-1.1.out",
  "output": "",
  "output_size": 0,
  "output_truncated": false,
  "success": false,
  "expected": null
}

The page also sends digest, the data-digest of the block it shows. If the runbook has been edited since and the block no longer matches, the request is refused with 409 (block <id> changed on disk since this page loaded; reload the page). A request without digest skips the check.

A destructive block needs a confirmation code. The first request without one is answered 428 with the code to type:

{ "error": "destructive block 040-exercise-failure-1 needs confirmation: type 72bd",
  "challenge": "72bd", "block_id": "040-exercise-failure-1" }

Send it back as the confirm form field. The code is issued per block, stays the same until it is used, and is retired by the execution it confirmed. A wrong code gets the same 428 again. Since the 428 hands the code to whoever asked, the code guards against a slip in the page, not against a script that holds the session token.

CODE=$(curl -s -X POST -H "X-Runsheets-Token: $TOKEN" $B/blocks/040-exercise-failure-1/execute | jq -r .challenge)
curl -s -X POST -H "X-Runsheets-Token: $TOKEN" -d "confirm=$CODE" $B/blocks/040-exercise-failure-1/execute

Errors are 409 with {"error": "..."}:

  • start the run for <runbook title> before executing blocks
  • <slug> has no run in this session; start it first
  • unknown block <id>
  • block <id> is not executable (<kind>)
  • blank input referenced by block: NAME
  • block <id> changed on disk since this page loaded; reload the page

POST /blocks/:id/acknowledge

Records that the operator ran a terminal block in their own terminal. Form fields note, runbook and digest (as for execute) are optional. Responds 201 with the acknowledgement:

{ "at": "2026-10-07T17:33:55.120-05:00", "step": "020-inspect-ruby",
  "note": "pressed enter", "block_id": "020-inspect-ruby-3" }

409 when the runbook has no run, for an unknown block, or for a block that is not terminal.

GET /executions/:id

The current state of an execution, same shape as above. output is the last 256 KB of the log, output_size is the full size and output_truncated says whether anything was cut. 404 for an unknown id. Like every request, it needs the session key and a loopback Host.

The page polls this every 500 ms until state is no longer running.

curl -s -H "X-Runsheets-Token: $TOKEN" $B/executions/1b6a8f0c2d3e | jq '{state, exit_status, output}'

POST /executions/:id/stop

Asks a running execution to stop: TERM to its process group, KILL two seconds later if needed. Responds 202 with the execution as it is at that moment (usually still running); poll GET /executions/:id until the state is stopped. Any execution in the session can be stopped, whichever runbook started it. Stopping an execution that has already ended is a no-op. 409 for an unknown id.

curl -s -X POST -H "X-Runsheets-Token: $TOKEN" $B/executions/1b6a8f0c2d3e/stop

Errors

Status Meaning
401 No session key: neither the cookie from the banner's link nor the token in X-Runsheets-Token.
403 Missing or invalid token on a non-GET request, or a non-loopback Host.
404 Unknown step, run, execution, file or runbook slug.
409 The operation is not allowed now (Runsheets::RunError): no session yet, no run for the runbook acted on. Refusals are logged at warn.
410 The session has ended. Only GET /session still answers.
422 A blank engineer or reason on POST /session, a blank note, an invalid step status, or a mark on a document that is not a numbered step.
428 A destructive block needs its confirmation code (Runsheets::Run::ConfirmationRequired); the body carries challenge.
500 The runbook failed to load (Runsheets::RunbookError), or an unexpected error.

JSON error bodies are {"error": "message"} on /blocks/* and /executions/*, or when the request prefers application/json. Everything else gets an HTML error page: a heading in words (Not allowed, Not found, Not right now, The session has ended, Check what you entered, Something went wrong), the message, and links to Home, Session and, in a library, Runbooks. It sits in the runbook's frame, or beside the library tree when no runbook is open.

Driving a whole session from the shell

Against a server started on examples/directory_based_runbooks/hello without --engineer and --why:

B=http://127.0.0.1:4567
TOKEN=3f9c…                      # the ?token= value from the banner
post() { curl -s -o /dev/null -X POST -H "X-Runsheets-Token: $TOKEN" "$@"; }
get()  { curl -s -H "X-Runsheets-Token: $TOKEN" "$@"; }

post --data-urlencode "engineer=ci" --data-urlencode "why=smoke test" $B/session
post --data-urlencode "inputs[NAME]=cli" $B/runs

ID=$(curl -s -X POST -H "X-Runsheets-Token: $TOKEN" $B/blocks/010-say-hello-1/execute | jq -r .id)
until [ "$(get $B/executions/$ID | jq -r .state)" != running ]; do sleep 0.5; done
get $B/executions/$ID | jq -r .output

post --data-urlencode "status=done" $B/steps/010-say-hello/mark
post --data-urlencode "note=greeting checked from the shell" $B/session/notes
post $B/session/end

On a server started on a directory of runbooks, add --data-urlencode "slug=hello" to the POST /runs. Ending the session closes the run (partial here, since only one step was marked) and stops the server.