Skip to content
titah

Reference

Architecture

The design decisions, the reasoning behind each one, and the milestone order that produced them.

This page condenses DESIGN.md — the outcome of a design interview, and the single reference for milestones M0 through M6.

Identity and position

Titah is a complete coding agent, not a wrapper over other agents. It has its own LLM loop, tool set and session management. Calling external agents is one extra capability — the differentiator, not the foundation. If claude and opencode are not installed, Titah still works fully.

v1 delegation targets are claude and opencode, through a config-driven adapter registry, so adding a third agent means editing configuration rather than touching the core.

Technical foundation

AreaDecision
Language / runtimeTypeScript + Node (developed on v26; node:sqlite built in)
TopologyClient/server. The core is an HTTP server; titah spawns a local one and attaches the TUI
ProtocolHTTP REST + SSE. Client→server POST, server→client SSE
StreamingHybrid. text.delta per token; whole-message snapshots for tool state
StorageSQLite for metadata and messages; large blobs to tool-output/<id> on disk
Retentiontitah sessions prune --older-than 30d, from v1
ProvidersAI SDK v7. @ai-sdk/openai-compatible is first-class; Anthropic/OpenAI are presets
Credentials${env:VAR} plus auth.json at mode 0600, kept separate from the config
ConfigJSONC + $schema. Global merged with project
DistributionOne npm package, with internal boundaries: src/core, src/tui, src/server
PlatformLinux + macOS for v1; the code stays platform-neutral

Why client/server from day one

Splitting a monolith later is open-heart surgery. Starting from a server is cheap and immediately opens the door to attach, a web UI, and remote use. To the user it still feels like one process.

It also makes the core testable with curl — the difference between a ten-minute debug and a two-hour one.

Why SQLite plus blobs on disk

The opencode install on the development machine had a 580 MB opencode.db while every other directory held hundreds of kilobytes. Agent transcripts are dominated by tool output nobody reads again after the turn ends.

Titah separates blobs from the start and ships retention in v1. Adding retention after someone already has a 600 MB database is too late.

The agent loop

The v1 tool set was read, list, glob, grep, edit, write, bash, plus delegation. skill and task shipped after the design document was first written — and it said otherwise for longer than it should have.

Everything else — webfetch, websearch, MCP, LSP, background bash, patch, move, remove, memory, plan, question, diagnostics, exit_plan and github — has since shipped too. That is 23 built-in tools.

The plugin loader was deferred on purpose, for as long as shipping a plugin API before the core hardened would have locked in an API that was probably wrong. It has since shipped, with plugins named one by one in the config rather than discovered — see Plugins — and hooks alongside it for the rules that are one shell command long.

Permission model

Deny by default for write / edit / bash, with a per-session allowlist and an --auto flag for automation.

It grew into three dimensions — class, argument, situation — decided in one function that titah permission explain also calls, so what is explained cannot differ from what runs. And a sub-agent’s permission is derived from its parent and clamped by the stricter of the two, so a parent can never hand out more than it holds. See Permissions & undo.

The one gap the axes cannot close is that an approved bash command runs with your full rights. sandbox.bash closes it at the process layer instead — off by default, and failing closed. See Sandbox.

Because the topology is client/server, permission travels across processes: the server emits permission.request, the client shows a dialog, the answer comes back as a POST, the server continues.

  • No client connected → auto-deny and log the reason. Blocking hangs CI; auto-allowing is a silent security hole.
  • Esc → abort the whole turn, not just the running tool. A half abort leaves a tool_call without its tool_result, corrupting history and causing errors on the next request.

Snapshots

Before each batch of writes, a shadow git snapshot so undo is always possible. That is what makes “always allow write” feel safe.

Modes

Three ship built in: Plan (refuses every change), Build Manual (confirm each step, the default) and Build Auto (no confirmations).

Plan locks changes through permissions, not by removing tools. When the tools were removed entirely the model simply gave up without a word — measured, not assumed. With an explicit refusal it receives a reason it can pass on. The safety is identical: permission is checked before execution.

Context management

Compaction runs automatically, between turns and mid-turn, bounded by the model’s declared contextWindow. An undeclared window means compaction is off for that model — never guessed, because a wrong number is more dangerous than no number.

Old tool output is pruned before anything is summarised, and task results are exempt from that pruning: re-running one costs a whole nested turn, unlike re-reading a file. The summariser’s own prompt is bounded too, and an oversized transcript is summarised in chunks rather than silently truncated by the provider.

Titah tracks execution state but has no intent state — the model has nowhere to record a plan that survives compaction beyond the plan tool. That gap has a written design.

Delegation

The adapter interface is deliberately shaped like ACP (prompt, sessionUpdate, requestPermission, cancel), so adding an ACP transport later means writing one new adapter rather than rebuilding the core.

Control is explicit, by the user — @claude please review this. No LLM-driven delegation in v1: a model deciding for itself could call Claude Code repeatedly within one turn and blow up cost without anyone noticing.

The adapter contract:

  1. Same working directory. An isolated worktree sounds safer but makes the answer irrelevant to the work actually in progress.
  2. Read-only by default. External agents answer; changes go through Titah’s own permission model.
  3. Only the final answer plus metadata enters context. The transcript goes to tool-output/ and is referenced by path.
  4. Session continuity through a persistent id mapping.

Consensus fans one prompt out in parallel and marks disagreement. It violates the “no concurrency” stance, but the concurrency is flat and bounded by the number of agents rather than recursive — and that distinction is what makes it safe.

Interface

An Ink TUI with opencode’s default keybindings (ctrl+x leader), on the alternate screen buffer so the terminal underneath is restored on exit.

The keybinding table lives at opencode.ai/tui.json — 184 actions. Titah implements the relevant subset with identical keys, with one deliberate deviation: tool_details, which opencode leaves unbound, is bound to <leader>d, because a collapsible tool block with no key to collapse it is a feature nobody can find.

Startup detects environment variables, probes local endpoints, and only then asks. The default model must be explicit in the config; if none is chosen, a picker — never a guess.

Verification

Thorough unit tests for the parts that fail silently: edit string matching and its non-unique cases, bash timeout and cancel, the permission engine, and delegation adapters against a stubbed external CLI. That last one matters, because tests that burn tokens never get run.

npm test runs 1,242 tests and makes no live LLM call.

Three to five end-to-end scenarios against a small fixture repo, run manually before a release rather than in CI. Jumping to a full eval before the core is stable means debugging the harness instead of the product.

Milestones

MilestoneDefinition of done
M0Repo, config loader + $schema, auth.json 0600, provider layertitah --version works; one real LLM call succeeds
M1Headless core: HTTP+SSE server, SQLite sessions, read toolscurl -X POST a prompt → SSE streams an answer that genuinely read a file
M2Write tools + permissions + snapshotsAgent fixes a bug in a fixture repo; undo restores it exactly; permission denied with no client attached
M3Ink TUI: attach, chat view, collapsible tool blocks, opencode keysA full interactive session; Esc cancels without corrupting history
M4Delegation: claude + opencode adapters, session mapping@claude explain module X lands in context; a second @claude resumes
M5Consensus + custom agents + commands + skills/consensus works; Tab switches agents; custom commands run
M6Onboarding wizard, retention prune, docs, Apache-2.0 npm releaseClean machine → installed → first session without touching a config editor

v1 is done when M0–M6 are complete and Titah has been used to build Titah for a full week before tagging v1.0.0.

M0–M6 are complete. 0.2.0 closed six gaps measured against opencode 1.18.4 and Claude Code 2.1.233 rather than guessed — structured output, cost and limits, hooks, background turns, a web client, and the bash sandbox. Later releases added tracking, session sync and the extension system; 0.5.0 the prompt cursor, open-by-default tool blocks and sub-agent transcript pages; 0.6.0 side panels as stacks of foldable boxes; 0.7.0 an extension version check tied to the API contract rather than to the product version. See the changelog.

Why the TUI is not first

A TUI on top of an unstable core means every core bug shows up as a render bug, and you debug two layers at once. A headless server can be tested with curl.

Delegation is deliberately not pulled earlier either, despite being the most interesting feature: without a mature tool loop there is no way to tell “the Claude adapter is broken” from “my tool loop is broken”. M4 is the earliest point where @claude can be judged honestly.

The most honest exit point is M2. If the core still feels fragile after M2, stop and fix it rather than moving on to the TUI. The commonly underestimated milestone is not M3 but M2 — permissions plus snapshot and undo.