Controls
Contextual buttons on the status message, the same six actions as reactions, and the one dropdown neither can express.
Six actions drive a thread. They're reachable two ways: buttons on the status message,
and emoji reactions on any message in the thread. Both land in the same
ThreadSession.control(), behind the same signature check, the same allowlist, and the same
event_id dedup as a mention.
The six
| Button | Reaction | Action | |
|---|---|---|---|
| 🛑 | Stop | octagonal_sign | Interrupt the running agent. Same as typing stop. |
| ♻️ | Re-run | recycle | Re-run the last prompt, at the same effort as before. |
| 🧠 | Effort | brain | Escalate. Offers a reasoning-effort picker to re-run at. |
| 🍴 | Fork | fork_and_knife | Fork this conversation into a new thread with a copy of the filesystem. |
| 🖥️ | Watch | desktop_computer | Watch the sandbox's desktop in a browser. |
| 💤 | Archive | zzz | Archive the sandbox now, rather than waiting out the idle timer. |
Buttons are contextual
The status message carries only the controls that mean something right now:
| Phase | Buttons |
|---|---|
| A run is going | Stop · Watch |
| The run is over | Re-run · Effort · Fork · Archive |
Stop disappears once a run ends, because interrupting a finished run does nothing.
Fork and Archive carry a Block Kit confirm object, so Slack renders a native
dialog before the action fires. One spends money, the other kills a live sandbox. Re-run
has no confirm: it's cheap and reversible, and a second click would only be friction.
Slack requires action_id to be unique within a block, so each control has its own id
(sark_interrupt, sark_retry, …) rather than sharing one and switching on value.
Reactions are not pre-seeded. Six emoji on every status message outlived their meaning,
piled up across a long thread, cost six reactions.add calls per run, and put the two
costly actions one stray click away.
Reactions still work anywhere
A reaction on any message in the thread works, which is the thing buttons can't do —
they only exist on the status message. Anything outside the six is ignored, which is what
keeps the reactions:read scope from being noisy. Skin-tone variants (+::skin-tone-3) are
stripped before matching, and reactions by the bot itself are ignored.
Controls fail closed. A reaction on a thread with no session does nothing and creates nothing. A reaction from outside the allowlist is dropped silently: replying would turn any emoji in a public channel into a way to make the bot talk.
Finding the thread
A reaction_added payload carries the ts of the message that was reacted to, but not
its thread_ts. So the thread has to be recovered before the Durable Object can even be
addressed; resolveThreadTs() in src/slack/api.ts does that lookup. A reaction on a reply
resolves to the thread that reply belongs to.
Buttons need no such lookup: a block_actions payload carries message.thread_ts directly.
♻️ Re-run
Re-running is parked, not sent directly: a retry record goes into storage and the
state machine picks it up on the next alarm. That's deliberate: the box may be archived, and
waking it is ensureBox's job, not the control handler's.
- Nothing run on this thread yet → "There is nothing to re-run on this thread yet."
- Something already running → "Something is already running here. Stop it with 🛑 first."
🧠 Escalate
A button or a reaction can only ever mean one fixed thing, so Effort doesn't pick a level.
It posts a Block Kit dropdown offering low, medium, high, max, noting the current
effort if there is one. Selecting a level comes back through /slack/interactive and parks
the same kind of retry.
Which levels a given model accepts varies, so an unsupported value surfaces as a Box API error rather than being rejected up front.
/slack/interactive handles both the dropdown and the status-message buttons. It verifies
the Slack signature and runs the same isAllowed gate as a mention before acting; anything
that isn't a block_actions payload for a known action id is dropped.
🍴 Fork
Branch a conversation without disturbing it. The bot:
- resolves a permalink to the source thread, then posts a new top-level message in the same channel announcing who forked it and linking back;
- forks this thread's box, baking the new thread's id into the fork's env. The new thread has to be opened first, because box env is fixed at fork time;
- sets the TTL and a readable name on the fork;
- seeds a session on the new thread with
adoptFork, recording what it was forked from; - rewrites that announcement to name the new box, which is only known after the fork;
- links the new thread from the old one.
The forked box starts from the current filesystem, so the new thread carries on from exactly where the old one was. Nothing runs in it until someone mentions the bot there.
adoptFork refuses to clobber an existing session: the new thread's ts is fresh, so a
session already being there would mean something went badly wrong, and overwriting it would
strand a running box.
Forking needs Slack, since there's no channel to fork into otherwise.
A forked filesystem inherits the parent's MCP registration, so until it re-registers the
fork holds a credential naming the parent thread. Forked sessions carry pendingBootstrap
and re-register as soon as the box is ready rather than waiting for someone to speak. The
window is the fork's provisioning time; it is not zero.
🖥️ Desktop
Asks the Box API for a desktop URL with public access and posts it, with a warning that
anyone holding the link can view the sandbox. The response field naming isn't pinned down in
the public docs, so url, desktopUrl, and vncUrl are all accepted and normalized.
💤 Archive
Archives the sandbox immediately instead of waiting for IDLE_STOP_SECONDS. If something
is running it's interrupted first, quietly, since the archive notice is the answer. Archiving
snapshots the filesystem, so the next message in the thread wakes it back up on the same
files.
Already archived → says so and does nothing.
Failure and dedup
Every control is deduped on event_id like any other Slack event. A control that throws
posts a user-safe ⚠️ line into the thread and logs the rest.