Switchboard can run Claude tasks without you at the keyboard, through two complementary mechanisms:
- Schedules — cron-style recurring tasks defined as Markdown files, fired by an in-process scheduler.
- Triggers — one-shot command injection into an already-open session, driven by dropping a JSON file. Meant for external scripts and harnesses.
A schedule is a Markdown file at <project>/.claude/commands/schedule-*.md with YAML frontmatter:
---
name: My morning audit
cron: 0 9 * * 1-5
enabled: true
slug: morning-audit
cli:
permission-mode: acceptEdits
allowed-tools: Bash,Read,Write
---
<Full self-contained prompt that Claude will execute>When the cron expression matches, Switchboard pre-seeds a new session with the prompt and spawns claude --resume <sid> -p "..." headlessly. The run appears as a regular session in the sidebar — open it there to see the result.
Click the clock icon on a project in the sidebar. This opens an interactive Claude session pre-loaded with a schedule-creator command: describe what you want scheduled, and Claude writes the schedule-*.md file for you. You can also write the file by hand — the scheduler rescans every minute, so changes take effect within 60 seconds, no restart needed.
Existing schedules are listed in the project's brain tab (Memory panel), each with a run now button that fires it immediately, bypassing the cron match.
enabled: falsedisables a schedule without deleting it.cronis standard 5-field syntax (minute, hour, day-of-month, month, day-of-week) with*, lists, ranges, and steps. No@dailyaliases, no DST awareness (times are local).permission-mode: acceptEdits(orauto) is the practical default — headless-pruns hang on any permission prompt otherwise.- One run at a time per schedule: if a run is still going when the next tick matches, that tick is silently skipped.
- The scheduler lives in-process: if Switchboard isn't running, the schedule doesn't fire. It's a personal tool, not a daemon.
The trigger watcher lets any external script type into an open session's terminal — no Electron IPC required. Drop a JSON file into ~/.switchboard/triggers/ (override with SWITCHBOARD_TRIGGERS_DIR):
{
"sessionId": "abc-123-def",
"command": "/compact",
"wait": "idle",
"timeout_ms": 120000
}sessionId— the target session (must be open in Switchboard).command— written to the PTY, followed by a discrete Enter keypress.wait—"none"(default) does not wait for the session to stop being busy;"idle"does. Neither sends into a composer with unsubmitted input: see "Politeness" —"none"can still wait, up totimeout_ms. Use"idle"for anything that must not interrupt a mid-response stream.timeout_ms— optional cap on the waiting, idle and politeness (≤ 600 000 ms; default 300 000). See "Politeness" below: withwait: "none"this is the only bound on how long a trigger sits waiting for a free composer.expectedCwd— optional. See "Target guard" below.
Environment overrides: SWITCHBOARD_TRIGGERS_DIR (watched directory), SWITCHBOARD_TRIGGER_IDLE_TIMEOUT_MS (default idle wait), SWITCHBOARD_TRIGGER_QUIET_MS (the politeness quiet window, default 3000 ms).
Instead of a single command, you can send a chain — a sequence of up to 20 steps injected one after another, each submitted and verified before the next:
{
"sessionId": "abc-123-def",
"chain": [{ "command": "/compact" }],
"wait": "none"
}command and chain are mutually exclusive.
waited_ms / total_waited_ms. Both fields mean the same thing — every
wait this trigger spent — scoped differently: a single command result
carries waited_ms for the whole trigger; a chain result carries
total_waited_ms for the whole chain, plus a per-step waited_ms inside each
steps[] entry.
command'swaited_msis thewait:"idle"wait (0 ifwaitis"none"or the session was already idle), plus the politeness wait, plus the submit-verification poll and its retry, if one fired.chain'stotal_waited_msis the initialwait:"idle"wait (same rule) plus, for every step the chain attempted, that step's own politeness wait, its submit-verification poll(s), and — for every step but the last — its busy-fall wait.steps[i].waited_msis the same sum scoped to stepialone, sototal_waited_msequals the initial wait plus the sum of every entry insteps[], except when the last attempted step is abandoned before it ever writes anything because the session exits or the global deadline fires: that step's partial wait still counts towardtotal_waited_ms, but it has no entry insteps[]to be attributed to. A step refused for a composer that never frees is not this exception (narrowed 2026-09-05, was previously grouped with the other two): it now gets its ownsteps[]entry too —submitted: "no",submit_retries: 0— so its politeness wait is attributed exactly like a step that did write. The remaining exception (session exit, global deadline, both checked before a step's own politeness wait even starts) was left alone: unlike the composer case, neither has astepSentAt-scoped wait of its own worth attributing to a step that was never even reached.
A measured gap of roughly 158 s between total_waited_ms and the sum of
steps[*].waited_ms on 2026-09-03 was this: each step's own politeness wait
was folded into total_waited_ms but left out of that step's own
waited_ms — fixed so the identity above holds exactly on every chain that
completes. The command path had the matching gap on its own smaller scale —
its waited_ms left out the submit-verification poll entirely — fixed the
same way, so a reader comparing the two paths finds them consistent.
wait accepts idle and none, and nothing else. An absent field still
means none, because existing triggers rely on that default, but any other
value — an empty string, null, "idel" — is refused before anything is
written, with a reason naming the value received. Falling back to none on a
typo would send immediately into a session that asked to be waited for, which is
the more dangerous of the two behaviours.
Nothing is written into a session that has input typed and not submitted. A trigger arriving while you are mid-sentence waits, and if it never gets a free composer before its deadline it renounces rather than splice its payload into your words.
This is not only courtesy. A slash command injected into a non-empty
composer never submits at all: Claude Code submits /compact through the
completion menu, which opens only when the / is the first character of an
empty box. A session once slept nine hours with an inert /compact sitting in
its composer. Politeness is the condition under which the channel works.
How Switchboard knows. It models the box. Every keystroke the renderer sends
reaches the main process through one IPC channel, and composer-state.js keeps
a running copy of the text it believes is sitting there, plus a cursor into it.
pending is that text's length in code points, so an emoji weighs one and
one backspace removes it.
A counter could only add and subtract; a model can be edited. What is applied:
| Input | Effect on the model |
|---|---|
printable bytes, pasted bytes, ESC [ 200 ~ … ESC [ 201 ~ content |
inserted at the cursor — a paste's embedded carriage returns are text, and do not clear the box |
| Enter, newline, Ctrl+U, Ctrl+C | clears |
Backspace / DEL, ESC [ 3 ~ (Delete) |
removes one code point behind / ahead of the cursor |
| Ctrl+W, Alt+Backspace | removes the word before the cursor |
| Ctrl+K | removes from the cursor to the end |
Left / Right (ESC [ C, ESC O C, …), Ctrl+A, Ctrl+E, Home, End |
move the cursor; a modified Left/Right moves by a word |
bare Up (ESC [ A, ESC O A), Ctrl+V, an unparseable escape |
insert one opaque placeholder — the content is unknown, so it counts as one |
kitty Enter with a modifier (ESC [ 13;2 u, ESC [ 13;5 u) |
inserts a line break |
kitty Enter without one (ESC [ 13 u), modified Up, OSC, other escapes |
nothing |
SGR mouse reports (ESC [ < b ; x ; y M/m), focus reports (ESC [ I, ESC [ O) |
nothing — and the quiet clock does not move; these two forms are the terminal talking, not the user |
An escape sequence cut across two IPC chunks is buffered and re-joined, so half
a sequence is never counted as text — including a lone ESC that turns out to
be the first byte of the next chunk's bracketed paste. A sequence that cannot be
parsed at all counts as input: doubt resolves to busy, always.
The composer is called free only when pending is zero and nothing has
arrived on that channel for SWITCHBOARD_TRIGGER_QUIET_MS (default 3000). The
freshness window covers the one case the model cannot: an Enter that validates a
slash-command completion empties the box while the CLI refills it.
Where this is blind — stated, not papered over:
- Only bytes coming from the renderer are seen. Input reaching the PTY by any other route is invisible to the model.
- The model is a line editor's model, not the CLI's. It knows nothing of wrapping, of multi-line navigation, or of any binding Claude Code adds beyond the table above; an unmodelled editing key leaves the text longer than the box really is. That over-count is the safe direction — the trigger renounces — but it stays until the next Enter, Ctrl+U or Ctrl+C, and until then every trigger for that session renounces.
- Ctrl+U is treated as clearing the whole box, which is right when the cursor is at the end. Used mid-line it would be an under-count if the CLI binds it to "kill to start of line" — unmeasured.
- Any chunk carrying something other than a recognised terminal report restarts
the quiet clock, whether or not it changes the text. Mouse and focus reports
are the exception, and they had to be: a TUI with mouse reporting on
(
CSI ?1003h) emits one report per pointer motion, on the same IPC channel as keystrokes, and until 2026-09-02 each of them pushed the clock. Measured that day on the real CLI, composer emptied with Ctrl+U, no key touched: pointer resting over the terminal, a trigger waited its full 30 s and was then refused —{"ok":false,"reason":"the last keystroke landed 47 ms ago, inside the 3000 ms quiet window","waited_ms":30046}; pointer moved off the terminal, the same trigger tookwaited_ms":15504to find 3 s of silence. The earlier claim here — "at most ~3 s each time, never a refusal on its own" — was wrong: with the user simply present at the machine, triggers were unusable. Reports now count as neither text nor activity, so a chunk holding only reports changes nothing at all, clock included. The exemption is deliberately narrow: SGR reports (CSI < b ; x ; y M|m) and focus reports (CSI I,CSI O) with no parameter — those two forms and nothing else. A near-miss — a parameter too few or too many, a non-numeric one, another final byte, a report cut short by the end of a chunk — is not recognised and still counts as input. Doubt resolves to busy here too: a wrong exemption would be a false "free", and a false "free" types over the user's sentence. - The exemption covers those two forms only, and xterm.js writes more than
reports on that channel. Its own replies still count as input and still push
the quiet clock (measured:
lastInputAtstamped,pendingunchanged) — the OSC colour replyESC ] 11 ; rgb:… ST, the XTWINOPS size repliesCSI 4 ; h ; w tandCSI 6 ; ch ; cw t, DA1 (CSI ?1;2c) and CPR (CSI r ; c R). Each costs a trigger up to one quiet window. Their periodicity has not been verified: they answer a query, so they are presumably one-off rather than repeating — that is a reserve, not a guarantee. - In the alternate screen buffer with mouse tracking off, xterm translates the
wheel into arrow keys and sends them as ordinary input. A bare
ESC [ Ais a history recall to this model, so it inserts one opaque placeholder: three wheel notches putpendingat 3 (measured) and every trigger for that session renounces until the next Enter, Ctrl+U or Ctrl+C. The direction is the safe one — the trigger gives up rather than typing over something — but this is the next "the trigger never fires", and it is not fixed here: deciding what a wheel-driven arrow key means to the composer is a change of its own. - Escape does not clear the composer on Claude Code v2.1.258 (measured), so treating it as neutral is correct today. A CLI change would turn it into a false "free".
- An Enter that validates a completion menu empties the model although the box is still full. Only the quiet window covers that.
- A composer filled by the CLI itself — a prompt, a queued message, a resumed draft — was never typed and is not counted.
- Modified Up arrows are not counted, and on Claude Code v2.1.258 that is
correct — as a dated measurement, not a guarantee. Measured on an isolated
PTY with a screen dump: plain
ESC [ AandESC O Ado recall history (the screen shows─── History 2/2 ───and the previous command lands back in the box), which is why the model inserts one placeholder for them.ESC [ 1;2 A(Shift+Up),ESC [ 1;3 A(Alt+Up) andESC [ 1;5 A(Ctrl+Up) leave the box empty, so not counting them is not an undercount on this version. A CLI release that gave those chords a meaning would reopen an undercount — and undercounting is the dangerous direction: it reads a full composer as free. - A triggers directory whose path contains an 8.3 short name (
JEAN-B~1) kills the process outright:fs.watch/libuv asserts. Use the long path.
The guard applies to every write, including the bare recovery Enter the watcher
sends when it saw no turn start — on a half-typed sentence that Enter would
submit the sentence. When politeness never allows a write, the result is
{ "ok": false, "submitted": "no", "error": "not sent", "reason": "…" }.
What this costs wait: "none". It no longer means "write now": against a
non-empty composer it waits, and the only bound is timeout_ms — 300 000 ms by
default. For all of that time the trigger holds one of the 8 concurrent slots
(MAX_INFLIGHT), so a handful of triggers aimed at sessions whose users walked
away mid-sentence can stall the queue for every other session. Set a short
timeout_ms on triggers that would rather renounce than wait.
Two triggers naming the same session never run at once. MAX_INFLIGHT
bounds how many trigger files the watcher processes in parallel; it says
nothing about which sessions they target. Two triggers aimed at the same
sessionId are serialized independently of that cap — the second waits for
the first's result to be written before it so much as samples that session's
busy state — so their writes can never land in the same composer
interleaved. Triggers aimed at different sessions are unaffected and keep
running in parallel, still bounded only by MAX_INFLIGHT. A trigger's own
timeout_ms / idle-wait deadline is what still bounds how long a second
trigger for the same session can end up waiting — nothing here waits longer
than that.
Every path that decides a trigger's fate — success, validation refusal, timeout,
missing session, refused wait — writes the result to
~/.switchboard/triggers/processed/<name>.result.json and then deletes the
trigger file. The directory therefore holds exactly the triggers still waiting
to be processed:
{ "ok": true, "submitted": "activity", "sessionId": "...", "command": "...", "sent_at": "...", "waited_ms": 320 }
{ "ok": false, "submitted": "no", "error": "not sent", "reason": "4 byte(s) of input are sitting unsubmitted in the composer" }submitted is the field to read, not ok. A payload written into a
composer is not a message received. Four values, compared by strict equality,
in this order (no < assumed < activity < confirmed):
| Value | Meaning |
|---|---|
confirmed |
the composer was read back empty right after our own Enter, the session was not already busy the instant we wrote, and a turn was independently observed in the same window — checked on the first attempt only, never after a retry |
activity |
the session was seen busy after our write, but the composer readback could not rule out interference, or the session was already mid-turn when we wrote |
assumed |
written, no failure seen, nothing observed afterwards |
no |
nothing was written, or it was written and not submitted |
A chain reports the weakest value any of its steps reached — this is the
field's meaning unchanged from before per-step values existed, kept for
compatibility with readers written against it. Since 2026-09-05, every entry
in steps[] also carries its own submitted, classified the same way and
compared on the same four-value order. Read it when you need to know whether
one specific step — most often the last one, e.g. a resume prompt at the end
of a /compact chain — was itself confirmed, rather than whether the chain as
a whole cleared some bar: a chain reporting "assumed" overall says nothing
about which step dragged it down; steps[i].submitted does. A step refused
before it ever reached a write (composer never free) still gets an entry, with
submitted: "no" — see the steps[] exception below, narrowed the same day.
What activity refuses to claim, and what a bare composer reading cannot
prove on its own. Busy is sampled, not compared against a baseline taken
before the write, and nothing ties it to that write: a session already
mid-turn when the trigger fires reads busy on the very first sample. Reading
the composer back does not close that gap by itself either — the composer
model only ever sees bytes the renderer sends (see "Politeness" above); this
transport's own writes go straight to the PTY and are invisible to it. So "the
composer reads empty after our write" only proves no human's unsubmitted
sentence is visible at that instant; it says nothing about whether the CLI
consumed what we ourselves just wrote, since the model never knew that text
existed. confirmed is only granted when that reading is paired with two more
things: the session was not already busy when we wrote (a session mid-turn
can swallow or queue injected text with the composer model none the wiser),
and a turn was still independently observed. Even then, a turn starting says
nothing about what the CLI made of the text — a slash command that misses
the CLI's completion menu is submitted as an ordinary message whose text
merely starts with /, and that message produces a turn too. Only reading
back the effect you asked for distinguishes those, and no transport in this
repository does that.
So a caller that must not act twice on the same intent still has to check the
effect itself — a smaller context window, a new transcript, a file on disk —
even when submitted reads confirmed; treat confirmed as "the handoff to
the session went through cleanly", and activity as "something happened,
unattributed".
What confirmed proves, stated exactly, and the one thing it does not.
confirmed means three checked facts and nothing more: the session was idle
the instant before we wrote, a turn was observed within the verify window
after that write, and this was the first attempt (no retry). It does not
prove that the turn it observed is the one our write started.
pollForBusyObserved is a level probe over the whole window, not an edge
tied to our write — any busy transition inside that window satisfies it,
regardless of what caused it. A second trigger on the same session, a human
resuming, or any other actor going busy inside the same window produces
confirmed exactly as our own write would. Serializing triggers per session
(above) closes the same-session case at the source — the second trigger
cannot even attempt a write until the first has fully finished — but an
actor outside this transport's own admission queue (a human, another
process) is not something a PTY byte stream can distinguish from our own
effect. A false confirmed remains reachable in that case: it is narrowed
by construction, never eliminated.
Changed — read this if you parse
submitted.confirmedused to be emitted whenever the session was seen busy after a write, which asserted more than the transport could know (a session already mid-turn satisfied it in milliseconds, with a turn the write did not cause). That case reportedactivityfor a time, andconfirmedwas reserved and never emitted.confirmedis back, gated as described above, so a reader testingsubmitted === 'confirmed'matches again — on a narrower, verified case than before. A reader testingsubmitted === 'no'orsubmitted !== 'no'is unaffected by any of this.
error is compared by strict equality too, so explanations go in reason
and never into error: not sent: input pending is not not sent. reason
carries the detail alone, and carries it for every failure — a reader that wants
to know why reads reason, never a substring of error.
error |
What it promises the reader | What the emitter does with it |
|---|---|---|
not sent |
not one byte reached the session. No idle ever came, politeness never allowed a write, or the trigger was refused before any write | nothing to assume about: the harness voids the pending guard, and the next turn forces again on its own |
chain timeout |
at least one step was written, and the expected effect was not observed before the deadline | the effect is only assumed, so the harness keeps blocking the next compaction |
| anything else | free text: session not found, pty write failed: …, a validation refusal |
read submitted to know whether anything landed |
The two reserved values are easy to confuse and mean opposite things, so:
- A chain whose first step landed and whose second was held back by politeness
reports
chain timeout, nevernot sent. - A
wait: "idle"that expires without the session ever going idle reportsnot sentwithreason: "timeout waiting for idle; nothing was written", andpartial: false— neverchain timeout. This is the commonest failure in service: a session reports itself busy for as long as any delegated agent runs, soidleis regularly unsatisfiable, and answeringchain timeoutthere would block every later compaction over a payload that never left. - The same holds when the session exits during that initial wait: the
errorstays the free-textsession exited during wait, butsubmittedisno,partialisfalse, andreasonsays nothing was written.
An exception anywhere while deciding a trigger's fate — not just the
anticipated validation refusals above — still ends in a result file and a
deletion. A trigger body that parses as valid JSON but isn't a usable shape
(the bare value null, a chain step that isn't an object) is caught and
reported as { "ok": false, "error": "internal error: <message>", "internal": true }, rather than left on disk with no result at all. internal: true is
set on this path only — a validation refusal never carries it — so a reader
can tell "our code broke" apart from "the trigger was refused" without
parsing error, which stays reserved for the strict-equality checks above.
When the deletion itself fails (permissions, a locked file, an entry that is
not a regular file), the trigger stays on disk. The result file is still
written, the failure is logged at error level, and that name is remembered for
the lifetime of the process so a later filesystem event on it can never run the
command a second time — the leftover file is inert, not pending. A trigger whose
name sits in processed/ has been processed, whatever the trigger directory
still shows. This is the only case that leaves a name non-replayable; an
internal exception on its own does not — once the result is written and the
trigger deleted, a later trigger dropped under the same name is a fresh
attempt.
processed/ has no retention policy: result files accumulate there for as
long as the directory lives, and nothing in the app ever removes them. Callers
that write many triggers should prune it themselves.
The primary use case is context-management harnesses — e.g. an agent hook that detects a full context window and injects /compact into its own session. Write the trigger file atomically (write to a temp name, then rename) so the watcher never reads a half-written file.
sessionId alone is not proof the trigger is aimed where the writer thinks:
a valid id naming an open session looks identical whether it was chosen
correctly or picked up a race (two sessions writing their transcript at the
same instant, one trigger addressing the wrong one — a real incident). The
optional expectedCwd field lets the writer state what it believes the
target session's working directory is, checked before anything is written:
{ "sessionId": "abc-123-def", "command": "/compact", "expectedCwd": "C:\\Projects\\my-worktree" }- Absent — no change from today: nobody declared an expectation, so nothing is checked.
- Present and it matches the session's actual cwd (case,
/vs\, a trailing slash and the Windows long-path prefix are all normalized first) — the trigger proceeds exactly as it would without the field. - Present and it disagrees — refused before any write:
{ "ok": false, "submitted": "no", "error": "not sent", "targetMismatch": true, "expectedCwd": "...", "observedCwd": "..." }. - Present but the session's cwd cannot be determined — refused the same
way,
targetCwdUnknown: trueinstead oftargetMismatch, so a reader can tell "disagreement" from "couldn't check" without parsingreason. This is deliberate: a check that silently lets the trigger through when it cannot verify would reopen the exact hole it exists to close.
What this does not protect against. The comparison is by folder. Two
sessions open in the same directory are not distinguished by it — the guard
narrows the incident it was built for (two different worktrees), it does not
generally solve "which of several sessions in one folder did the writer
mean". It also does not resolve 8.3 short names, subst drives, or
junctions/symlinks to their real target — two spellings of the same real
folder in any of those forms are treated as a mismatch, not folded together.