Turbo is an iOS Push-to-Talk App that uses a Unison Cloud backend.
The app side currently proves out Apple's PushToTalk framework integration. The backend side is now a first control-plane slice for direct 1:1 channels, device registration, ephemeral PTT token handling, HTTP control endpoints, websocket signaling, and APNs wakeups.
Turbo/: SwiftUI iOS prototype app.Server/: backend notes and architecture documentation.AGENTS.md: repo-specific instructions for AI/code agents working on the Unison codebase.UNISON.md: Unison workflow, mode rules, and documentation/testing rules.UNISON_LANGUAGE.md: Unison language guide and syntax reference.SWIFT.md: app-side Swift/iOS architecture and working guidance.APP_STATE.md: app-side state machines, session phases, and successful PTT flow examples.SWIFT_DEBUGGING.md: simulator/device/PTT/audio debugging guidance.BACKEND.md: backend/control-plane/storage/query guidance.TOOLING.md: tooling, operational entrypoints, and infrastructure overview.STATE_MACHINE_TESTING.md: canonical scenario-driven state-machine testing workflow..agents/: supporting Unison language and workflow notes.
Use these docs as the primary authority for their respective concerns:
AGENTS.md- repo-level working rules, mode overview, startup guidance, and doc-loading rules
TOOLING.md- tooling, operational entrypoints, simulator/probe infrastructure, and how to choose the right tool
UNISON.md- Unison workflow, scratch-file/typechecking process, and Unison-specific mode rules
UNISON_LANGUAGE.md- Unison syntax, semantics, and language-reference guidance
SWIFT.md- app/client architecture, state management boundaries, and implementation guidance
APP_STATE.md- app-visible session phases, state derivation, and PTT journey examples
SWIFT_DEBUGGING.md- simulator/device/PTT/audio debugging loops and escalation rules
BACKEND.md- backend/control-plane scope, storage/query rules, and backend operational guidance
STATE_MACHINE_TESTING.md- the default distributed bug reproduction, proof, and regression-testing model
handoffs/README.md- handoff conventions and how to use the timestamped handoff log
handoffs/*.md- timestamped project state and session memory
The iOS app has a backend integration path:
- It uses Apple's PushToTalk framework.
- It has the PTT entitlement and background mode configured.
- It receives real ephemeral PTT push tokens from
PTChannelManager. - It uses the backend for dev seeding, auth, device registration, direct-channel lookup, join, ephemeral token upload, and begin/end transmit.
- Contact presence, request queues, and conversation state are now backend-driven.
- The contact list now has a dedicated backend summary route, and the selected conversation uses a stronger backend-owned session snapshot.
- The app also surfaces Apple-held PushToTalk sessions separately, so a stale system session can be ended from the UI.
- Local websocket signaling is not currently used in the fast local-dev loop.
- The app no longer depends on WebRTC or CocoaPods; media transport is being kept behind an app-owned abstraction so a relay-oriented implementation can replace the prototype spike cleanly.
The backend should act as the control plane, not the media plane.
Planned v1 responsibilities:
- dev auth and a simple user directory
- device registration
- stable backend-owned 1:1 direct channels
- channel membership checks
- ephemeral PTT token ingest and storage
- websocket signaling for control-plane notices and future transport setup
- single active transmitter enforcement per channel
- local stub push sender for development
Explicit non-goal for v1:
- media relay or SFU
Planned media direction after the prototype spike cleanup:
- iOS client:
PushToTalk+AVAudioSession+AVAudioEngine - transport: app-owned
MediaSessionboundary with a future relay-oriented implementation - backend: Unison remains the control plane; media relay will run separately
Start here for backend design:
Server/unison_ptt_handoff.mdServer/backend_architecture.md
Current local facts confirmed in this repo:
- Unison project name:
turbo - Reference project (read-only):
cuts ucmis installed locally- the local Unison MCP can access
turbo/mainandcuts/main
Current backend libraries installed in turbo/main:
base@unison/cloud@unison/routes@unison/json
The cuts project is still the best local reference for service structure, store modules, and local/cloud entrypoint patterns.
If you are starting fresh in this repo:
Read this core set first:
- Read
AGENTS.md. - Read
handoffs/README.md. - Read the latest file in
handoffs/if you need the current project state.
Then load only the docs needed for the task:
- Read
TOOLING.mdfor tooling and infrastructure context. - Read
UNISON.mdfor Unison/backend workflow rules. - Read
UNISON_LANGUAGE.mdonly for Unison syntax or semantics. - Read
SWIFT.mdfor app/client architecture and implementation work. - Read
APP_STATE.mdfor app-visible conversation/session states and transition examples. - Read
SWIFT_DEBUGGING.mdfor simulator/device/PTT/audio debugging. - Read
BACKEND.mdfor backend/cloud/storage/route work. - Read
STATE_MACHINE_TESTING.mdwhen the task is about distributed bugs, scenario design, or proof loops. - Read
Server/backend_architecture.mdif you need backend structure or Unison deployment context.
Treat the backend as control-plane-only unless the user explicitly changes scope.
The iOS client important boundaries are authority for session logic. New behavior should usually go into the domain, coordinators, or typed integration seams first.
Turbo now has a real development observability loop:
- debug builds auto-capture structured state transitions
- debug builds auto-publish diagnostics after high-signal transitions
- the backend stores exact-device diagnostics per authenticated user
- merged timeline tooling can read
device A + device Bwithout manual upload steps - simulator scenarios are checked into
scenarios/and run against the simulator PTT shim plus the real backend
This means distributed control-plane bugs should now be debugged in this order:
- reproduce in the simulator scenario runner when possible
- inspect the merged timeline
- only move to physical devices for Apple-specific behavior
Treat STATE_MACHINE_TESTING.md as the canonical statement of that loop.
The simulator diagnostics transport is fixed, but the scenario itself is not yet green.
What is true right now:
just simulator-scenario-mergereliably reads exact-device simulator reports after a scenario run- the scenario runner now executes real Swift Testing cases instead of silently running zero tests
- the current failing test is
TurboTests/simulatorDistributedJoinScenario()
So the next engineering task is no longer “make simulator diagnostics visible”; it is “fix the actual scenario crash now that the simulator runner is truthful.”
Prefer this order:
- Backend verification
just prod-probe- probe defaults are the reserved handles
@quinnand@sasha, not the manual device-test pair
- App verification in simulator
- run
just simulator-scenariofor the distributed control-plane smoke - run the in-app self-check when you need one-app diagnostics
- inspect the persistent diagnostics log when a state transition looks wrong
- run
- Real device verification
- only for PushToTalk / background / lock-screen / audio behavior
The simulator is now valid for distributed control-plane verification because the app uses a simulator PTT shim instead of PTChannelManager there.
Use these commands:
just simulator-scenario- runs the checked-in simulator scenarios in
scenarios/ - covers request creation, incoming accept, peer-ready, both-ready, transmit begin/end, and disconnect
- activates the scenario runner through a temporary repo-local runtime config file so the simulator test process executes the selected spec deterministically
- runs the checked-in simulator scenarios in
just simulator-scenario request_accept_ready- runs only the named checked-in scenario
- use this when iterating on one distributed bug without paying for the whole scenario set
just simulator-scenario-merge- fetches the simulator pair's latest published diagnostics by exact device id
- use it after a run to inspect the merged timeline without manual uploads
Current source of truth:
- the
simulatorDistributedJoinScenario()spec runner result - the merged simulator diagnostics timeline fetched by
just simulator-scenario-merge - the regular
TurboTestsunit suite
Current status:
- the merged simulator diagnostics path is now reliable
- the scenario itself currently fails, so treat that failure as a real product/integration bug rather than a tooling issue
Recommended testing strategy:
- express new distributed regressions as checked-in scenario JSON in
scenarios/ - simulator scenarios for request/join/ready/transmit/disconnect and distributed state-machine bugs
- physical devices only for microphone permission, real Apple PushToTalk UI, backgrounding, lock screen, and actual audio
Foreground signaling can still use the app websocket, but background receive needs the real PushToTalk wake contract:
- the app uploads the ephemeral PushToTalk token it receives while joined
- the backend uses that token to send a
pushtotalkAPNs push when a remote speaker starts - the app's
incomingPushResult(...)returns the active remote participant quickly - PushToTalk then activates the audio session
- only after that activation should the app reconnect transport and start background playback
For fast iteration:
- simulator/unit loop:
- keep reducer/domain tests for payload parsing and wake state
- use
just simulator-ptt-push <channel_id>to inject a simulator push payload into the running app
- backend payload loop:
- use
just ptt-push-target <channel_id> <backend> <sender>to inspect the canonical receiver token + wake payload for the sender's active transmit - the intended end state is direct APNs send from Unison, but hosted Unison Cloud is currently waiting on the upstream runtime rollout
- until that runtime is deployed, the interim production sender should be the backend-triggered Cloudflare worker path described in APNS_DELIVERY_PLAN.md
- use
just ptt-apns-start <channel_id> <backend> <sender>only for manual one-off APNs debugging once auth env vars are configured - use
just ptt-apns-worker <backend>andjust ptt-apns-bridge <backend> @avery @blakeonly as legacy/debug helpers - wake-send attempts are uploaded to the backend dev diagnostics surface, so
scripts/merged_diagnostics.pyincludes them in the merged timeline as[wake:apns] ...
- use
- device loop:
- use physical devices for lock-screen and blue-pill validation
- treat those runs as the source of truth for background wake behavior
The simulator path is useful for payload handling and app state transitions, but physical devices are still required for the real PushToTalk wake + audio-session behavior.
APNs sender env vars for deploys and local APNs debugging:
TURBO_APNS_TEAM_IDTURBO_APNS_KEY_IDTURBO_APNS_PRIVATE_KEY_PATHorTURBO_APNS_PRIVATE_KEY- optional
TURBO_APNS_USE_SANDBOX=1for development entitlements - optional
TURBO_APNS_BUNDLE_ID="com.rounded.Turbo"
Recommended local setup uses direnv with an untracked .envrc:
export TURBO_APNS_TEAM_ID="YOUR_TEAM_ID"
export TURBO_APNS_KEY_ID="YOUR_KEY_ID"
export TURBO_APNS_PRIVATE_KEY_PATH="$HOME/.config/turbo/AuthKey_YOUR_KEY_ID.p8"
export TURBO_APNS_USE_SANDBOX=1Notes:
- keep the
.p8key outside the repo, for example under~/.config/turbo/ .envrcis ignored by git in this repo, so local APNs secrets stay untracked- after creating or editing
.envrc, rundirenv allow - verify the variables are visible inside the repo with
direnv exec . env | rg '^TURBO_APNS' turbo.deployresolvesTURBO_APNS_PRIVATE_KEY_PATHlocally at deploy time and stores the PEM contents in cloud config asTURBO_APNS_PRIVATE_KEY- deployed backend code should read
TURBO_APNS_PRIVATE_KEY, not a filesystem path
The app now writes a persistent diagnostics log file automatically.
In-app:
- open the diagnostics sheet
- note the displayed log-file path
- use
Copy transcriptfor a shareable plain-text snapshot
The diagnostics snapshot currently includes:
- current identity
- selected contact
- active channel id
- joined/transmitting/backend/websocket/media state
- status text
- backend status text
The app also auto-publishes diagnostics in debug builds after high-signal state transitions.
That means the normal loop is now:
- reproduce once
- tell the agent which side looked wrong
- fetch the latest report or merged timeline from the backend
Manual upload remains available, but it is now a fallback rather than the primary workflow.
For simulator-driven distributed debugging, the normal loop is now:
just simulator-scenario <name>just simulator-scenario-merge- fix the failing invariant or state transition
Most recent validated commands:
xcodebuild -project Turbo.xcodeproj -scheme BeepBeep -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build CODE_SIGNING_ALLOWED=NOxcodebuild -project Turbo.xcodeproj -scheme BeepBeep -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.4' -skip-testing:TurboUITests test CODE_SIGNING_ALLOWED=NO
The unit suite currently covers:
- session coordinator invariants
- authoritative contact retention
- selected-session reconciliation rules
- primary action derivation
- self-check summary behavior
- simulator PTT join/transmit behavior
- a simulator-backed distributed smoke scenario
Important design decisions already agreed for v1:
- Use one shared backend implementation for local and cloud.
- Provide both
turbo.deployandturbo.serveLocal. - Stub APNs locally by logging intended pushes.
- Use backend-owned direct channel IDs.
- Store ephemeral PTT tokens per
channel + user + device. - Route to one active receiving device per user per channel.
- Include real websocket signaling from the first backend milestone.
- Enforce at most one active transmitter per direct channel.
- Keep the media contract transport-agnostic so a relay-oriented transport can replace the prototype spike cleanly.
Backend storage changes in this repo should follow these rules:
- model
OrderedTablekeys from the queries we need to serve - use compound keys and
rangeClosed.prefixfor scoped reads - avoid whole-table scans with in-memory filtering on route hot paths
- add explicit secondary indexes or projections for additional access patterns
- keep primary rows and secondary projections in sync in the same transaction
- keep transactions small and focused
- update dev reset/cleanup flows whenever a new projection is added
Recent production debugging confirmed why this matters: a broad contact-summary path that scanned too much durable state was fine locally but unstable when deployed. The fix was not a hosting workaround; it was a better query-shaped schema and narrower reads.
Use this for fast iteration right now:
- For backend-focused local checks, run
turbo.serveHttpLocal - For full simulator ready/transmit scenario runs, run
turbo.serveLocal - Use the printed named URL or the LAN equivalent, for example:
http://localhost:8081/s/turbohttp://localhost:8090/s/turbohttp://192.168.1.161:8081/s/turbo
- Set
TurboBackendBaseURLin Turbo/Info.plist to that base URL - Rebuild and reinstall the app
Important current split:
turbo.serveHttpLocalis the reliable local path for backend-only and route-level checks, and is HTTP-onlyturbo.serveLocalis the websocket-capable path to use for simulatorrequest_accept_ready/ transmit scenario verificationturbo.deployremains the intended production/cloud path
Operational reminders:
Turbo/Info.plistTurboBackendBaseURLshould behttp://localhost:8081/s/turbofor local HTTP route checks,http://localhost:8090/s/turbofor local websocket-backed simulator scenario work,http://<your-mac-lan-ip>:8081/s/turbofor a physical device against local HTTP, andhttps://beepbeep.tofor the deployed backend.- If no interactive
ucmprocess is already using the local codebase, usejust deploy. - If you are already working inside a live
ucmsession,just deploycan block on the codebase lock; in that case runturbo.deployfrom that existing MCP/UCM session instead. - If you changed backend behavior in the local Unison codebase, that change will not be live on
https://beepbeep.tountilturbo.deployhas actually run. - Dev user seeding is no longer automatic on app launch. If you want the canonical dev handles on a fresh backend, call
POST /v1/dev/seedexplicitly. - Use
just resetfor the authenticated runtime reset andjust reset-allfor a full backend cleanup.just seedrestores the canonical dev handles after a full reset. All default tohttps://beepbeep.toand can be overridden, e.g.just reset http://localhost:8081/s/turbo @avery. - Use
just clean-scratchto delete repo-rootscratch_*.ufiles when temporary route experiments or one-off migration drafts have drifted away from the actual codebase state. - Use
just route-probeafter changing backend route composition. It exercises the deployed HTTP surface end to end, including the routes most likely to regress when Unison route order changes:- dev reset/seed
- diagnostics upload and latest-read routes
- auth and device bootstrap
- contact summaries
- invite subroutes (
accept,decline,cancel) - websocket registration held open during route assertions that depend on live connectivity
- channel state/readiness/transmit routes
ptt-push-targetduring an actual active transmit
- Treat
scripts/route_probe.pyas part of the route contract. When you add, remove, rename, or reorder backend routes, update the probe in the same change and run it before trusting the deploy. Some routes only become valid inside a live websocket session or active transmit window, so the probe intentionally keeps those preconditions alive while it asserts them. - The simulator is valid for distributed control-plane verification because the app uses the simulator PTT shim there. Real Apple PushToTalk UI, backgrounding, lock-screen behavior, and audio still require physical devices.
- If local UI behavior looks impossible, restart
turbo.serveHttpLocaland clear backend runtime state viaPOST /v1/dev/reset-statebefore debugging further.
The backend now exposes GET /v1/config, and the app uses that to decide whether websocket signaling is supported by the current runtime.
Backend slice currently implemented in the Unison codebase:
GET /v1/configPOST /v1/auth/sessionPOST /v1/devices/registerPOST /v1/presence/heartbeatGET /v1/users/by-handle/:handleGET /v1/users/by-handle/:handle/presenceGET /v1/contacts/summaries/:deviceIdPOST /v1/invitesGET /v1/invites/incomingGET /v1/invites/outgoingPOST /v1/invites/:inviteId/acceptPOST /v1/invites/:inviteId/declinePOST /v1/invites/:inviteId/cancelPOST /v1/channels/directPOST /v1/channels/:channelId/joinPOST /v1/channels/:channelId/leaveGET /v1/channels/:channelId/state/:deviceIdPOST /v1/channels/:channelId/ephemeral-tokenPOST /v1/channels/:channelId/begin-transmitPOST /v1/channels/:channelId/end-transmitGET /v1/ws?deviceId=...authenticated websocket signaling endpoint
Current websocket contract:
- dev auth still uses the
x-turbo-user-handleheader - websocket handshake requires
deviceIdas a query parameter - websocket text frames are flat JSON
SignalEnvelopeobjects - signaling payloads are forwarded opaquely as text
- clients still send
toUserId - the backend ignores client
toDeviceIdand rewrites it from active channel presence
Current transmit contract:
POST /v1/channels/:channelId/begin-transmitnow only requires the senderdeviceId- the backend resolves the peer user and their active receiving device server-side
- requests fail if the target user has no active device joined to that channel
- the active-session snapshot also exposes backend-derived
canTransmit, so the client can treatreadyas “press-to-talk is actually possible now”