Skip to content

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 _BackendErrordone("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-disk Journal (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.type directly; the journal uses dataclasses.asdict for 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 @work cancellation is a Python CancelledError; an MCP disconnect is a Python exception; a context-window overflow is a Python ContextOverflowError.
  • Single-loop cancellationEsc cancels the worker, the in-flight tool's finally block 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 kin would 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 runs kin -p headlessly 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 remote kin via 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 is ssh + 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.