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

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.

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.

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.

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:

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