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
    1. Topology
    2. The block stream
    3. Transport & encryption
    4. Relay session lifecycle
    5. App → bridge control messages
    6. The HTTP API surface
  2. Writing a custom adapter
    1. The BaseAdapter contract
    2. A minimal adapter
    3. Capabilities
    4. Detection & drop-in install
    5. Lifecycle across a two-surface run

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

TypeShape (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: runningdone/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

  1. Bridge sends register (with its resumable_session_ids). The relay seeds Postgres and issues JWT resume tokens to connected clients.
  2. App connects → relay sends relay_status (bridge_online, agents, resume_tokens).
  3. App sends session_request (new) or session_resume (reconnect, with since_ts). The relay verifies the JWT and forwards to the bridge.
  4. Bridge replays buffered blocks since since_ts as live blocks, then replay_done; older history follows as replay=true blocks.
  5. 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:

MessagePurpose
session_request / session_resumeOpen or reattach a session.
messageSend a user prompt into the session (queued if a turn is mid-flight on a non-streaming-input adapter).
respond_permission / input_responseAnswer a permission_request / input_request.
set_interactivitySteer how often the agent asks questions (autonomous/balanced/interactive).
set_featuresSync allow-listed feature toggles (summaries, cloud assists, live activity).
scheduleCRUD a recurring prompt bound to a session.
stt_chunkEncrypted 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.

AreaUsed byWhat
/auth, /bridges, /devicesbothEmail OTP / magic-link sign-in, device pairing (15-min claim window), bridge & device registry. Tokens stored SHA-256-hashed.
/bridge/versionbridgeSelf-update feed (proxies GitHub Releases → wheel / Mac app zip / screen-helper per arch).
/api/ai/*bridgeOpt-in cloud LLM tier for summaries, titles, STT correction (GPU Ollama → OpenRouter → Haiku). Metered to the user's quota; gated on consent.
/api/push/*bridgeAPNs delivery (alert / background / Live Activity). Body is non-sensitive; an encrypted payload rides along.
/ws/sttappCloud 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.

MethodReturnsResponsibility
start(session_id)AgentInfoBlockSpawn/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:

FlagIf True, overrideFor
provides_modelslist_models()The new-session model picker.
provides_usageget_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.

AttributeEffect in the app
cap_imagesImage picker enabled in the composer.
cap_documentsNon-image file attachments accepted.
cap_thinkingThinking blocks will render.
cap_permissionsPermission cards will appear.
cap_subagentsSub-agent tool calls render as nested groups.
cap_interruptStop button active mid-turn.
cap_input_during_turnComposer stays enabled during generation (otherwise messages queue).
cap_terminalMay 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:

KindBetween turnsExamples
Per-turn / leaselessSpawns a fresh process each turn in stream(), then exits — so the CLI is free to resume in between.Codex, Cursor, Gemini, Droid, Pi
PersistentHolds 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):

HookRole
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.