Developer Docs
Terminull runs AI coding agents on your own machine and drives them from your phone. A small bridge on the desktop wraps each agent in a pluggable adapter that normalizes its output into a uniform block stream the app renders. This page documents how to write your own adapter — the part of Terminull that’s meant to be open and extended — and how pairing, re-pairing, and sharing a machine differ.
Writing a custom adapter
An adapter wraps one agent CLI or server and translates its output into a normalized block stream — streaming text, thinking, tool calls, plan cards, diffs — so every agent feels identical in the app. 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.
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. |
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.
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. |
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.
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.
Pairing, re-pairing & sharing a machine
Every machine running Terminull has a permanent identity — a beacon id plus a public key — and shows a terminull://pair QR that carries it. Two different QRs exist, and what a scan does depends on which QR it is and who scans it. That distinction is the whole of this section: one QR hands over the machine, the other lends it out.
Pairing — linking a machine the first time
On macOS the setup wizard shows the QR on its last page, and the menu bar offers it any time under Add device by QR. On a headless Linux box the terminull service prints the pairing URL as text (a terminal QR is unreadable over SSH), which you can paste into the app or drag onto the Mac window.
Scanning it from the app claims the machine to your account — you become its owner. The app registers the beacon id and public key; the machine then polls once for its permanent auth token and comes online. The QR carries only the beacon id, the machine's public key, an ephemeral key-exchange key, its home relay address, and a one-time claim code — never your code, sessions, or credentials.
Getting the QR on a headless machine
A freshly installed service prints a pairing QR to its own output the first time it starts unpaired, and stops printing it once a device has claimed it. To get one at any later moment, ask the running service:
terminull-admin qr # print a QR (needs the service running)
terminull-admin qr --url # just the pairing link, on stdout
The QR is drawn on the service's output, not in the terminal you typed the command into — your terminal gets the link back instead. Under systemd that output is the journal, so render it there:
journalctl --user -u terminull --no-pager -o cat -n 40
-o cat is not optional. Without it, journald prefixes every line with a timestamp and unit name — which shifts each row of the code sideways, so it will not scan no matter how it looks at a glance.
Over SSH the link is usually easier than the picture. terminull-admin qr --url prints a bare URL you can paste into the app — or pipe it into your own renderer, which avoids the journal entirely: terminull-admin qr --url | qrencode -t ANSI.
Two things that commonly trip people up: the installer puts the command inside the service's own environment, so if your shell can't find it use the full path ~/.local/share/terminull/venv/bin/terminull-admin; and the command talks to the running service over a local socket, so “terminull is not running” means exactly that — start it with systemctl --user start terminull and try again. Treat whatever it prints as an owner QR, with all the caution described above.
Re-pairing — scanning again from the same account
Scanning the owner QR again from an account that already owns the machine is a re-claim, and it is safe to repeat. It refreshes the stored public key and name, and re-opens the token window. Nothing is lost — your sessions live on the machine itself, not on the phone, so they are all still there afterwards.
Re-pair when you add a second device of your own (an iPad alongside your iPhone), reinstall the app, or reset the machine's identity with a purge-uninstall — the last case regenerates the key, which is exactly what the re-claim writes back.
The owner QR is a secret. If a different account scans it, that is not a join — it is an ownership transfer. The server records it as pending and it takes effect the next time the machine polls, moving the machine out of your account. Holding a claim code proves physical access to the machine, which is why it is treated as authority over it. To let someone else in without giving up ownership, use the share QR below.
Sharing — letting someone else use your machine
In the app, open Account, find the machine, and tap the QR icon beside it. The machine mints a fresh code valid for two minutes, registers it with the server, and displays a share QR with a live countdown. (Share is unavailable for LAN-only machines, which have no account behind them.)
When a different account scans it, they join as a member: the machine appears in their list, they receive push notifications, and they can run sessions on it. Ownership does not change, and the machine's auth token is untouched. Members who have already joined can re-scan any time without you opening a new window.
The expiry is enforced by the server, not just displayed. A scan with no matching code is refused with "this machine isn't being shared right now", and one past the deadline with "this share code has expired — ask the owner for a new QR". So a screenshot of an old share QR is worthless, which is the point: sharing is a deliberate, momentary act rather than a permanent grant.
Which QR am I looking at?
| Owner QR | Share QR | |
|---|---|---|
| Where to find it | Setup wizard, or menu bar → Add device by QR | App → Account → the QR icon next to a machine |
| How long it lasts | Until the machine's identity is reset | Two minutes, with a countdown |
| Your own account scans it | Re-pairs — safe to repeat | Nothing new; you already have it |
| Someone else's account scans it | Takes ownership of the machine | Joins as a member; you stay the owner |
| Safe to screenshot? | No — treat it like a password | Harmless once the two minutes are up |
LAN-only pairing
A machine configured with pairing_mode = "lan" pairs entirely on your local network: its QR is flagged LAN-only, the app skips the account claim, and no server is involved at any point. The machine is then reachable only from the same network, and push notifications and cloud AI assists are unavailable. It also cannot be shared — sharing is a server-mediated concept, and there is no server in this mode.
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.