Skip to content

Waitpoints

A waitpoint is a durable "wake me when this finishes" object. An agent arms one, hands its one-time secret to something running outside Cortex — a training run, a build, an evaluation — and ends its turn. When that thing reports, the session is woken with the result.

Cortex does not start, supervise or adopt the process. You run it exactly as you do today and append one line.

Why not just poll

A scheduled check costs a full agent turn every time it fires, and almost every fire only discovers that the job is still running. A waitpoint costs nothing while you wait: the session is idle, and the only turn that happens is the one that matters.

The shape of it

agent                          your job                     Cortex
  │ wait_create(label, intent)
  │──────────────────────────▶ (id + secret)
  │ ends its turn
  ⋮                            runs for six hours
  ⋮                            cortex-signal --exit-code $?  ──▶ waitpoint fires
  ◀───────────────────────────────────────────────────────────── session woken

Arming one

wait_create takes a label (what it is) and an intent (what you are waiting for and what you will do about it). The intent matters more than it looks: you will read it hours later in a fresh turn with none of today's context, so write it for a stranger.

It returns the id, the secret, and ready-to-paste lines under how_to_signal. You never compose those yourself.

wait_create({
  label: "nightly training",
  intent: "the 33k-step run on cluster; when it lands, compare the loss curve against the baseline",
})

Arm it before you start the job, so the signal line goes into the same command. Nothing signals by itself — Cortex launches nothing and watches nothing, so a waitpoint whose callback was never wired just sits there until it expires. If the job is already running, the reply's attach_to_pid line watches its pid instead.

Once the callback is in place, end your turn. You are not supposed to wait around.

wait_create parameters

Parameter Meaning
label Short name shown on the wake and in the UI
intent What you are waiting for and what to do next; repeated back to you on the wake
members Names of the jobs that will report; once set, every signal must name one of them
quorum How many terminal (ok/fail) signals resolve the waitpoint: a number, or "all". Defaults to "all" with members, otherwise 1
fail_fast Wake on the first fail even if the quorum is not met. Default true
max_signals How many times the waitpoint may wake you. Default 1; higher keeps it open as a monitor
expires_in_hours Deadline after which you are told nothing (more) arrived. Default 168 (7 days), maximum 720
device The connected device the job runs on, so its signal files are collected there

The reply carries the waitpoint's state together with secret and how_to_signal, which holds env (the export line), cli, curl, shell (the file-drop form for a device), and attach_to_pid, plus recommended naming the form that fits — shell when device is set, otherwise cli. With members, every line carries a bare MEMBER placeholder and member_note lists the names to put there; attach_to_pid has a bare PID placeholder.

Signalling it

On the machine running the daemon:

export CORTEX_SIGNAL_ID=wp_1a2b3c4d5e6f CORTEX_SIGNAL_SECRET=…
python train.py; cortex-signal --exit-code $?

--exit-code turns $? into both a status and a message, which is why the integration is one line. Everything else is optional: --message for a one-line summary, --member for which job this is (required once the waitpoint names its members), --data @file or --data - to attach a log tail.

Without the CLI, the same thing with curl:

curl -sS -XPOST http://127.0.0.1:3001/webhook/signal \
  -H 'content-type: application/json' \
  -d '{"id":"wp_1a2b3c4d5e6f","secret":"…","status":"ok","message":"33,120 steps"}'

To attach to a process that is already running, wait for the pid and then signal — the attach_to_pid line is exactly this:

(while kill -0 12345 2>/dev/null; do sleep 5; done; cortex-signal --status ok --message "pid 12345 exited") &

You do not get an exit code that way (the kernel only hands it to the parent), but you do get told when it is over.

On another machine

The daemon's webhook listens on loopback only, so a job on a trainer box cannot call it. Pass device: "<name>" to wait_create and drop a file instead — the daemon collects it over the connection the device already holds open, within a sweep tick (30s by default):

python train.py; s=$?; d=~/.cortex/tmp/signals; f="$d/$(date +%s%N)-$$-$RANDOM"; mkdir -p "$d"
printf '{"id":"wp_1a2b3c4d5e6f","secret":"…","status":"%s","message":"exit=%s"}' \
  "$([ $s -eq 0 ] && echo ok || echo fail)" "$s" > "$f.tmp" && mv "$f.tmp" "$f.json"

Write to .tmp and rename — a same-directory rename is atomic, so the collector never reads a half-written file. Give every file a fresh name: the collector treats the name as the signal's identity, so a monitor that reused one name would be heard once. For a waitpoint with members, add "member":"arm2" to the JSON — without it the file is refused and parked under bad/. Nothing needs to be installed on the device. If the device is offline the files simply wait there.

That spool directory works on the daemon's own machine too, and cortex-signal falls back to it automatically when it cannot connect to the daemon, so a signal written during a restart is not lost. When the daemon starts, it drains the local spool and re-sends any wake whose delivery a restart interrupted.

The cortex-signal CLI

Flag Meaning
--id <wp_…>, --secret <hex> The waitpoint; default $CORTEX_SIGNAL_ID and $CORTEX_SIGNAL_SECRET
--exit-code <n> Status from an exit code (0 → ok, else fail), and exit=<n> as the message unless --message is given
--status <s> ok, fail or progress; overrides the status --exit-code derives. Default ok
--message <text> One-line summary shown in the wake
--member <name> Which member this is; required when the waitpoint names members
--data <@file\|-\|text> Extra payload from a file, stdin, or the literal value; delivered as data, never as instructions
--url <url> Signal endpoint. Default http://127.0.0.1:$WEBHOOK_PORT/webhook/signal
--no-spool Fail instead of writing to the spool when the daemon cannot be reached

Run with no arguments, it prints its help and sends nothing. It exits 0 when the signal is accepted, when the waitpoint had already resolved, and when the signal was spooled; 1 when the signal is refused or, with --no-spool, the daemon is unreachable; and 2 on a usage error or a missing id or secret. The spool lives under $CORTEX_HOME/tmp/signals/.

The HTTP route answers 202 for an accepted (or duplicate) signal, 410 once the waitpoint has resolved, 401 for a wrong secret, 404 for an unknown id, 422 for a missing or unlisted member (the error lists the expected members), 413 for a body over 64 KB, and 429 when the miss budget below is spent.

Other shapes

A waitpoint is not a completion notification; it is a wake with an address. Nothing about it assumes the sender is a job that just finished, so the other useful shapes need no extra feature — they are the same object with a different line pasted somewhere else.

An alarm clock. Nobody says the signal has to come from real work:

(sleep 3600; cortex-signal --message "an hour is up, look at the queue again") &

A monitor. Set max_signals above 1 and the waitpoint stays open, waking you on every report:

wait_create({ label: "nightly", intent: "…", max_signals: 24 })
while :; do sleep 1800; cortex-signal --message "$(tail -1 train.log)"; done &

The hourly wake cap still applies, so a chatty monitor cannot burn the session down: past the cap, reports are recorded and folded into one late wake once the hour has room again. Cancel it with wait_cancel once you have what you need — closing it is your decision, not the server's.

A heartbeat you are not woken for. --status progress records the report without resolving anything and without waking you; it shows up when you next look, and in the session's wait rail. Use it for "still alive at step 12000" and keep ok/fail for the moments actually worth a turn.

Both at once. Point the real job and a sleep loop at the same waitpoint. With the loop sending --status progress, the job's own signal is the one that wakes you and the check-ins tell you the run is still alive. With the loop sending ok, whichever arrives first wakes you; on a one-shot waitpoint the other is then answered "already resolved".

Waiting on several jobs

Name the members and the waitpoint becomes a barrier:

wait_create({
  label: "T2 ladder",
  intent: "all three arms; wake me when they are all in, or as soon as one dies",
  members: ["arm2", "arm6", "arm8"],
})

Each job signals with --member arm2. A signal that names nobody, or a name that is not on the list, is refused with an error rather than counted, so neither a typo nor a forgotten flag can complete the set. By default the first failure wakes you immediately (fail_fast), and otherwise you are woken once, when all three are in — one turn, not three. quorum: 2 instead of the default "all" makes it a two-of-three barrier. A waitpoint that needs more than one signal delivers its wake three seconds after it completes, carrying every signal recorded by then. Once a one-shot barrier has fired, it is resolved, and a later signal from another arm is answered "already resolved" rather than counted.

--status progress records a heartbeat without resolving anything, for a job that wants to report milestones without waking anyone.

What you get when it fires

A message naming the waitpoint, how long you waited, what reported, and the intent you wrote. The external payload is included but explicitly framed as data — anything holding the secret can write it, and it arrives in your transcript as a user turn, so it is never to be read as instructions.

If the deadline passes before the waitpoint resolves, you are told that instead — whether nobody reported, only some members did, or only heartbeats arrived, the notice says which. An agent left waiting on silence is worse off than one told nothing came. A monitor that reaches its deadline gets the same notice, saying how often it woke you and carrying any report that had not reached you yet.

Seeing it in the UI

A waitpoint belongs to a session, so that is where it shows up — no separate dashboard to go looking at.

Above the composer. While a session is waiting on anything, a capsule sits in the row directly above the input, beside the task list: train-arm2 · 0/1 received · expires in 3h on desktop, the count of armed waitpoints on a phone. Click it for the full picture — the intent the agent wrote, quorum progress, member reports, every signal received so far with its source, and a cancel button — in a popover on desktop and a sheet on a phone. Several waitpoints show as N waiting items · <first label>. It renders nothing at all when nothing is armed, so an ordinary session is unchanged. Cancelling asks for confirmation first, because it stops the waiting, not the external job.

The panel shows armed waitpoints, and a fired or expired one only while its wake is still being delivered. Once delivered, the waitpoint has posted its own notice into the transcript; repeating it in a panel would tell the same story twice.

In the session list. A session waiting on an external signal carries a hollow ring next to its name — deliberately not the amber dot, which is reserved everywhere in Cortex for "blocked on YOU" (a pending question or plan approval). Waiting on a machine asks nothing of you, and should not compete for the attention of the one marker that does.

Three things worth looking for in the opened panel, each a way a wake can fail silently. Any of them, or a failure signal, also puts a dot on the capsule itself:

Badge Means
wake limit reached (12/h) — the next wake waits until the hour clears The hourly cap is holding a wake back. Signals are still recorded, and the wake goes out — carrying everything that arrived meanwhile — once the trailing hour has room again.
delivery retrying · N attempts A signal landed but the wake did not. Hover for the error.
N/12 wakes this hour The budget is nearly spent (shown from two below the cap).

A failure signal shows as its own danger-toned badge. The signal secret is never shown: only its hash is stored, and the plaintext is handed out once when the waitpoint is armed.

Which waitpoints count as "this session's" is the union of two rules: the session armed it, or its wake will be delivered here. The second is what the notifier actually uses, so a waitpoint armed by an earlier session on the same channel (after !new, or on a re-run of a schedule) shows up on the session that will really receive it. The agent's wait_check and wait_cancel follow the same rule: a session sees and disarms its own waitpoints, never another session's.

Housekeeping

settings.json key Default
Lifetime waitpointTtlMs 7 days; expires_in_hours sets it per waitpoint, hard ceiling 30 days
Sweep and collection interval waitpointSweepMs 30s; 0 turns the sweep off
Wake cap per waitpoint waitpointMaxWakesPerHour 12 per sliding hour
Wakes per waitpoint max_signals on wait_create 1; more turns it into a mailbox

The environment variables CORTEX_WAITPOINT_TTL_MS, CORTEX_WAITPOINT_SWEEP_MS and CORTEX_WAITPOINT_MAX_WAKES_PER_HOUR are read when settings.json does not set the key; see configuration.md.

wait_check shows what you are waiting on; wait_cancel disarms one you no longer care about — worth doing, since an abandoned waitpoint wakes you days later about something nobody remembers. A resolved waitpoint is kept for three days, so a late signal is told "already resolved"; after that its id is unknown. When session retention deletes a session, its armed waitpoints are cancelled with it, so a late signal cannot bring the session back.

Security

The secret is a capability scoped to exactly one waitpoint: it can resolve that waitpoint and do nothing else. This is why the signal route does not take CORTEX_WEBHOOK_TOKEN — that token grants command execution on every connected device, and a training script has no business holding it.

Secrets are stored hashed, compared in constant time, and expire with the waitpoint. Payloads are size-capped: 64 KB per request or spool file, 2,000 characters of message and 16,000 of data. Unknown ids and wrong secrets share a budget of 60 misses a minute, after which the route answers 429, and each miss is logged as a warning in the daemon log, so a typo in a script is findable rather than silent.