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:1and 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:
- You turned Remote on in Settings. The setting is
remote_enabled, and absent means off. - 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 gets503 license_inactive, and the listener closes. - A bind address could be resolved for the mode you picked.
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:
| Label | Means |
|---|---|
| listening | The server is up and the panel names the address and port it answers on. |
| not listening | You 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. |
| off | The 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.
| Mode | Called in Settings | Binds to | When it fails |
|---|---|---|---|
loopback | This machine only | 127.0.0.1 | Never. A phone cannot connect to it either. |
tailnet (default) | Private mesh | The first local IPv4 address inside 100.64.0.0/10 | No such address → the server does not start; the reason is no_tailnet_address |
lan | Local network | The 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.
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.
- 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.
- Press Pair a device. The desktop shows an 8-digit code and a QR of the same payload.
- The phone scans it, or you type the code into a client pointed at the address.
- The phone exchanges the code for a device token, once. The desktop stores only
sha256(token)— no route reads a token back.
| Fact | Value |
|---|---|
| Code length | 8 digits, numeric |
| Code lifetime | 5 minutes, single use |
| Wrong tries before the code burns | 5 — the window closes and you press the button again |
| Pair attempts per source address | 10 per 15 minutes |
| Paired devices per machine | 8 — revoke one to make room |
| Token | 32 random bytes from the OS generator, 64 lowercase hex characters |
| Token expiry | None. 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.
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.
| Scope | Called in Settings | Grants |
|---|---|---|
monitor | Monitor | Read the machine’s state: feed, board, missions, pane output, counts. |
order | Order | Run an intake and land the result as a Board task or a headless mission. |
approve | Approve | List the gates waiting on a human, and decide one. |
converse | Converse | One text or voice turn with the assistant, on a read-only tool surface. |
drive | Type into panes | Send 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.
| Platform | Screen capture from a phone |
|---|---|
| macOS | available needs the Screen Recording grant |
| Linux | available needs grim or maim installed |
| Windows | no 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
| Action | From a paired phone |
|---|---|
| Read the feed, board, missions, pane output | Yes, with monitor |
| Move a board card | No. The board is read-only over the remote. |
| Start, cancel or merge a mission directly | No. Missions are read-only; an order can create one. |
| Run a full Maestro dispatch | No. There is no maestro order target — a Maestro run gates on a plan approval at the window. |
| Browse files, open an editor, write to git | No. None of it exists on this surface. |
| Read an arbitrary file through the assistant | No. The file-read tool is on the never-list for the converse scope. |
| Send a line into a running pane | Only with drive, and one line per request |
| Take a screen capture | Only with monitor plus the per-device flag |
| Decide an approval that is already waiting | Yes, with approve |
| Cause an approval to exist | No. The remote does not create gates. |
| Talk to the assistant | Yes, with converse, on a read-and-status tool surface only |
| Bring the desktop window forward | No. 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 target | What lands | Permission category | Called in Settings |
|---|---|---|---|
board (default) | One Board card per plan task, in todo | board_write | Board write |
mission | One headless mission that runs an agent CLI in your repo | shell | Shell commands |
Only allow proceeds. deny refuses, and ask refuses too — with 403 and a message naming the category and the current policy.
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.
| Rule | Behaviour |
|---|---|
| One line per request | The desktop appends Enter itself. One request is one submitted line. |
| Embedded newline or carriage return | 400. A second command cannot be smuggled into one call. |
| Control characters | Dropped, tab excepted. An escape sequence drives the agent’s own interface rather than answering it. |
| Body cap | 4 KB |
| Rate | 30 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 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 family | Window closed |
|---|---|
| Feed, board, missions | works backend-owned |
| State snapshot | works pane counts read 0/0 and it tells you the window is gone |
| Panes list, pane tail, pane input | 503 the pane registry lives in the window |
| Approvals raised by the window | absent they reappear, with their original timestamp, if the window comes back |
| Converse | works 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
| Bucket | Limit | Keyed on |
|---|---|---|
| All requests | 120 per minute | device |
| Concurrent event streams | 2 | device |
| Start an order | 10 per minute | device |
| Confirm an order | 20 per hour | device |
| Decide an approval | 30 per minute | device |
| Screen capture | 6 per minute | device |
| Converse turns | 6 per minute, one at a time | device |
| Line into a pane | 30 per minute | device |
| JSON body | 1 MB | — |
| Pair attempts | 10 per 15 minutes | source address |
| Failed authentications | 5 consecutive → 15-minute lockout | source 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.