Developer Docs
Terminull runs AI coding agents on your own machine and drives them from your phone. A small bridge on the desktop speaks a uniform block protocol to the app over a relay, and a pluggable adapter wraps each agent. This page documents both: the wire protocol between the app and the bridge, and how to add your own adapter.
Contents
1 · The API ↔ bridge protocol
There are two channels between the app and your machine. The relay carries the live, end-to-end-encrypted session stream (blocks in, control messages out). The HTTP API handles everything that isn't a live session: pairing, account/device registry, push notifications, and opt-in cloud assists. The relay never sees plaintext; the API never sees your code.
1.1 · Topology
iOS app ──ws──▶ VPS relay ──ws──▶ bridge (your Mac/Linux) ──▶ Claude Code / Codex / …
│ │ │
│ sig verify, history, blocks,
│ resume tokens, session state,
│ push hold-queue adapters
│
└──https──▶ VPS API (pairing, devices, push, cloud assists)
The relay holds no block history — history lives in the bridge. It verifies the bridge's signature, issues JWT resume tokens, and feeds the push hold-queue, but it cannot decrypt what passes through. The relay is single-instance (its registry and queues are in process memory), so a bridge and its clients must land on the same process.
1.2 · The block stream
All agent output is a stream of JSON objects called blocks. The bridge produces them, the relay pipes them, the app decodes and renders them. Every block has a type and a ts (Unix seconds, float). The adapter's job is to normalize whatever its agent emits into these blocks — so every agent feels identical in the app.
Core block types
| Type | Shape (abridged) | Meaning |
|---|---|---|
session_start | {session_id, agent} | First block of a session. |
agent_info | {name, version, tools[], slash_commands[], cap_*} | Capabilities & tools. Drives which UI the app shows. |
turn_start | {} | Marks the start of an agent turn. |
text | {text, done, id} | Assistant text. text is the full accumulated string (not a delta); replace in place by id. done=true ends the run. |
thinking | {thinking, done, collapsed, label} | Reasoning / chain-of-thought. Same streaming semantics as text. |
tool_call | {id, name, args, status, label, subject, preview} | status: running → done/error. Paired to a result by id. |
tool_result | {tool_call_id, content, exit_code, is_error} | Full tool output. Over 100 lines → tool_result_large with a fetch id. |
permission_request | {id, action, risk, details} | Run pauses until the user approves/denies by id. |
input_request | {id, prompt, input_type, options[]} | Ask the user a question (text/choice/confirm). |
todo | {id, items[]} | Live multi-step plan card; re-sent whole each update, upserted by stable id. |
file_changes | {files[], working_dir} | Emitted at turn end when editing tools touched files. |
session_end | {reason, cost_usd, input_tokens, …} | Last block of every turn. Carries cost/token totals. |
There are more (progress, bg_task, diff, session_summary, error, terminal/STT blocks…) — the full reference lives in docs/block-protocol.md in the repo. The six an adapter must get right are listed under the contract.
Streaming contract
For text and thinking: emit done=false chunks as the model generates, each carrying the full text so far under a stable id, then one done=true at the end. The app replaces (never appends) when it sees a repeated id. Code fences are not split at the bridge — the app splits prose / code / tables client-side.
1.3 · Transport & encryption
Blocks ride the relay WebSocket as signed, encrypted envelopes. Only a plaintext routing hint is visible to the relay:
{
"type": "block",
"session_id": "<sid>",
"block_type": "<plaintext routing hint>", // relay routes on this only
"enc": "<base64(nonce12 + AES-GCM ciphertext+tag16)>",
"sig": "<Ed25519 signature; relay verifies, then strips>",
"replay": false, // true for history backfill
"server_ts": 1234567890.0 // original ts on replay, now on live
}
The session key is HKDF(bridge_priv × ios_pub, session_id) (X25519 ECDH → HKDF), and the same key encrypts every block and STT chunk in the session. The app's X25519 public key (ios_epk) rides both session_request and session_resume so the bridge can re-derive the key after a restart. Full detail: docs/encryption.md.
1.4 · Relay session lifecycle
- Bridge sends
register(with itsresumable_session_ids). The relay seeds Postgres and issues JWT resume tokens to connected clients. - App connects → relay sends
relay_status(bridge_online,agents,resume_tokens). - App sends
session_request(new) orsession_resume(reconnect, withsince_ts). The relay verifies the JWT and forwards to the bridge. - Bridge replays buffered blocks since
since_tsas live blocks, thenreplay_done; older history follows asreplay=trueblocks. - App flushes history to the chat on
replay_done.
1.5 · App → bridge control messages
The reverse direction is a set of typed control messages the bridge dispatches by an explicit type switch (anything unrecognized is ignored). A few of the common ones:
| Message | Purpose |
|---|---|
session_request / session_resume | Open or reattach a session. |
message | Send a user prompt into the session (queued if a turn is mid-flight on a non-streaming-input adapter). |
respond_permission / input_response | Answer a permission_request / input_request. |
set_interactivity | Steer how often the agent asks questions (autonomous/balanced/interactive). |
set_features | Sync allow-listed feature toggles (summaries, cloud assists, live activity). |
schedule | CRUD a recurring prompt bound to a session. |
stt_chunk | Encrypted mic PCM for bridge-side speech-to-text. |
A message must be dispatched in both transports — relay_client.py (relay path) and local_server.py (LAN/Bonjour path) — since the bridge is reachable directly on the local network too.
1.6 · The HTTP API surface
Distinct from the live session stream. The bridge and app call these over HTTPS; the API holds account/device state and the secrets that must live server-side (the APNs key, cloud-LLM keys). It never receives session content.
| Area | Used by | What |
|---|---|---|
/auth, /bridges, /devices | both | Email OTP / magic-link sign-in, device pairing (15-min claim window), bridge & device registry. Tokens stored SHA-256-hashed. |
/bridge/version | bridge | Self-update feed (proxies GitHub Releases → wheel / Mac app zip / screen-helper per arch). |
/api/ai/* | bridge | Opt-in cloud LLM tier for summaries, titles, STT correction (GPU Ollama → OpenRouter → Haiku). Metered to the user's quota; gated on consent. |
/api/push/* | bridge | APNs delivery (alert / background / Live Activity). Body is non-sensitive; an encrypted payload rides along. |
/ws/stt | app | Cloud STT proxy → external GPU ASR box (or Groq Whisper fallback). Authenticates & quota-meters; holds no model. |
2 · Writing a custom adapter
An adapter wraps one agent CLI or server and translates its output into the block stream above. One adapter instance equals one interactive session with one agent. Built-in adapters live in terminull_bridge/adapters/ (Claude Code, Codex, Cursor, Gemini, Kilo, OpenCode, Pi, Hermes, Droid); your own can be dropped in without touching the core.
2.1 · The BaseAdapter contract
Subclass BaseAdapter and implement four coroutines. The contract is enforced at import — a subclass that skips a required declaration raises TypeError immediately, so an adapter can't silently inherit a no-op.
| Method | Returns | Responsibility |
|---|---|---|
start(session_id) | AgentInfoBlock | Spawn/connect the agent; return its capabilities via make_agent_info(). |
send(text, attachments=None) | — | Feed a user message into the running session. |
stream() | AsyncIterator[Block] | Yield normalized blocks as the agent produces them. |
stop() | — | Cleanly shut the session down. |
Every turn your stream() must end with a SessionEndBlock, and tool calls must emit a running then a done/error ToolCallBlock (plus a paired ToolResultBlock). Two optional surfaces are explicit opt-out — you must set each flag to True or False:
| Flag | If True, override | For |
|---|---|---|
provides_models | list_models() | The new-session model picker. |
provides_usage | get_usage() | Cost/token stats for /status. |
2.2 · A minimal adapter
import asyncio, uuid
from terminull_bridge.adapters.base import BaseAdapter
from terminull_bridge.blocks import TextBlock, SessionEndBlock
class EchoAdapter(BaseAdapter):
# ── detection identity ──
agent_id = "echo"
display_name = "Echo"
cli_binary = "echo" # default detect() probes `echo --version` on PATH
# ── capabilities (single source of truth) ──
cap_thinking = False
cap_permissions = False
cap_interrupt = False
# ── optional surfaces: explicit opt-out required ──
provides_models = False
provides_usage = False
def __init__(self, cwd=None, model=None, **kw):
self._cwd = cwd
self._queue = asyncio.Queue()
async def start(self, session_id):
self._sid = session_id
return self.make_agent_info(name="Echo", version="1.0")
async def send(self, text, attachments=None):
await self._queue.put(text)
async def stream(self):
text = await self._queue.get()
# stream the reply, then close the turn
yield TextBlock(text=f"You said: {text}", done=True, id=str(uuid.uuid4()))
yield SessionEndBlock(reason="completed")
async def stop(self):
pass
make_agent_info() reads your cap_* attributes and prepends the universal bridge slash-commands automatically — build the info block through it rather than hand-rolling capabilities. Your __init__ is signature-filtered: the bridge offers cwd, model, binary, resume_session_id, on_session_id (call it with your agent's own session id so a bridge restart can resume this conversation), and effort — but only passes the ones you actually declare, so a minimal def __init__(self, cwd=None) still works. Declare what you need.
2.3 · Capabilities
Declared as class attributes; surfaced in the AgentInfoBlock so the app shows or hides the matching UI.
| Attribute | Effect in the app |
|---|---|
cap_images | Image picker enabled in the composer. |
cap_documents | Non-image file attachments accepted. |
cap_thinking | Thinking blocks will render. |
cap_permissions | Permission cards will appear. |
cap_subagents | Sub-agent tool calls render as nested groups. |
cap_interrupt | Stop button active mid-turn. |
cap_input_during_turn | Composer stays enabled during generation (otherwise messages queue). |
cap_terminal | May open a terminal on the device. |
2.4 · Detection & drop-in install
Detection lives on the adapter as a classmethod detect() → DetectedAgent | None — the same path for built-in and external adapters. The default detect() requires agent_id and, if cli_binary is set, probes shutil.which() + --version. Override it for HTTP/config-based probes.
An external adapter is just a .py module dropped into ~/.terminull/ext_adapters/ defining a BaseAdapter subclass with at least agent_id + display_name:
# ~/.terminull/ext_adapters/echo.py
from terminull_bridge.adapters.base import BaseAdapter
class EchoAdapter(BaseAdapter):
agent_id = "echo"; display_name = "Echo"; cli_binary = "echo"
provides_models = False; provides_usage = False
... # the four coroutines
The loader imports each module guarded — it runs the same contract check, rejects collisions with built-in ids, logs and skips anything malformed, and registers the valid ones. They're then detected and instantiated exactly like built-ins. No core edit, no restart of anything but the bridge.
Where the code lives. The built-in adapters ship as editable source at terminull_bridge/adapters/*.py — even in the published, bytecode-compiled bridge, that folder stays plain .py so you can read them as worked examples and copy one as a starting point. The full, copy-pasteable reference adapter — documenting every hook and the complete __init__ signature above — is examples/ext_adapter_example.py; drop a copy into ~/.terminull/ext_adapters/ and edit. The contract you're implementing is terminull_bridge/adapters/base.py.
2.5 · Lifecycle across a two-surface run
A session can be driven from two surfaces — the native agent CLI in a terminal and Terminull — over its lifetime. The rule is one writer, many observers: Terminull must never become a second concurrent writer into a conversation the terminal is actively driving. How an adapter participates depends on how it holds the agent between turns:
| Kind | Between turns | Examples |
|---|---|---|
| Per-turn / leaseless | Spawns a fresh process each turn in stream(), then exits — so the CLI is free to resume in between. | Codex, Cursor, Gemini, Droid, Pi |
| Persistent | Holds the agent (a long-lived subprocess or server) across turns — needs an explicit release before a terminal can take over. | Claude Code, OpenCode, Kilo |
To make handoff seamless, an adapter can implement a few optional class methods (all default to "not supported", so a basic adapter simply opts out of handoff):
| Hook | Role |
|---|---|
scan_external_sessions() | Discover sessions this agent started outside Terminull (read its on-disk transcripts) → they appear in the app's sidebar. |
is_cwd_externally_owned(cwd, sid) | Is a native process currently driving this dir/session? Gates the contention guard so Terminull parks instead of double-writing. |
make_transcript_mirror(cwd, sid) | Read-only tail of a terminal-owned session's transcript → mirrored into the app live, without writing. |
latest_sid_for_cwd(cwd) | Re-resolve the lineage head after a terminal hand-back (--resume may mint a new id). |
terminal_resume_command(sid) | Shell command that resumes the session in a terminal → powers the app's "Continue in Terminal" button. |
The flow, end to end: the app shows external CLI sessions via scan_external_sessions(); tapping one binds a Terminull session to it. If the terminal owns the dir, the bridge parks the session (queuing messages and showing a "detached observer" state) and streams a read-only mirror instead of writing. When the terminal goes idle, the bridge auto-attaches — re-resolving the lineage head, then draining the queue. In reverse, "Continue in Terminal" releases an idle live session and re-parks it as a mirror. The whole design, with the per-adapter coverage matrix, is in docs/session-handoff.md.
The bridge core is intended to be open. Source layout, the full block reference, and the handoff design live alongside this in the repo's docs/. Questions: hello@terminull.dev.