← All docs
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.
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: [email protected].