Companion to UX.md. This doc covers protocol, server internals, agent adapter shape, mobile app structure, and the experiment plan for the one open decision (how to drive agent CLIs).
| Layer | Choice |
|---|---|
| Desktop server | Node / TypeScript (single makit binary via pkg/bun) |
| Mobile app | Flutter (iOS first, Android free) |
| Wire protocol | WebSocket + JSON (one socket per device ↔ server) |
| Pairing | QR + Noise-IK handshake, libsodium keypair per device |
| Discovery | mDNS on LAN; overlay VPN (Tailscale/headscale) for remote |
| Agent adapter | Hybrid: PTY scrape v1, native SDK/JSONL per-agent v2 |
| Persistence | SQLite (server-side, single file in ~/.makit/) |
| Push | APNs / FCM via optional hosted relay (opt-in) |
┌────────────┐ WSS+JSON ┌──────────────────────────────┐
│ Phone │ ───────────────▶ │ makit server (Node/TS) │
│ (Flutter) │ ◀─────────────── │ │
└────────────┘ │ ┌────────────────────────┐ │
│ │ Session Manager │ │
┌────────────┐ │ │ ├─ Session (codex) │ │
│ Tablet │ ◀──── WSS ─────▶ │ │ ├─ Session (pi) │ │
└────────────┘ │ │ └─ Session (claude) │ │
│ └────────────────────────┘ │
┌────────────┐ │ ┌─ Agent Adapter (PTY/SDK)──┐
│ Desktop │ ◀── local IPC ─▶ │ ┌─ Project Index ──────────┐│
│ tmux pane │ │ ┌─ Device Registry ────────┐│
└────────────┘ │ ┌─ Event Log (SQLite) ─────┐│
└──────────────────────────────┘
Every connected client holds one WebSocket to the server. The server is the single source of truth for sessions, transcripts, and agent process lifecycle.
WebSocket frames are JSON objects. Every message has:
type Envelope = {
v: 1; // protocol version
id: string; // ULID, client-generated for requests
t: MessageType; // discriminator
// ...type-specific fields
};| Family | Direction | Examples |
|---|---|---|
hello |
C→S, S→C | Handshake, capability negotiation, resume cursor |
sub |
C→S | Subscribe/unsubscribe to session(s) or project(s) |
event |
S→C | Append-only stream of session events (see §3) |
cmd |
C→S | User intent: send-message, approve, cancel, spawn, kill |
ack / err |
S→C | Response to a cmd with its id |
presence |
S→C | Which devices are currently subscribed to a session |
ping/pong |
both | Keepalive, RTT measurement |
- Client persists last
event.seqper session locally. - On reconnect:
hello { resumeFrom: { [sessionId]: lastSeq } }. - Server streams missed events from the SQLite event log, then live.
- Queued outbound
cmds are flushed in order with their originalids; the server dedupes byid(idempotent commands).
- TLS terminated at the server (self-signed cert pinned at pair time).
- First frame after socket open is
hello { deviceId, sig }wheresigis a Noise-IK / Ed25519 challenge response over a server-issued nonce. - Device pubkey must be in
device_registry. Unknown device → close with4001 unpaired.
type Project = {
id: string;
name: string;
path: string; // absolute cwd on server host
pinned: boolean;
lastActivityAt: number;
};
type Session = {
id: string;
projectId: string;
agent: "codex" | "pi" | "claude" | "shell" | string;
title: string; // derived from first user message
status: "idle" | "running" | "awaiting-input"
| "awaiting-approval" | "error" | "exited";
approvalPolicy: "yolo" | "ask-on-risky" | "ask-always";
createdAt: number;
userId: string; // single user in v1, reserved for team mode
};
type Event = {
seq: number; // monotonic per session
sessionId: string;
ts: number;
kind: EventKind;
payload: unknown; // discriminated by kind
};
type EventKind =
| "user.message" // text the user sent
| "agent.message" // assistant prose
| "agent.thinking" // optional reasoning trace
| "tool.call.start" // { name, args, callId }
| "tool.call.delta" // streaming stdout/stderr chunk
| "tool.call.end" // { callId, exitCode, summary, artifacts }
| "approval.request" // { callId, risk, preview }
| "approval.decision" // { callId, decision, by: deviceId }
| "session.status" // status transition
| "session.error";Events are append-only and authoritative. The phone's chat UI is a pure
projection of the event stream. Tool cards are derived by folding
tool.call.start + deltas + end into a single card.
src/
├── server.ts // WebSocket server, TLS, hello/auth
├── protocol/ // Zod schemas for every envelope
├── sessions/
│ ├── SessionManager.ts // lifecycle, status, fan-out
│ ├── Session.ts // one session = one agent process + event log
│ └── policies.ts // approval policy enforcement
├── adapters/
│ ├── AgentAdapter.ts // interface (see §5)
│ ├── PtyAdapter.ts // generic PTY scrape
│ ├── CodexAdapter.ts // codex JSONL (v2)
│ ├── PiAdapter.ts // pi SDK / JSONL (v2)
│ └── ClaudeAdapter.ts // claude-code SDK (v2)
├── projects/
│ └── ProjectIndex.ts // discover repos, watch cwd, pin/hide
├── devices/
│ └── DeviceRegistry.ts // paired devices, revocation
├── pairing/
│ ├── qr.ts // render QR with pairing token
│ └── handshake.ts // Noise-IK
├── transport/
│ ├── mdns.ts // advertise `_makit._tcp.local`
│ └── relay.ts // optional hosted relay for push + NAT fallback
├── storage/
│ └── db.ts // SQLite (better-sqlite3); migrations
└── cli.ts // `makit serve`, `makit pair`, `makit sessions`, ...
Key invariants:
- Session has one writer (the agent process), N readers (devices).
Fan-out is in-memory pub/sub keyed by
sessionId. - All events are written to SQLite before being fanned out. Reconnect/resume always reads from the log, never from memory.
- Server never blocks on a client. Slow client → drop subscription, force resume on reconnect.
The single seam that decides whether we can support a new agent CLI:
export interface AgentAdapter {
readonly agent: string; // "codex" | "pi" | ...
start(opts: SpawnOpts): Promise<void>;
send(input: UserInput): Promise<void>; // user message / keystrokes
// SPEC-35: inject into the RUNNING turn instead of starting a new one.
// `true` = delivered (the adapter echoed it; the Session must not requeue)
// `false` = refused for any reason (no steer primitive, no active turn, a
// stale `expectedTurnId`, a non-steerable turn kind) → queue it
// Exception: an attachment that cannot be materialised returns `true` after
// emitting `session.error` — the prompt is undeliverable, so requeueing it
// would fail forever (see the SPEC-35 plan's deviation note).
steer(input: UserInput): Promise<boolean>;
approve(callId: string, decision: ApprovalDecision): Promise<void>;
cancel(): Promise<void>;
kill(signal?: NodeJS.Signals): Promise<void>;
// Push-out: adapter emits normalized Events into the session log.
on(event: "event", listener: (e: Omit<Event, "seq" | "sessionId">) => void): this;
on(event: "exit", listener: (code: number | null) => void): this;
}Four implementations ship today (server/src/adapters/); the adapter
interface is identical so the rest of the server doesn't care which one a
session uses:
- Drives
pi --mode rpcas a long-running JSON-RPC subprocess (one per Session, started lazily on first send, killed on Session shutdown). - Gets pi's slash commands, skills, extensions, prompt templates, mid-turn steering, abort, model switching, and compaction — all native, no ANSI guessing.
- Bridges a real
piTUI already running in a terminal-multiplexer pane (e.g. herdr) to a makit session, so the phone sees it as normal chat. - Read: tails the pi session
.jsonlthe TUI writes. Write: injects the phone's text into the pane via send-text + Enter. No second pi process is spawned for the chat stream, so there's no double-writer corruption.
- A session whose events are pushed in by the
makit-mirrorpi extension (loaded into the user's realpi, TUI or otherwise) rather than produced by a makit-spawned process.
- Deterministic fake (fixed-delay echo + markdown sample reply) used by the app's E2E suite; no real agent involved.
A message submitted while a turn is in flight is never sent as an overlapping
request. Session.sendUserMessage decides:
- Steer —
adapter.steer(input)injects it into the running turn. Only codex implements it (turn/steer, with the announced turn id as theexpectedTurnIdprecondition). The contract takes aUserInput(text + optional attachments) and expects an active turn (expectedTurnId). Returnstrueif the message was steered,falseif steering failed (protocol rejection, precondition mismatch, or no active turn). Attachment materialization failures emitsession.errorand returntrueto prevent requeueing the unmaterializable prompt (see SPEC-35 PLAN deviation tracking). - Queue — anything the adapter cannot steer waits in an in-memory FIFO on the
Session and is delivered one message per
idletransition. Pending messages ride the sessions snapshot asSessionDTO.queued(never the event log, so a restart cannot replay a stale queue) and are cancellable viaqueue.cancel;cancel(stop) drops the whole queue.
A turn is only ever "in flight" because the agent said so — turn/started /
ACP's prompt lifecycle. codex's turn/start reply is deliberately ignored: sent
mid-turn it returns a turn id that is never announced or completed.
(The original PTY-vs-native Spike 0 in §10 predates World A/B/D and is historical — makit ships pi-only, so the PTY/Codex/Claude comparison there never shipped.)
lib/
├── main.dart
├── app/ // routing, theming, keyboard shortcuts
├── pairing/ // QR scan, mDNS browse, VPN check
├── transport/
│ ├── ws_client.dart // reconnect, resume, queue
│ └── protocol.dart // codegen-ish from shared JSON schema
├── store/ // Riverpod (or Bloc) state
│ ├── projects.dart
│ ├── sessions.dart
│ ├── events.dart // event-log projection per session
│ └── presence.dart
├── ui/
│ ├── home/ // Projects → Sessions list
│ ├── session/ // chat view + composer
│ ├── tool_card/ // collapsed card → fullscreen drilldown
│ ├── diff_viewer/
│ ├── composer/ // slash palette, @-mentions, voice
│ └── settings/
└── platform/
├── push.dart // APNs/FCM tokens
├── voice.dart // dictation
└── secure_storage.dart // device keypair
State is a pure projection of the event stream:
events[sessionId] → derived(messages, toolCards, status).
Same render path for live and replayed events.
- User runs
makit serveon desktop.- Server generates a long-term server keypair on first run (stored in
~/.makit/server.key). - Advertises
_makit._tcp.localvia mDNS with TXT recordfp=<server-fp>.
- Server generates a long-term server keypair on first run (stored in
- User taps "Pair device" in app → opens camera.
makit pair(or the server's status TUI) prints a QR encoding:makit://pair?host=<lan-ip-or-overlay-hostname>&port=<p>&fp=<server-fp>&t=<short-lived-token>- Phone parses QR, opens WSS to host:port, pins
fp, sendshello { pair: { token, devicePub } }. - Server verifies token (single-use, 5-min TTL), stores
devicePubindevice_registrywith a user-chosen label, responds with paired ack. - App stores
{ host, fp, devicePriv, serverPub }in secure storage. - Subsequent connects: Noise-IK handshake using stored keys; no token needed.
Remote access: same flow, but host resolves through Tailscale/overlay DNS.
The pair QR can be regenerated after the user joins the overlay so it carries
the overlay hostname instead of LAN IP.
- Adapter emits
tool.call.startwith arisk: "safe" | "risky" | "destructive"classification (PTY adapter uses a per-CLI rule table; native adapters use the agent's own classification when available). SessionManagerchecksapprovalPolicy:yolo: auto-approve all.ask-on-risky: auto-approvesafe, emitapproval.requestfor the rest.ask-always: emitapproval.requestfor every call.
- While awaiting: session status flips to
awaiting-approval, push fires. - First device to send
cmd: approvewins; decision is broadcast asapproval.decision. - Adapter is told to proceed or to inject a cancel into the agent.
- Server can run standalone (LAN/VPN only, no push) — fully functional.
- Optional hosted relay (
relay.makit.devor self-hosted) does two things:- Forwards APNs/FCM pushes (device registers token; server sends push intents to relay; relay holds Apple/Google creds).
- Acts as a NAT-traversal fallback for users who don't want Tailscale.
- Relay never sees plaintext: it only carries opaque envelopes; push payloads are minimal ("session X needs you") with the body fetched over WSS when the app opens.
Goal: decide whether v1 ships pure PTY, pure native-per-agent, or hybrid.
Setup: one throwaway branch, two adapters wired behind the same interface:
PtyAdapterdrivingcodexandclaude-codevia node-pty + xterm-headless.CodexAdapterdrivingcodexvia its JSONL event stream.
Tasks to run through both:
- Send a multi-turn prompt, observe message events.
- Trigger a file edit (tool call), observe diff fidelity in the card.
- Trigger a shell command with streaming output.
- Trigger an approval-required action.
- Cancel mid-tool-call.
- Crash the agent, observe recovery.
Success criteria for "PTY is enough for v1":
- Tool-call boundaries detected ≥95% reliably across both CLIs.
- Diff/file output reconstructable without ANSI artifacts.
- Approval gating injectable before the side effect (not after).
- < 50ms added latency vs raw CLI.
If PTY fails any of these for an agent, that agent gets a native adapter in v1. Other agents stay on PTY until they hurt.
Deliverable: docs/SPIKE-0.md with the matrix filled in and a
recommendation. Then we lock the v1 adapter set.
- Server:
bun build→ single binary,brew tap makit/tap && brew install makit. Alsonpx makit servefor quick try. - Mobile: TestFlight (iOS) + internal track (Android) during v0.
- Versioning: protocol
vis independent of app/server semver; server must supportvandv-1for graceful upgrades.
- M0 — Skeleton: WS server, pairing, one fake echo "agent", Flutter shell renders messages. No real agent yet.
- M1 — Spike 0: PTY vs native, pick adapter set.
- M2 — Real sessions: spawn codex/pi via chosen adapter, full event stream, approvals, mirror to desktop pane.
- M3 — Projects & multi-session: project index, home screen, presence.
- M4 — Resume/offline: event log, reconnect, queued commands.
- M5 — Notifications ✅: actionable lock-screen approvals (SPEC-08) + content-free APNs wake (SPEC-07). See NOTIFICATIONS.md and PUSH.md.
- M6 — Polish: slash palette, @-mentions, voice, diff viewer.
- Encryption-at-rest for the event log? (probably yes; SQLCipher.)
- How much of the desktop tmux pane content do we mirror back into the phone (just agent CLI output, or the whole pane including user shell history)?
- Web client: same WS protocol, but auth via short-lived JWT from a paired device? Or require its own pairing?
- Team mode: per-session ACL granularity (read / write / approve)?