← back

redeye

2026

your computer, without being at your computer

redeye

pair your phone to your Mac and run real coding sessions on it from anywhere. the agent runs as you, in your own repo, with your shell and your auth; the boundary is pairing plus whatever macOS has granted, not a path check the app could talk itself out of. a menu-bar Mac app holds the link (round trip, uptime, frames, reconnects, spend) and an iOS app drives it: prompt from the phone, watch tool calls and diffs land, and see the live Mac screen when you need to look.

two outbound sockets, a stateless relay, claude as a child process

  • swift
  • swiftui
  • electron
  • node
  • python
  • fastapi
  • websockets
  • hmac
  • postgres
  • webcodecs
  • jpeg
  • bonjour
  • claude-code
  • apns

the problemA coding agent you cannot reach is a coding agent you do not have

Claude Code is useful on my Mac because it has my shell, my git credentials and my repos checked out where I left them. That is also why it is stuck: shut the lid, get on a train, and the useful thing is in a room I am not in.

I looked at three ways out and took the one where nothing listens.

move it to a servernone of your authnone of your checkoutsrebuild a laptop each sessionexpose the Macinbound port on a personal boxtunnel or dynamic DNSevery hotel NAT gets a voteboth ends dial outno inbound port anywhererelay copies frames onlyredeploy unpairs nothing
three ways to reach your own machine

the ideaThe relay only introduces them

Identity is an HMAC-signed token that carries its own claims, so the relay verifies a signature and reads who may talk to whom. It looks nothing up, which is why the switchboard is 171 lines of dictionaries.

The default signing secret used to be compiled in, meaning a relay deployed without one booted fine and minted forgeable identities. Its absence is now a refusal to start, and a second key verifies without minting so rotation is a deploy rather than an outage.

pair/claim
the most valuable unauthenticated route here, so 8 attempts per caller and a global bucket of 200
1mac shows codeK7F-2QM, 10 min TTL2phone claimsPOST /v1/pair/claim3code poppedsingle use4device tokennames one host5socket opensboth ends outbound
pairing a phone
the macrelayiphonehost bearerredeye://pair?c=code, device namedevice_token/v1/ws/devicedevice.connectedrelay.jsPOST /v1/pair/newrenderDevices()the Devices panel/v1/pair/newin memory, 600s/v1/pair/claimpops the codeauth.py mint()typ device, sub, hosthub.pyattach_device, host toldScanViewqr or typedRelayClient.pairPairResponse decoded
a pair code becomes an authorised device row

how it worksOne prompt, phone to process and back

Text goes on the phone's socket, the relay writes it onto the Mac's socket, and the Mac writes it as one JSON line into a running claude child. Answers come back the same way, an event per line.

Streamed deltas are appended to a block, then the block is replaced wholesale when the final text arrives. Replacing rather than appending is what stops every answer rendering twice, and it is where the haptic fires, once per block instead of once per token.

event kinds
one child line becomes one of fourteen: delta, text, thinking, tool, tool_result, agent, title, done, outcome and the rest
iphonerelaymacossession.inputby keystdinsession.eventChatViewcomposerAppStatesocket, sessions/v1/ws/devicehub.pycopies, stores nothinghost.js45-case frame routeragent.jsstream-json both waysclaude childyour shell, your authyour repo
one prompt, end to end
the agentthe macrelayiphonestream-jsonfolded insession.eventclaude stdoutone json linetool_result blockarrives as user linenormalize()one line, one kindkind: toolname, summary, detailrelay.jstoDevice, lan first/v1/ws/hostpops the to fieldhub.pyto_device by idapply()AppState, by keyToolCardDiffBlock when Edit
one tool call, stdout to a card on the phone
iphonerelaythe macthe agentupload.chunkone file, prefixedupload.resultpath, not bytesAttachMenucamera roll, cameraUploadDeskbase64, 480 KiB chunkscompose()paths into the text/v1/ws/devicecopies, keeps nothinguploads.jsbegin, chunk, end~/Redeye/uploads0600, outside TCCclaude childopens the path itself
a photo becomes a path the agent can open

the fenceWhat the boundary actually is, and what it is not

There was an app-level fence once, one chosen folder and a path check on everything the phone named. I removed it because it stopped the honest route and none of the others: a paired phone could always start a session inside the folder and ask the agent to read the disk, and a symlink walked out of it anyway.

So the folder you pick is where the phone starts, not where it ends. An owner's session runs as the owner with bypassPermissions, because there is nobody at the Mac to answer a prompt, so if an agent must not reach a directory, do not grant Redeye access to it.

allowlist, not denylist
a forgotten allowlist entry means extra prompts, a forgotten denylist entry means a member skipping permissions on somebody else's Mac
one assignment
the clamp sits on the setter, not at the four places a mode can arrive from
assumeda path check in the appone allowed folderdefeated by a symlinkenforced by my codeactualpairing, done by a personmacOS TCC grantssymlinks do not helpenforced by the OS
the fence people assume vs the fence that holds
screen recordingthe live viewaccessibilityinput controlthe folder triodesktop, documents, downloadsfull diskopt in, at the Mac
the four TCC grants Redeye tracks
ownerbypassPermissionsreal HOMEagent config, ssh agent, tokens presentwhatever TCC grantedmembercapped at acceptEditsHOME relocatedCLAUDE_CONFIG_DIR, SSH_AUTH_SOCK, ANTHROPIC_BASE_URL, GH_TOKEN, XDG rewrittensame TCC ceiling
owner session vs member session

the hard partA socket that says it is connected and is not

The bug that cost me most had nothing to do with agents. When the far end vanishes without a FIN, and a stream cut at the hour mark looks exactly like that, the socket stays ESTABLISHED, close never fires, and every frame written to it is retransmitted into nothing.

Screen capture was the other one. Shelling out per frame capped it near a slideshow, and a hidden renderer's compositor is so starved that a video element became the next ceiling, so frames are pulled off the track itself.

pacing
measured from grab time, not encode end, or encode time inflates every interval
two watchers
the stream runs at the highest fps anyone asked for
what the OS saysESTABLISHEDno close eventUI shows Connecteda latency figure from cachewhat is trueinbound bytes at zeroevery send a retransmitphone sees nothingfor hours
connected, and actually connected
1pingevery 5s2wait2x the interval32 misses~20s of silence4terminate()close would wait forever5reconnect1s to 30s, jittered
the watchdog
hidden renderermain processrelayiphone30 fps track12 fpscapture:framescreen.frameUIImagecapture.jsframes off the trackwanted()paced from grab timeencode()jpeg at q0.52lastSignatureidentical base64 droppedscreen.jsone encode, every watcherfanOutFrame()skipped if saturatedhub.py1 MiB frame capAppStatescreenSeq drops stragglersScreenFloatdraggable corner float
one screen frame, capture to pixels

the stackFour apps, one frame vocabulary

Every message is the same shape and nothing about it is typed or generated, so adding one means touching three languages by hand and I have shipped a Swift decoder that disagreed with a JS producer. What it buys is a relay that never has to understand any of it.

The relay stores accounts, orgs and a session index, never a transcript and never a local path. A path carries a username and the shape of a disk, and two Macs disagree about it anyway, so a folder is identified by its git remote and each Mac resolves its own checkout.

redeye-backendpython, fastapiredeye-desktopelectron, noderedeye-mobileswiftuiredeye-webnext.js
four apps
iphoneSwiftUI · 142 viewsURLSession websocket · WidgetKit live activityrelayFastAPI · uvicorn · Cloud Runasyncpg · HMAC-SHA256 tokensmacElectron · ws · dns-sd bonjour · desktopCapturerthe agentclaude · mantis · grok · stream-json over stdio
what sits on what
phonetransportmac32-byte secretAppStaterelayanywherelan.jssame wifihost.jscannot tell which
the same frames, two roads

where it isWhat works, what I know is wrong

It works end to end and there is a script that proves it: a host stood up, a simulated phone paired through the real deployed relay, a real Claude session driven, the tool calls and the answer asserted on arrival. A command-line iPhone takes the same path, which is how most of the protocol got debugged without opening Xcode.

The bug I am most wary of now was a field that was correct and unread. One flag had described which engines report a running cost total since the engine table was written, nothing consulted it, and every turn-driven session looked free after its first answer.

swift lines34,429 countelectron main lines20,555 countrelay python lines6,784 countios views142 countframe types97 counthttp routes50 countinbound frames handled45 count
counted scale
the agentthe maciphonetotal_cost_usdwhich engineper-turn deltaspend.resultresult linerunning total, not turnagent.jsreported minus costSeencostCumulativefalse for turn enginesledger.jscountTurn, day buckethost.js spend()the Mac's own dayAppStatecase spend.resultForecastChipprojected spend today
what a turn cost, receipt to chip
pushbuilt, needs an APNs keylive activityuntested against a real pushpassword resetneeds a mail providerforked historyrefused, not resolvedcodexits own json, refused
open