sark

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

ButtonReactionAction
🛑Stopoctagonal_signInterrupt the running agent. Same as typing stop.
♻️Re-runrecycleRe-run the last prompt, at the same effort as before.
🧠EffortbrainEscalate. Offers a reasoning-effort picker to re-run at.
🍴Forkfork_and_knifeFork this conversation into a new thread with a copy of the filesystem.
🖥️Watchdesktop_computerWatch the sandbox's desktop in a browser.
💤ArchivezzzArchive 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:

PhaseButtons
A run is goingStop · Watch
The run is overRe-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:

  1. 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;
  2. 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;
  3. sets the TTL and a readable name on the fork;
  4. seeds a session on the new thread with adoptFork, recording what it was forked from;
  5. rewrites that announcement to name the new box, which is only known after the fork;
  6. 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.

On this page