deckspace.dev / DeckSpace / docs / phone-remote

Phone remote

A small server inside the desktop app that a paired phone can talk to. There is no account in it, nothing is listening until you switch it on, and today it answers only on a network you share with the Mac.

The remote surface exists for one situation: an agent is working, you are not at the desk, and something is waiting on a human. From a paired phone you can watch the feed, read a pane, decide an approval, place an order, and take one conversational turn with the assistant. The list of things you cannot do is longer, and most of it is deliberate.

What it is

  • An HTTP server the desktop app runs itself, on port 8800. The pairing payload carries the real port.
  • No account, no sign-in, no directory of users. A paired device acts as the machine’s owner, bounded by the scopes you gave it.
  • The contract is versioned api:1 and frozen. A phone on an older build keeps working against a newer desktop.

The server starts only when all three of these hold, and stops when any stops holding:

  1. You turned Remote on in Settings. The setting is remote_enabled, and absent means off.
  2. Your DeckSpace subscription is active. This is re-checked on a timer of at most 30 seconds, so a lapse takes effect without anyone touching the app: open streams get a bye, the next request gets 503 license_inactive, and the listener closes.
  3. A bind address could be resolved for the mode you picked.
Off means off

There is no “start it just to see”. While Remote is disabled, port 8800 is not listening at all — something scanning your machine gets a closed port, not a 403.

Turning it on

Settings → Remote is the only place any of this can be changed. No route on the phone can write a remote setting, turn the screenshot flag on, or widen its own scopes. The surface cannot grant itself more surface.

The switch shows three states, not two, because “on but not listening” is real:

LabelMeans
listeningThe server is up and the panel names the address and port it answers on.
not listeningYou turned it on and it could not bind. The panel says why in a sentence — no address in the mesh range, no private network address, port already held, no saved identity.
offThe switch is off. Nothing is listening.

Where it answers

You pick a mode; the server resolves it to exactly one address. It never binds 0.0.0.0, and never falls back to a wider interface than the one you chose. A mesh mode with no mesh address is a hard stop, not a quiet demotion to your Wi-Fi.

ModeCalled in SettingsBinds toWhen it fails
loopbackThis machine only127.0.0.1Never. A phone cannot connect to it either.
tailnet (default)Private meshThe first local IPv4 address inside 100.64.0.0/10No such address → the server does not start; the reason is no_tailnet_address
lanLocal networkThe first RFC-1918 address (10/8, 172.16/12, 192.168/16)None present → does not start; the reason is no_lan_address

Address matching is IPv4-only. 100.64.0.0/10 is the range a private mesh hands out, and matching on the range rather than on an interface name is what makes the check honest on a machine where the interface is named something else.

Local network is plain HTTP

Nothing on that path is encrypted. The bearer token a paired phone sends is readable by anyone on that network who can see the packets, and they could then use it. Settings makes you confirm the choice a second time and keeps a warning on screen for as long as it is selected. The private mesh encrypts device-to-device on its own, which is why it is the default.

Pairing

Pairing is initiated on the desktop, by the person sitting at it. It is the only way a token is ever issued.

  1. In Settings → Remote, tick the scopes this device may have and whether it may take screen captures. That is the moment the decision is made.
  2. Press Pair a device. The desktop shows an 8-digit code and a QR of the same payload.
  3. The phone scans it, or you type the code into a client pointed at the address.
  4. The phone exchanges the code for a device token, once. The desktop stores only sha256(token) — no route reads a token back.
FactValue
Code length8 digits, numeric
Code lifetime5 minutes, single use
Wrong tries before the code burns5 — the window closes and you press the button again
Pair attempts per source address10 per 15 minutes
Paired devices per machine8 — revoke one to make room
Token32 random bytes from the OS generator, 64 lowercase hex characters
Token expiryNone. It ends when you revoke the device or wipe the database.

The 8-digit code is not what makes this safe on its own — the burn rule is. Five wrong guesses inside a five-minute window ends the window.

Narrowing works, widening does not

You can take a scope away from a paired device in the device list, and it applies at once: that device’s open connections close and the next request is re-checked against what is left. You cannot add one back. Giving a device more than it was paired with means pairing it again, in front of the machine. The same rule holds for the screen-capture flag.

Revoking is immediate and not reversible. Turning Remote off entirely keeps the device list, so switching it back on does not mean re-pairing every phone.

Scopes

Five scopes. A device holds the ones you ticked at pair time and nothing else; a route outside them is not reachable by it.

ScopeCalled in SettingsGrants
monitorMonitorRead the machine’s state: feed, board, missions, pane output, counts.
orderOrderRun an intake and land the result as a Board task or a headless mission.
approveApproveList the gates waiting on a human, and decide one.
converseConverseOne text or voice turn with the assistant, on a read-only tool surface.
driveType into panesSend one line into a running agent’s pane, as if typed at the machine.

Screen capture is not a scope. It is a per-device switch, off by default, meaningful only alongside monitor, and it gates exactly one route. It is separate because a capture of this machine shows everything on the screen — other apps, mail, messages — not just DeckSpace.

PlatformScreen capture from a phone
macOSavailable needs the Screen Recording grant
Linuxavailable needs grim or maim installed
Windowsno capture path the app reports the capability as false, so a well-behaved client hides the button

On a machine with several displays this is the primary display only — the one with the menu bar on macOS. The route is capped at 6 calls a minute and is unreachable from the assistant, so a spoken turn cannot route around either the toggle or the cap.

What the phone can and cannot do

ActionFrom a paired phone
Read the feed, board, missions, pane outputYes, with monitor
Move a board cardNo. The board is read-only over the remote.
Start, cancel or merge a mission directlyNo. Missions are read-only; an order can create one.
Run a full Maestro dispatchNo. There is no maestro order target — a Maestro run gates on a plan approval at the window.
Browse files, open an editor, write to gitNo. None of it exists on this surface.
Read an arbitrary file through the assistantNo. The file-read tool is on the never-list for the converse scope.
Send a line into a running paneOnly with drive, and one line per request
Take a screen captureOnly with monitor plus the per-device flag
Decide an approval that is already waitingYes, with approve
Cause an approval to existNo. The remote does not create gates.
Talk to the assistantYes, with converse, on a read-and-status tool surface only
Bring the desktop window forwardNo. No remote request shows, raises, focuses or unminimizes the window — the window is where you are not.

The converse scope is an allowlist of eight tool names covering panes, tasks, pane output, mission status, Maestro status, git status and memory reads. Anything not on it is unreachable, including a tool shipped tomorrow. Approving, spawning agents, typing into panes, launching processes, writing files, changing settings and reading arbitrary paths are all explicitly on the never-list.

Ordering from a phone passes the desktop’s permission gate

This is the part that surprises people, and it is working correctly when it surprises you.

An order is three steps: describe the goal, answer whatever the engine asks, confirm the plan. The confirm is where work is actually created, and it resolves the same tool-permission category the assistant on that machine is held to, through the same workspace-then-global fallback the Settings panel shows.

Confirm targetWhat landsPermission categoryCalled in Settings
board (default)One Board card per plan task, in todoboard_writeBoard write
missionOne headless mission that runs an agent CLI in your reposhellShell commands

Only allow proceeds. deny refuses, and ask refuses too — with 403 and a message naming the category and the current policy.

The stored default is “ask”, so remote ordering is refused until you change it

When no policy row exists for a category, the effective policy is ask. There is nobody at the desk to answer a prompt — that is the whole situation this surface exists for — so honouring ask would mean a confirm that waits forever on a dialog nobody will see, and treating it as allow would silently widen every machine’s default. It refuses instead.

The fix is a Settings step on the Mac, done once. Go to Settings → Permissions and set Board write to allow for ordering to the board, and Shell commands to allow if you also want mission orders. Then confirm again from the phone.

The two targets gate on two different categories on purpose. A headless mission starts an agent that writes files and runs commands in your repository; a Board card does not. Gating only the cheaper one would let a refused phone retry the identical confirm with target:"mission" and get a worktree and a booked agent run instead. The rule this keeps: a phone cannot do more on the machine than the assistant running on it can.

Three timing facts about orders

  • Starting an order blocks on a model call. It commonly takes ten to sixty seconds and has a five-minute ceiling, because its answer is the clarifying questions themselves. Every other route answers immediately.
  • A mission you order does not start immediately. The scheduler sweeps every 60 seconds but only claims a mission that has gone untouched for its staleness window of 240 seconds, which is how it tells an orphan from one the window is driving. Roughly four to five minutes, reported per request as starts_within_s.
  • The desktop runs one headless mission at a time. If another is running, yours waits behind it and the response says so rather than offering a countdown it cannot honour.

Confirming twice is safe. A confirm whose response was lost to a dropped connection cannot be charged twice — a second confirm on an already-confirmed order returns the original result instead of creating anything.

Typing into a pane

The drive scope reaches exactly one route and no tool. It is deliberately not a terminal.

RuleBehaviour
One line per requestThe desktop appends Enter itself. One request is one submitted line.
Embedded newline or carriage return400. A second command cannot be smuggled into one call.
Control charactersDropped, tab excepted. An escape sequence drives the agent’s own interface rather than answering it.
Body cap4 KB
Rate30 per minute — fast enough to answer an agent, not fast enough to be a keyboard.
SIGMA::Defanged. The pane reader parses echoed output for that marker, so an injected line would otherwise be read back as an internal message.

A line typed into a live agent CLI is arbitrary code execution one level removed, which is why it is its own scope rather than part of monitor. The pane registry lives in the desktop window, so this route needs the window open; with it closed you get 503, not a silent failure.

The relay, and why the phone is local-network-only today

Direct means the phone and the Mac are on the same network: loopback, your Wi-Fi, or a private mesh that spans both. That covers the desk and the sofa. It does not cover an airport.

The relay is the answer to that: the Mac keeps one outgoing connection open to a named host, the phone makes an ordinary HTTPS request to that host, and bytes move between them. It answers no route itself — every scope check, rate limit and audit line still happens on your Mac, on the same code path a direct request takes. It is off by default, under Settings → Remote → Reach it from anywhere, and turning it on takes an explicit confirmation.

The default relay host does not resolve yet

The host the app ships with is relay.deckspace.dev, and that name currently has no DNS record — a lookup returns NXDOMAIN. Turning the relay on therefore gets you the panel’s connecting state, then a line saying the relay could not be reached and it is still trying. No tunnel comes up.

Until that name resolves, the phone reaches your Mac only on a network it shares with it. Everything else on this page works exactly as described — the relay is additive and is not a precondition for anything — but away-from-home access is not available today.

When it does work, one property is worth knowing in advance: the relay terminates TLS. The wire is encrypted on the phone’s hop and on the Mac’s hop, and the relay handles plaintext bytes in between. That is a weaker promise than the private mesh makes, where nothing between the two devices can read anything. Settings says so in the confirmation dialog and keeps a standing line on screen while the relay is on, rather than claiming a purely local arrangement that would not be true.

A client that has both a direct address and a relay address is required to try direct first, with a two-second health probe, and to fall back only on failure. You never pay for the relay on your own network.

Push notifications: specified, not built

The contract defines content-free push wake-ups in detail. They are not implemented. The routes are not in the router, and the desktop reports the push capability as a hard-coded false, which a conforming client reads as “do not offer notifications”.

What that means in practice: your phone learns that an approval is waiting only while its event stream is open. Close the app and nothing rings. Plan around that if you are relying on the phone to catch a gate.

The browser console is not served yet

Four paths are reserved in the route table for an in-app web console — /, /assets/*, /manifest.webmanifest and /sw.js. They return 404 today. Settings still shows a “console” address next to the bind address; opening it in a browser gets you nothing.

Pairing therefore needs a client that speaks the API rather than a browser pointed at the Mac.

What still works with the desktop window closed

Some state lives in the window because that is where the live state machines run. Those routes return 503 rather than an empty list, because an empty list reads as “nothing is running”, which would be a lie.

Route familyWindow closed
Feed, board, missionsworks backend-owned
State snapshotworks pane counts read 0/0 and it tells you the window is gone
Panes list, pane tail, pane input503 the pane registry lives in the window
Approvals raised by the windowabsent they reappear, with their original timestamp, if the window comes back
Converseworks tools that need the window fail individually with a message; the turn still completes

There is a third state worth knowing: the window is alive but too busy to answer in time. The approvals list then returns the gates it can prove plus a flag saying the answer is partial, and the badge count is a floor rather than a total. A stall never produces a “this gate closed” signal — nobody decided it, and the audit trail must not claim otherwise.

Limits

BucketLimitKeyed on
All requests120 per minutedevice
Concurrent event streams2device
Start an order10 per minutedevice
Confirm an order20 per hourdevice
Decide an approval30 per minutedevice
Screen capture6 per minutedevice
Converse turns6 per minute, one at a timedevice
Line into a pane30 per minutedevice
JSON body1 MB
Pair attempts10 per 15 minutessource address
Failed authentications5 consecutive → 15-minute lockoutsource address

The last two are keyed on the address because they run before there is a device to key on. One consequence: several devices behind one router share them, so a phone hammering a stale token can lock out a well-behaved phone on the same network for fifteen minutes.

You can see what the phone did

Every consequential request is written to the desktop’s own feed, in the same stream as everything else: which device, which route, the status, how long it took. Approval decisions are attributed to the phone that made them.

Routine reads are counted and rolled up once a minute rather than journaled one by one, so a polling phone cannot flush the feed and push real work out of it. Four things are never written: tokens, pairing codes, converse text, and the assistant’s replies. That a conversation happened, from which device, how long it took and which tools it used — yes. What was said — no.