Code Raven gains a small WebSocket server that only your tailnet can reach, and Code Raven Mobile gains a Meetings tab that talks to it. From the phone you convene a meeting of up to four experts or Rookery seats, read it as it happens, reply, steer Mira, and conclude. The MacBook Pro at home runs the meeting exactly as it does at the desk.
The problem
Meetings involve no coding, only reading and replying, yet they run only at the desk. Code Raven Mobile talks to the Mac through iCloud files that arrive "within a minute or so" and are checked every 120 seconds, which works for captures and cannot carry a conversation.
Planning also found desktop problems a phone would hit harder. Starting a meeting silently replaces a live one and leaves it marked active. A wrong seat count crashes the app through a precondition. Nothing keeps the Mac from sleeping or App Nap from slowing a meeting in a hidden window.
Approach
A loopback server published by Tailscale Serve
RemoteMeetingServer binds NWListener to 127.0.0.1:47821, so nothing on the home Wi-Fi can reach it. tailscale serve --bg 47821 gives it an HTTPS name on the tailnet with a valid certificate, and the phone connects with URLSessionWebSocketTask over wss://. The rejected alternative binds to the Tailscale interface directly, which needs a raw-IP connection on the phone and an unencrypted socket inside the tunnel. Phase 0 verified this path on 2026-09-14: a Let's Encrypt certificate, 101 with the token, 400 without it, and an echo from iPhone Safari on cellular.
The server drives the service and keeps no meeting logic of its own
RemoteMeetingController maps commands onto calls the desktop already makes: startMeeting, startRookery, addUserMessage, softPauseMeeting, directLead, concludeMeeting. RemoteMeetingBroadcaster watches the service's published state and pushes changes. MiraDelegatedMeetingRunner already proves the service runs with no view mounted.
One shared protocol file
RemoteMeetingProtocol.swift sits beside the existing mobile contract and follows its rules: Foundation-only, a required type, lenient defaults. Messages are diffed by id, so a phone that drops and reconnects receives one snapshot and misses nothing.
Pairing rides the iCloud container
The Mac writes remote/pairing.json with the Serve URL and a bearer token. The phone's existing metadata query already watches *.json, so pairing needs no QR code or typing.
Engine fixes land first
The one-meeting guard, the seat-count validator, the power assertion, the Rookery directive drain and the stuck isGenerating flag are desktop bugs today. A remote client makes each one easier to hit, and each one is testable at the desk before the phone exists.
Decisions made for you
Whole messages with a typing indicator
Code Raven never streams expert text; every meeting call passes onOutput: nil. The phone matches the desktop. Adding streaming touches every invocation in the service and is listed out of scope.
A meeting keeps running when the phone disconnects
iOS suspends the socket whenever the app leaves the foreground. Auto-pausing on disconnect would stop a meeting every time you switch apps. The meeting runs its turn budget and waits, the same as when you walk away from the desk. Reversible with a grace-period pause in the broadcaster.
A convene at the desk pauses the live meeting silently
The phone asks before pausing. Desk callers (widgets, the room, suggestion cards) get the pause without a prompt, which replaces today's silent overwrite. Adding a desk confirmation later is one alert in MeetingRoomView.
The Mac's meeting view steps back when the meeting changes underneath it
If the phone starts a meeting while StandupMeetingView is open at the desk, the view returns to the room. Today it would render the new meeting with the old project's roster.
The token is stored in iCloud Drive
iCloud Drive is not end-to-end encrypted unless Advanced Data Protection is on. The token alone grants nothing without being signed in to your tailnet, and Settings can regenerate it.
Remote Meetings is off by default, and Meetings is a fifth phone tab
The server starts only after you enable it in Settings → General → Mobile Sync. A fifth tab narrows each tab-bar button; a Meetings segment inside Projects was the alternative and buries the live meeting two taps deep.
The work
Phase 0: Transport spike
7/7 done Done 2026-09-14. Serve + loopback verified from the iPhone on cellular.- Sign in to Tailscale on the MacBook Pro and the iPhone with the same account, and set the Mac app to launch at loginSarah
- Enable HTTPS certificates and MagicDNS in the Tailscale admin consoleSarah
- Confirm the Tailscale CLI can run
serveon this install (standalone app vs App Store variant) - Build a scratchpad loopback WebSocket echo server with
NWListeneron127.0.0.1:47821and a bearer-token check - Run
tailscale serve --bg 47821and confirm a WebSocket upgrade through thets.netHTTPS name reaches the echo server - Confirm from the iPhone on cellular that a WebSocket connects to the
wss://nameSarah - Record the transport decision (Serve + loopback, or tailnet-interface bind) at the top of this plan before Phase 3
Phase 1: Engine fixes on the Mac
8/8 done Lands on its own and fixes latent desktop bugs.- Add
StandupMeetingService.conveneConflict()returning the live meeting whenactiveMeeting?.status == .active, ignoring concluded meetings left inactiveMeeting - Make
startMeetingandstartRookeryhard-pause a live meeting throughpauseMeeting()before replacing it, so no caller can orphan a meeting as.active - Add
MeetingConveneValidatorthat checks seat counts against theStandupMeeting.initpreconditions and returns an error instead of crashing - Hold
ProcessInfo.beginActivity(options: [.userInitiated, .idleSystemSleepDisabled])while a meeting is active, released on pause, conclude and discard - Reset
isGeneratingon the early-return guards in the conversation loops and the Rookery loop - Drain queued Mira directives and requests in the Rookery loop, matching
drainPendingLeadActionin the dev loops - Close
StandupMeetingViewback to the room whenactiveMeeting?.idchanges underneath it, instead of rendering another project's meeting with the wrong roster - Extract the note-writing half of
MeetingHandoffSheet.ensureNoteintoMeetingHandoffNoteWriterso the sheet and the server share it
Phase 2: Shared protocol
6/6 done One Foundation-only file compiled into both apps.- Create
Code Raven/Models/MobileSync/RemoteMeetingProtocol.swiftwithRemoteMeetingProtocol.version, the pairing record, and the port constant - Define
RemoteClientCommand:hello,listProjects,roster,recommendSeats,convene,resume,say,composing,pause,play,pauseForLater,directive,askMira,conclude,saveHandoffNote,recentMeetings,transcript - Define
RemoteServerEvent:welcome,projects,roster,seatRecommendation,conflict,snapshot,activity,messagesAppended,document,liveEnded,concluded,recentMeetings,transcript,handoffNoteSaved,error - Define wire models
RemoteSeat,RemoteMessage,RemoteMeetingState,RemoteMeetingSummaryandRemoteConclusionthat carry only what the phone renders - Add
MobileSync.Path.pairingand document theremote/folder in the contract file header - Write a standalone
swiftcharness that round-trips every command and event and decodes unknowntypevalues without throwing
Phase 3: Mac server
20/20 done Drives the existing service; no second meeting engine.- Create
Services/RemoteMeetings/RemoteMeetingServer.swiftbindingNWListenerwith WebSocket options to the Phase 0 endpoint, rejecting upgrades without the bearer token - Read the Mac's Serve URL from the bundled CLI's
tailscale status --json(Self.DNSName), because this Mac's own DNS cannot resolve itsts.netname - Add Publish to Tailnet and Unpublish actions that run
tailscale serve --bg http://127.0.0.1:47821andtailscale serve resetthrough/Applications/Tailscale.app/Contents/MacOS/Tailscale - Answer a rejected upgrade with an immediate 401, since the spike's tokenless loopback client hung until timeout instead of failing
- Record the
Tailscale-User-Loginvalue at first successful pairing and reject later upgrades through Serve that carry a different login - Create
RemotePairingStorethat generates and persists the token in Application Support with 0600 permissions and writesremote/pairing.jsonto the iCloud container - Create
RemoteMeetingControllermapping each command to the existing service call, resolving projects withProjectManager.shared.projects.first(where:)and callingloadAgentsForProjectbefore readingproject.agents - Build the development roster from
project.agentsminus Rookery seats and.foundation, plus Rookery guests fromAgentCatalog.agent(withId:), mirroringMeetingRoomDraft.convene - Build the Rookery roster for the Personal project from
AgentCatalog.catalog(for: .personal)with seat charters - Convene development meetings with 1 seat as
.consultationand 2–4 as.standup, attaching the expert's latest report the wayMeetingRoomDraft.latestReportReferencedoes - Convene Rookery sittings through
RookeryRoomBuilder.buildwithcap: 4andstartRookery, rejecting a Rookery convene on a non-Personal project - Answer
recommendSeatswithExpertBenchRouter.selectfor development projects andRookeryRouter.selectfor Personal, trimmed to four - Return
conflictwith the live meeting's topic and project whenconveneConflict()is non-nil and the command lackspauseExisting: true - Create
RemoteMeetingBroadcasterobserving the service publishers, diffing messages by id, and sendingsnapshoton connect andmessagesAppendedafter - Map
composingtouserIsComposingwith a 30-second expiry and clear it when the connection closes - On
conclude, awaitconcludeMeeting(), then send the summary andMeetingHandoffResolver.resolvefor development meetings or the record and brief for Rookery sittings - Handle
saveHandoffNotethroughMeetingHandoffNoteWriter, callingloadNowIfNeededon the project'sScratchpadManagerfirst - Serve
recentMeetings(active, paused, and the last five concluded) and read-onlytranscriptfromMeetingStore - Add a Remote Meetings block to
MobileSyncSettingsSection: enable toggle (off by default), server status, Serve URL readout, publish state, connected-phone indicator, and Regenerate Token - Start the server from the
applicationDidFinishLaunchingtask afterrecoverActiveMeetings()when enabled, and stop it inapplicationWillTerminate
Phase 4: Phone
12/12 done A fifth tab built from the existing Theme components.- Create
Mobile/CodeRavenMobile/Meetings/MeetingConnection.swift, a@MainActorstore that reads the pairing record, connects, reconnects with backoff on foreground, and publishes connection state - Add a
meetingscase toRootView.Tab, itsNavigationStack, and aRavenTabBaritem, and update the four-tab header comment - Build
MeetingsHomeView: connection status, the live meeting card, and recent meetings for theProjectSelectionproject - Build
ConveneMeetingView: question, details, a seat picker capped at four (development experts plus Rookery guests, or Rookery seats in Personal), and a Let Mira Choose action - Show the conflict confirmation "A meeting on topic is running on the Mac. Pause it and start this one?" and resend with
pauseExisting: trueon confirm - Build
LiveMeetingViewon the editorial register: message bubbles colored per seat, Mira's bubbles, the typing indicator, and the natural-end banner - Build the composer with an @Mira insert,
composingsignals while text is non-empty, and send - Add pause/play, a directive menu filtered like
LeadDirective.available(for:phase:), and Ask Mira - Show Mira's living document in a sheet rendered with
MarkdownView - Build
MeetingConclusionViewwith the summary or Rookery record and a Save Handoff as Note action - Add a read-only transcript screen for concluded meetings and a Resume action for paused ones
- Run
xcodegen generateand add a-tab meetingssample state toSampleDatafor simulator screenshots
Verification
6/7 done Compile checks and scratchpad harnesses.xcodebuild -scheme "Code Raven" -destination 'platform=macOS' buildsucceedsscripts/verify-build-product.shconfirms the built bundle is fresh- The Mobile
xcodebuildfromMobile/README.mdsucceeds for the simulator - The Phase 2 protocol harness passes
- A scratchpad harness runs
RemoteMeetingServer's listener on loopback and confirms a client without the token is rejected and a client with it receiveswelcome - A
MeetingConveneValidatorharness rejects 0 and 5 development seats, 0 and 5 phone Rookery seats, and 2 seats for a consultation - Simulator screenshots of Meetings Home, Convene, Live and Conclusion from sample data
Manual Testing
10 tasks Runtime checks on the real devices.- Quit and relaunch Code Raven on the MacBook Pro so the new build runs, then enable Remote Meetings in Settings → General → Mobile SyncSarah
- Install the phone build from Xcode and confirm the Meetings tab reads Connected at home on Wi-FiSarah
- Confirm Code Raven Mobile connects on its first launch without a Local Network promptSarah
- With Wi-Fi off on the phone, convene a four-expert development meeting in CodeRaven and confirm messages arrive and a reply lands in the roomSarah
- Convene a four-seat Rookery sitting in Personal from the phone and use a seat summons and an @Mira mentionSarah
- Start a meeting at the desk, convene another from the phone, confirm the conflict prompt, and confirm the desk meeting shows as paused and resumes cleanlySarah
- Lock the phone for five minutes mid-meeting, reopen, and confirm the transcript catches up with nothing missingSarah
- Conclude a development meeting from the phone, save the handoff as a note, and confirm the note on the Mac links to the meetingSarah
- Leave the Mac idle on AC power with the display asleep for 30 minutes and confirm a phone convene still worksSarah
- Confirm a phone on a different Tailscale account, or with Tailscale off, cannot connectSarah
Risks & edge cases
Local Network prompt in the app
Phase 0 proved Serve carries a WebSocket from iPhone Safari on cellular to a loopback server on the Mac. Safari and Code Raven Mobile get separate permission decisions, so whether the app meets a Local Network prompt is checked on its first device run.
The Mac is unreachable
A closed lid, a logged-out Tailscale, or Code Raven not running all leave the phone showing Unreachable with no way to fix it remotely. AC sleep is already off on this MacBook Pro; the lid is the remaining gap.
Loop fixes touch code that has never been runtime-exercised
Resetting isGenerating on early returns and draining Rookery directives change the conversation loops. The view-evaluation early return is deliberately left alone because Design Studio depends on it.
No notification when the room waits for you
A meeting that reaches its natural end while the phone is locked sits quietly until you open the app.
Your home network's DNS filter
NextDNS on the home network blocks some hostnames that MagicDNS forwards to it. It already broke Chrome's secure DNS on this Mac. The phone resolves the Mac's ts.net name through Tailscale's split DNS route, which the filter does not see, and Phase 0 confirmed the phone reaches the Mac that way. The Mac cannot resolve its own name with Tailscale DNS off, so the app reads its Serve URL from the Tailscale CLI.
A renamed Mac changes the Serve URL
The pairing file is rewritten on launch, so the phone recovers after the Mac restarts Code Raven.
Out of scope
- Live text streaming. Nothing streams on the desktop either. Adding it means threading
onOutputthrough every meeting invocation, and it belongs in its own plan. - Push notifications when the room waits for you. iOS suspends the socket in the background. A notification needs APNs, which means a server or CloudKit subscription.
- Images from the phone, context references, and prior-meeting references at convene. The service accepts all three. The phone picker work is deferred.
- Rookery extras: Empty Chair, pursuit binding, presets, and more than four seats.
- Plan This in Claude Code from the phone. The handoff lands as a note and is planned at the desk.
- Spec reviews and view evaluations. Both are driven from Ideation and Design Studio.
- The iMac as host.
- Running two meetings at once. Sarah chose one at a time on 2026-09-14.
Final review
Strongest part. The phone gets meetings without a second meeting engine, and the first phase fixes desktop bugs that exist today regardless of the phone.
Weakest part. Phase 0 removed the transport risk, but the plan has 70 tasks with runtime verification only at the end. Phase 3 is the largest phase and the one most likely to surface surprises in how views prepare meetings before calling the service.
Adjustments worth considering. Ship Phase 1 on its own first. Consider cutting the concluded-transcript screen from Phase 4 if the phone's Chronicle reader already shows meeting records well enough.
The server-drives-service shape is solid and already proven by the Mira delegated runner. The score is held down by how much of the convene logic lives in drafts that the controller has to mirror.