Architecture¶
A mental model of how kin is put together: a native Python agent harness with the terminal UI as one client on top of it. This page is conceptual — for the event-by-event contract and the deep internals, see Internals & Contributing.
Harness + TUI¶
kin is two layers in one process.
The harness (src/kin/harness/) is the engine. It runs the agent loop, talks to the model, executes tools, enforces permissions and modes, and persists sessions. It has no terminal and no UI code — it is a library you could drive from any client.
The TUI (src/kin/tui/) is the Textual terminal interface. It is one client of the harness: it renders what the harness emits, collects your input, and drives the modal prompts (approvals, questions, plan review). Nothing about the agent's behaviour lives in the UI.
There is no separate kin subprocess and no wire protocol between the two. Historically this repo was a thin front-end that read a Go binary's NDJSON stream over a pipe; that is gone. The harness is native Python and runs in-process alongside the UI.
The in-process loop¶
A turn is the familiar agent loop: the model produces text and tool calls, the harness runs the tools, feeds the results back, and repeats until the model stops. Permission decisions (mode, the shell allowlist, your session approvals) gate each tool call along the way.
That loop runs as a cancellable Textual @work worker, on Textual's own event loop — not on a background thread. Because it is a worker, you can interrupt a turn at any point with Esc: the worker is cancelled, in-flight tools clean up, and the UI returns to an idle prompt. A queued message you typed mid-turn is flushed when the turn finishes.
The event seam¶
The harness and the UI meet at a single seam: the harness emits a stream of typed events (streamed text, reasoning, tool calls and results, todos, mode and model changes, the blocking approval and question prompts, lifecycle markers), and the UI renders each one. The vocabulary of those events — every type and field — is defined in src/kin/harness/events.py, which is the source of truth for the contract. Keeping the seam explicit is what lets the same harness back a different client later without rethinking its API.
How a turn flows¶
sequenceDiagram
actor You
participant Composer as Composer<br/>(TextArea)
participant App as KinApp<br/>(_run_turn @work)
participant Loop as harness.loop<br/>(run_turn)
participant Backend as Backend<br/>(Anthropic / OpenAI-compat / Fake)
participant Tools as Tools<br/>(registry)
participant Mode as Permission gate<br/>(modes + permissions)
You->>Composer: type + Enter
Composer->>App: _send() — post KinEvent(send)
App->>App: enqueue worker (group="agent_turn")
App->>Loop: session.run_turn(...)
Loop->>Backend: stream(model, messages, tools)
Backend-->>Loop: chunks (text / reasoning / tool_call)
Loop->>Mode: _decision_for(tool, args)
Mode-->>Loop: ALLOW / ASK / DENY / SANDBOX
alt ASK
Loop->>App: emit approval_request (correlation_id)
App->>You: ApprovalModal (push_screen_wait)
You->>App: once / always / deny
App->>Loop: resolve_approval(correlation_id, outcome)
end
Loop->>Tools: tool.run(args, ctx) [if ALLOW or post-approval]
Tools-->>Loop: ToolResult (text / multimodal / error)
Loop->>Backend: continue turn (append tool result)
Backend-->>Loop: more chunks or stop
Loop->>App: emit done(stop)
App->>Composer: restore focus, flush queued
Single Python process, single event loop, single event vocabulary. The
only blocking round-trip (approval_request / question / plan_ready)
parks on an in-process Future keyed by correlation id — see
src/kin/harness/CLAUDE.md's
session/ row (the blocking machinery lives in session/roundtrip.py)
for the details.
Retry posture — root vs subagent¶
loop._run_model retries a clean (pre-output) transient backend error
for the parent; the parent never re-streams into the live UI. Subagent
children have a different posture: their prose buffers into a
ForwardingEmit (the child is not rendered live), so a child CAN safely
retry through a mid-stream error — the buffer is trimmed via the
stream_retry event and the next attempt's prose replaces the dedup'd
tail. The gate keys on a per-session headless_retry flag (spawn_child
sets it True on the child; the root stays False): retry when
(sess.headless_retry or not (had_text or had_reasoning)) and
_is_transient(exc) and attempt < _MAX_RETRIES. On exhausted retries the
existing _BackendError → done("error") path runs; with the
subagent-retention wave the child parks idle and the parent gets a
structured partial it can resume. See
docs/decisions/0061-headless-retry-posture.md
for the design.
Compared to other agent harnesses¶
kin is not the only shape an "agent harness with a terminal UI" can
take. The interesting axis is where the seam between the model-loop
engine and the surface lives:
| Harness | Where the loop runs | Where the UI runs | IPC seam |
|---|---|---|---|
| kin (this repo) | Same Python process as the UI | Same Python process | Typed Event dataclasses, posted via Textual's post_message; blocking round-trips on in-process Futures keyed by correlation id |
| Claude Code | Subprocess (Node.js) driven by Anthropic's own agent SDK | The CLI itself — same binary, in-process | stdio pipe — the SDK reads NDJSON from the subprocess's stdout. Resumable via the subprocess's --resume |
| Gemini CLI | Subprocess (Node.js) — Gemini's agent runtime | The CLI itself | stdio pipe — NDJSON over the subprocess's stdout. The CLI spawns the runtime as a child process and consumes its JSON events |
| Codex CLI | Subprocess (Rust) — OpenAI's agent runtime | The CLI itself | stdio pipe — same shape as Claude Code and Gemini. Resumable via the subprocess's session id |
| Aider / Continue.dev | IDE extension host (VS Code / JetBrains) | The IDE | LSP-style — JSON-RPC over a stdio socket; the agent lives in the extension, the chat panel is just an LSP client |
| OpenHands / Devin | A remote service (the agent is a web app) | A browser tab | HTTPS + WebSocket — the agent is a backend service, the chat is a thin client |
The in-process shape (kin) buys you:
- No subprocess lifecycle — no
--resume, no orphan reaping, no cross-process state migration. The harness rehydrates from the on-diskJournal(a JSONL append-log) on every launch. - No JSON serialization between the engine and the UI — events
are Python objects. The render mixin dispatches on
event.typedirectly; the journal usesdataclasses.asdictfor the on-disk side only. - No IPC failure modes — there is no half-written pipe, no
malformed JSON recovery path, no "the subprocess crashed, what do
we render" state machine. A Textual
@workcancellation is a PythonCancelledError; an MCP disconnect is a Python exception; a context-window overflow is a PythonContextOverflowError. - Single-loop cancellation — Esc cancels the worker, the
in-flight tool's
finallyblock runs, the UI returns to idle, and the modal closes. No "did the subprocess see the SIGINT" race.
It costs you:
- The UI and the harness must use the same Python. A Textual rewrite in another language would have to re-implement the loop, the permission gate, and the journal format. (The events seam is explicit so this is a tractable rewrite, not a port — but it isn't free.)
- No remote-client story today. A different machine talking to a
live, interactive
kinwould need either a wire client (re-introducing the subprocess + NDJSON shape the pivot explicitly removed) or a WebSocket bridge — neither exists yet, and neither is scheduled. The Outpost is not that bridge: it runskin -pheadlessly on a schedule (or via the v1 machine door) and reaches you back over the dashboard + Matrix — a different kind of work, not remote interactive access to a running session. (An earlier Outpost shape did proxy a live browser terminal to a remotekinvia ttyd; retired in the middle-way plan's Step 3, 2026-07-06,docs/decisions/0033-outpost-automation-layer.md— the escape hatch for a real interactive shell isssh+docker exec, an operator path, not a product surface.)
If you've used Claude Code, Codex, or Gemini CLI before, the
practical difference you'll feel in kin is: Esc always
works (the worker is cancelled mid-tool, the subprocess is killed
in its own process group), the journal is a real file you can
cat (JSONL append-log, replayable across providers), and the
permission gate is a property of the kind, not the tool (you can
add a tool without touching the gate; you add a kind only if the
tool's risk class is new).
Where the deep internals live¶
This page stops at the mental model. The working notes for the harness — the loop, the backends, the permission gate, the session journal, the blocking round-trip — live in the repo's internal documentation, indexed from Internals & Contributing. For what each tool does, see Tools; for the conceptual model behind the permission gate in the diagram above, see Permissions; for the day-to-day keys and cycling, see Modes & permissions.