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:
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.
{
"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 firstunknown block <id>block <id> is not executable (<kind>)blank input referenced by block: NAMEblock <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.
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.