# 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…

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](/docs/kin/internals/).

<!-- SOURCE: README.md, CLAUDE.md, src/kin/harness/events.py, src/kin/harness/loop.py, src/kin/harness/model_routes.py, src/kin/harness/media_routing.py, src/kin/harness/session/, src/kin/tui/turn_lifecycle.py, src/kin/tui/app.py -->

## 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 ++escape++: 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 backend is selected through an optional named model-route catalog. The MCA,
vision inspection, Rig, task children, workflow children, and specific agent
profiles can each bind to a different provider preset/model while the loop and
tool contracts stay wire-agnostic. A catalog snapshot owns prepared backend
prototypes plus global/per-route stream leases; each resolved session gets
isolated mutable backend state. Route permits surround only model I/O, never
tool execution, so nested delegation cannot deadlock on a parent-held lease.

Vision is an auxiliary isolated call, not a second agent loop: it receives one
question and image payloads with no root history/system/tools, then returns an
untrusted observation. Canonical history keeps image bytes; a text-only MCA
gets a temporary wire projection with deterministic media refs and can inspect
them through the same route later.

## 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

```mermaid
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/AGENTS.md`](https://github.com/kinra-ai/kin/blob/dev/src/kin/harness/AGENTS.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`](https://github.com/kinra-ai/kin/blob/dev/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 `Future`s 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 cancellation** — ++escape++ 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](/docs/kin/guide/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: **++escape++ 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](/docs/kin/internals/). For what each tool does, see [Tools](/docs/kin/guide/tools/); for the conceptual model behind the permission gate in the diagram above, see [Permissions](/docs/kin/concepts/permissions/); for the day-to-day keys and cycling, see [Modes & permissions](/docs/kin/guide/modes-and-permissions/).
