70 tasks · 5 phases · 2 apps · 2026-09-14

Live Meetings on Code Raven Mobile

Convene, follow and steer expert meetings and Rookery sittings from the iPhone while the MacBook Pro at home runs them, over a Tailscale-published WebSocket server inside Code Raven.

70Tasks
5Phases
4Seats max
1Meeting at a time
The gist

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

Judgment call

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.

Judgment call

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.

Judgment call

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.

Judgment call

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.

Judgment call

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.

Judgment call

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 serve on this install (standalone app vs App Store variant)
  • Build a scratchpad loopback WebSocket echo server with NWListener on 127.0.0.1:47821 and a bearer-token check
  • Run tailscale serve --bg 47821 and confirm a WebSocket upgrade through the ts.net HTTPS 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 when activeMeeting?.status == .active, ignoring concluded meetings left in activeMeeting
  • Make startMeeting and startRookery hard-pause a live meeting through pauseMeeting() before replacing it, so no caller can orphan a meeting as .active
  • Add MeetingConveneValidator that checks seat counts against the StandupMeeting.init preconditions 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 isGenerating on the early-return guards in the conversation loops and the Rookery loop
  • Drain queued Mira directives and requests in the Rookery loop, matching drainPendingLeadAction in the dev loops
  • Close StandupMeetingView back to the room when activeMeeting?.id changes underneath it, instead of rendering another project's meeting with the wrong roster
  • Extract the note-writing half of MeetingHandoffSheet.ensureNote into MeetingHandoffNoteWriter so 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.swift with RemoteMeetingProtocol.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, RemoteMeetingSummary and RemoteConclusion that carry only what the phone renders
  • Add MobileSync.Path.pairing and document the remote/ folder in the contract file header
  • Write a standalone swiftc harness that round-trips every command and event and decodes unknown type values without throwing

Phase 3: Mac server

20/20 done Drives the existing service; no second meeting engine.
  • Create Services/RemoteMeetings/RemoteMeetingServer.swift binding NWListener with 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 its ts.net name
  • Add Publish to Tailnet and Unpublish actions that run tailscale serve --bg http://127.0.0.1:47821 and tailscale serve reset through /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-Login value at first successful pairing and reject later upgrades through Serve that carry a different login
  • Create RemotePairingStore that generates and persists the token in Application Support with 0600 permissions and writes remote/pairing.json to the iCloud container
  • Create RemoteMeetingController mapping each command to the existing service call, resolving projects with ProjectManager.shared.projects.first(where:) and calling loadAgentsForProject before reading project.agents
  • Build the development roster from project.agents minus Rookery seats and .foundation, plus Rookery guests from AgentCatalog.agent(withId:), mirroring MeetingRoomDraft.convene
  • Build the Rookery roster for the Personal project from AgentCatalog.catalog(for: .personal) with seat charters
  • Convene development meetings with 1 seat as .consultation and 2–4 as .standup, attaching the expert's latest report the way MeetingRoomDraft.latestReportReference does
  • Convene Rookery sittings through RookeryRoomBuilder.build with cap: 4 and startRookery, rejecting a Rookery convene on a non-Personal project
  • Answer recommendSeats with ExpertBenchRouter.select for development projects and RookeryRouter.select for Personal, trimmed to four
  • Return conflict with the live meeting's topic and project when conveneConflict() is non-nil and the command lacks pauseExisting: true
  • Create RemoteMeetingBroadcaster observing the service publishers, diffing messages by id, and sending snapshot on connect and messagesAppended after
  • Map composing to userIsComposing with a 30-second expiry and clear it when the connection closes
  • On conclude, await concludeMeeting(), then send the summary and MeetingHandoffResolver.resolve for development meetings or the record and brief for Rookery sittings
  • Handle saveHandoffNote through MeetingHandoffNoteWriter, calling loadNowIfNeeded on the project's ScratchpadManager first
  • Serve recentMeetings (active, paused, and the last five concluded) and read-only transcript from MeetingStore
  • 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 applicationDidFinishLaunching task after recoverActiveMeetings() when enabled, and stop it in applicationWillTerminate

Phase 4: Phone

12/12 done A fifth tab built from the existing Theme components.
  • Create Mobile/CodeRavenMobile/Meetings/MeetingConnection.swift, a @MainActor store that reads the pairing record, connects, reconnects with backoff on foreground, and publishes connection state
  • Add a meetings case to RootView.Tab, its NavigationStack, and a RavenTabBar item, and update the four-tab header comment
  • Build MeetingsHomeView: connection status, the live meeting card, and recent meetings for the ProjectSelection project
  • 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: true on confirm
  • Build LiveMeetingView on 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, composing signals 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 MeetingConclusionView with 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 generate and add a -tab meetings sample state to SampleData for simulator screenshots

Verification

6/7 done Compile checks and scratchpad harnesses.
  • xcodebuild -scheme "Code Raven" -destination 'platform=macOS' build succeeds
  • scripts/verify-build-product.sh confirms the built bundle is fresh
  • The Mobile xcodebuild from Mobile/README.md succeeds 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 receives welcome
  • A MeetingConveneValidator harness 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

Partly verified

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.

Edge case

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.

Regression risk

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.

Edge case

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.

Edge case

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.

Edge case

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 onOutput through 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.

76/100

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.