A Stream Deck for Claude Code.
An old phone docked beside the monitor becomes an aesthetic cockpit: it shows what Claude is doing, with character and motion, while your attention is elsewhere — and it is where you approve permissions and answer Claude's questions without touching the terminal.
The product is the object. It is watched, not carried: being looked at is the medium.
Runs on Android and iOS — any phone with a browser. It is a web app, not a native one; there is nothing to install from a store. The only difference between the two platforms is that iOS does not vibrate (see below).
Vocabulary: CONTEXT.md. Design record: DECISIONS.md.
Both are supported and get the same Deck: the same clock, the same Clawd, the same speech bubble, the same takeover cards, the same pause and offline states.
The one difference — no haptics on iOS. iOS Safari does not implement
navigator.vibrate, so an iPhone Deck flashes without buzzing. Every
vibration call is feature-detected, so nothing breaks and nothing errors; the
haptic is an enhancement of the boundary flash, never the alert itself, and the
flash is what an alert actually is on both platforms. If a silent Deck matters
to you, dock the Android phone.
Neither platform gets notifications when you are away from the desk — that is deliberate, not a platform gap. See Deliberate boundaries.
A session clock that never lies. Claude Code UserPromptSubmit and Stop
hooks POST to the gateway, which streams them over SSE to the PWA. The deck is
a landscape desk clock: time of day while idle, an incrementing session timer
from the moment a prompt is submitted, frozen on Stop — labeled with the
session's directory name, with a badge counting concurrently running sessions.
Events are server-timestamped and replayed from the ring buffer on reconnect
(Last-Event-ID), so a network blip never leaves the deck stale; while the
stream is down the deck shows an unmistakable gray scanline OFFLINE state.
Idle/running render dim for the always-on OLED, the layout pixel-shifts every
minute against burn-in, and Wake Lock keeps the docked screen alive.
Clawd narrates, in a speech bubble. The mascot is the primary way the deck tells you its state without being read: sleeping when idle, typing while a session runs, waving when a turn completes, alarmed when something needs you, plus paused and offline poses. Every state has exactly one expression.
Beside him, a speech bubble shows the latest completed tool call — one line at
a time, as if Clawd is narrating: the command, the file path, the tool name,
with a colored write/edit label before filenames. PostToolUse hooks feed
it, registered only for Write|Edit|MultiEdit|NotebookEdit|Bash so reads never
pay a hook round trip, and a table-driven classifier marks high-impact Bash
commands (installs, migrations, docker, pushes, deploys).
Between submitting a prompt and the turn's first tool call there is nothing to narrate, so the bubble cycles one of Claude Code's own thinking verbs instead of sitting empty — the deck looks like it is thinking because it is.
The bubble shows one thing, not a history — glanceable from arm's length rather than something you must stop and read. It speaks exactly while Clawd types and clears the moment his pose changes, so it can never outlive the state that produced it. The centered SLOPDECK title steps aside while he speaks and returns at rest.
Earlier versions rendered this as a scrolling activity ticker along the bottom. That strip is gone — several simultaneous scrolling rows are noise on an object watched peripherally. The hooks behind it are unchanged.
Completion alerts that stay meaningful. A Stop alerts only when the turn
ran at least the threshold (10s default, SLOPDECK_ALERT_THRESHOLD_MS) —
short chat turns stay silent, so an alert always means real work finished. The
alert lands on the deck itself: a green boundary flash, plus a vibration tap on
Android. There is no notification to a phone you aren't looking at — the deck is
an ambient display, not a pager, and no server-side notification channel exists
to leak across workspaces.
Approve or deny from the deck. A PermissionRequest http hook — it fires
only when a permission dialog would genuinely appear, so allowlisted commands
never reach the deck — is held open by the gateway while the deck takes over
the screen: tool name plus the exact command/path payload in real monospace,
with Allow (hold-to-fill, scaled to risk), Deny, and
Ask-in-terminal taps. Allow/Deny answer the hook with the documented
decision JSON; Ask-in-terminal — and every fallback — returns no decision,
letting the terminal dialog proceed normally. Fallbacks never auto-deny:
immediately when no deck is connected, at 540s (under the 600s hook timeout)
when a connected deck stays silent. Prompts queue FIFO, oldest first, with a
queue-depth badge counting what waits behind the visible card.
Every card names its own project. The deck renders one session large, but a held prompt may come from a different one — so the card carries its project name at heading scale, with the session id small and dim beside it (two sessions in one project stay tellable apart). You are never approving a command without knowing which project asked. Attribution never outweighs the payload, which stays the card's primary read.
Pause is one tap, no arming ritual: it flips the gateway to passthrough,
and while paused every PermissionRequest falls back to the terminal
instantly. The deck tints purple while paused; tapping again resumes
interception. The same switch is reachable from the workstation as
slopdeck on / slopdeck off.
Answer Claude's questions remotely —
Read this before enabling. Claude Code offers no supported way to answer
AskUserQuestionremotely. This feature works by exploiting undocumented behavior (the deny-with-reason hack below), which means:
- Terminal re-asks are normal, not bugs. Any fallback — no deck connected, paused, timeout, an unrecognized payload shape — puts the question back in the terminal. Expect this to happen and treat it as the feature working.
- A Claude Code upgrade can break it silently. Run
npm run canaryafter upgrading; if it reports the session ignored the deny reason, disable question interception until it is revalidated.- It ships off by default, behind its own opt-in and its own hook matcher, so it cannot affect permission handling if it misbehaves.
Permission approval (above) rests on documented, decision-capable hooks and carries none of these caveats. These are two different levels of stability in one product; do not assume the guarantees transfer.
With question interception enabled, a PreToolUse http hook matched to
AskUserQuestion alone lets the deck render the question with one tap-target
per choice (plus an Ask-in-terminal escape); the tapped choice returns as
permissionDecision: "deny" with reason User selected: <choice> — which
Claude reads as the answer. Multi-question calls step through the card one
question at a time, and multiSelect questions render toggleable choices
with a CONFIRM tap. Every fallback (no deck, pause, timeout, unrecognized
payload shape) returns permissionDecision: "ask" so the question renders in
the terminal normally. An unanswered card falls back after 60s total for the
whole call (SLOPDECK_QUESTION_TIMEOUT_MS) — a stale answer to a question
mid-plan is worse than a terminal re-ask; permission prompts keep their 540s
window. The canary test drives a real Claude Code session end-to-end to
revalidate the hack (needs working credentials, consumes tokens).
Two things slopdeck does not do. Both are design decisions, not gaps — knowing them up front is the difference between the tool fitting your desk or not.
It shows one session large, never several side by side. The deck renders the active session — the one that most recently emitted — and acknowledges other running sessions only as a count. It is an object glanced at from arm's length, where one thing shown clearly beats several shown poorly. If you want a multi-session dashboard, this is not one. (Held prompts are the exception that proves it: a prompt from a non-active session still takes over the screen, which is why every card names its own project.)
It does nothing when you are not looking at it. There are no push notifications, no emails, no off-deck alerts of any kind — a backgrounded or locked phone is deliberately silent. slopdeck is watched, not carried: being looked at is the medium. If you need to know a turn finished while you are in another room, use something else; an ambient object that can also interrupt you anywhere is just a notification app with a nicer clock.
slopdeck sends your work to a gateway. If that gateway is the hosted one, it is a machine operated by the slopdeck maintainer, and you should know exactly what reaches it.
What transits the gateway. Hook payloads carry what the deck must render:
- Project paths —
cwdfor each session (its basename becomes the label). - Command text and file paths — for each completed
Write,Edit,MultiEdit,NotebookEdit, andBashcall. - Tool names and
session_id. - The exact
tool_inputpayload for permission and question cards — that is the point of showing you what you are approving, and it means command text reaches the gateway in full.
slopdeck does not see your conversation with Claude, the model's responses, or file contents it was not handed in a payload.
Events are never written to disk. Event history lives in an in-memory ring buffer, and every live workspace is process memory. There is no activity database anywhere in the system. A gateway restart drops event history and any held prompt — which is safe by design: a dropped prompt falls back to the terminal and is never auto-denied.
Keys are stored hashed. The workspace key table is the only durable store, and it holds SHA-256 hashes, never key material, verified with a timing-safe comparison. Dumping that table does not yield a working hook key or deck key.
Idle workspaces are swept. On the hosted gateway, a workspace untouched for
longer than SLOPDECK_EPHEMERAL_TTL_MS (default 7 days) is deleted outright
— stored keys and in-memory runtime together. Ingest and stream activity keep it
alive, so only genuinely abandoned workspaces are collected.
Workspaces cannot see each other. A workspace owns its events, its pause state, its held prompts, and its decks. Nothing crosses that boundary — it is the product's core safety property, and it is enforced identically hosted and self-hosted.
Self-hosting is a real alternative, not a disclaimer. Same code, same isolation rules, same payloads — what changes is who operates the box. If you would rather none of the above leave your own infrastructure, follow the walkable path.
Anthropic ships Remote Control (claude remote-control), which lets you
approve, monitor, and steer sessions from the Claude mobile app. It is official,
supported, and works from anywhere. If what you want is remote approval, use
it — slopdeck does not try to beat it at that, and it is a better tool for the
job of controlling Claude Code from away from your desk.
slopdeck is a different object. Remote Control is an app you launch when you want to check on something. slopdeck is a screen already sitting on your desk, already on, already showing you the answer — no launching, no unlocking, no reaching for a phone. The difference is ambient presence versus deliberate access, and it is the entire reason slopdeck exists.
What follows from that:
- It runs on a dedicated docked phone, not the one in your pocket — an old handset given a second life as a desk instrument.
- It is designed to be looked at for hours: dim ambient states, burn-in pixel-shift, a strict motion budget, and a mascot whose pose tells you the state without reading.
- It is deliberately deskbound (see above) — it will never chase you with a notification, which is precisely what Remote Control is good at.
They are not mutually exclusive. Run both if you want the desk object and away-from-desk reach.
Don't want to run a server? Use the always-on hosted gateway. One command on
your workstation — no clone, no tokens, no .env:
npx slopdeck installPick hosted when asked, and scan the QR with your phone — Android or iPhone, either works. That's it: the deck pairs over HTTPS and works from anywhere. Requires Node ≥ 22.6 and Claude Code already set up on the machine.
Read Privacy before pointing your work at the hosted gateway.
Prefer a global install? npm install -g slopdeck, then slopdeck install.
To self-host the gateway instead (your own VPS, full privacy), follow the walkable path below.
Four steps: deploy the gateway → wire the workstation → scan the QR → dock the phone.
First time, on the VPS:
git clone <this repo> slopdeck && cd slopdeck
cp deploy/.env.example deploy/.env # fill in tokens + DECK_DOMAIN
cd deploy && docker compose up -d --buildCaddy provisions HTTPS for DECK_DOMAIN automatically. Every update after
that is one local command:
SLOPDECK_DEPLOY_HOST=deploy@your-vps ./deploy.sh
# or: ./deploy.sh deploy@your-vps
# SLOPDECK_DEPLOY_PATH overrides the remote checkout dir (default: slopdeck)deploy.sh SSHes in, fast-forwards main, and rebuilds the compose stack.
Deploys are deliberately manual — no CI trigger. See
Restart behaviour for what a redeploy costs and why it is
safe. Deploy when you know the deck is quiet, not whenever a branch merges.
In a clone of this repo on the workstation:
npm install
npx slopdeck installThe installer asks for the gateway URL, verifies the gateway is reachable,
prompts for the hook token (hidden — it never lands on screen or in shell
history), and verifies it against the gateway before touching anything. Then
it merges the hook config into ~/.claude/settings.json (surgically — a
malformed settings file aborts with the disk untouched), writes a marked
export block to ~/.zshrc, and asks whether to enable question interception.
Open a new shell afterwards so the hook token is exported.
Install ends with the pairing finale: it prompts for the deck token and prints
a QR encoding https://your-deck-domain/#deck-token=… — the token rides the
URL fragment, so it never reaches HTTP requests or server logs. Scan it with
the phone camera and the deck is paired; the installer then sends a handshake
through the real hook path, and the mascot waves on the phone when the whole
chain (DNS, TLS, hook token, gateway, SSE, deck token) works end to end.
Re-pair any time — new phone, new domain — with slopdeck qr.
Tap the idle clock: the deck goes fullscreen and locks landscape. Add to Home Screen for the installed-PWA experience — Chrome on Android, Safari's Share → Add to Home Screen on iOS. Wake Lock keeps the screen alive in the dock.
A gateway restart is safe at any moment, and that is a design property rather than luck. Restarting — a redeploy, a crash, an OOM kill — loses exactly two things, and neither one can hurt you:
- Event history. The ring buffer is memory. The deck reconnects, replays what survives, and carries on; the clock absorbs the replay silently and only near-live completions flash, so a restart never produces a burst of stale alerts.
- Any held prompt. A permission or question request the gateway was holding open is dropped.
The second is the one that sounds alarming and isn't, because of the fallback rule: slopdeck never auto-denies. Every path that fails to produce an answer — no deck connected, gateway restarted, gateway unreachable, timeout, paused — returns no decision, and the terminal dialog appears exactly as if slopdeck were not installed. A hook that cannot reach the gateway is a non-blocking error in Claude Code by design, so a dead gateway degrades to normal terminal prompting on its own.
The failure mode of slopdeck is therefore "you approve it in the terminal instead" — never a fabricated refusal mid-task, and never a session wedged waiting on a server that is no longer there. That is what makes manual deploys acceptable: the worst case of restarting at a bad moment is one prompt you answer where you would have answered it anyway.
What survives a restart: workspace keys, provided SLOPDECK_DB_PATH points
at a mounted volume. Without it the key store is in-memory too, and a restart
means re-pairing.
slopdeck on|off flip interception (the deck's Pause switch, remotely)
slopdeck status diagnose the whole chain on one screen
slopdeck qr re-print the phone-pairing QR
slopdeck uninstall remove the hooks and the .zshrc block
npm install
npm test # vitest suite (unit + e2e tracer + deploy.sh)
npm run typecheck
SLOPDECK_HOOK_TOKEN=$(openssl rand -hex 32) \
SLOPDECK_DECK_TOKEN=$(openssl rand -hex 32) \
npm run dev # gateway + PWA on :8484