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
| Area | Decision |
|---|---|
| Language / runtime | TypeScript + Node (developed on v26; node:sqlite built in) |
| Topology | Client/server. The core is an HTTP server; titah spawns a local one and attaches the TUI |
| Protocol | HTTP REST + SSE. Client→server POST, server→client SSE |
| Streaming | Hybrid. text.delta per token; whole-message snapshots for tool state |
| Storage | SQLite for metadata and messages; large blobs to tool-output/<id> on disk |
| Retention | titah sessions prune --older-than 30d, from v1 |
| Providers | AI 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 |
| Config | JSONC + $schema. Global merged with project |
| Distribution | One npm package, with internal boundaries: src/core, src/tui, src/server |
| Platform | Linux + 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 atool_callwithout itstool_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:
- Same working directory. An isolated worktree sounds safer but makes the answer irrelevant to the work actually in progress.
- Read-only by default. External agents answer; changes go through Titah’s own permission model.
- Only the final answer plus metadata enters context. The transcript goes to
tool-output/and is referenced by path. - 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
| Milestone | Definition of done | |
|---|---|---|
| M0 | Repo, config loader + $schema, auth.json 0600, provider layer | titah --version works; one real LLM call succeeds |
| M1 | Headless core: HTTP+SSE server, SQLite sessions, read tools | curl -X POST a prompt → SSE streams an answer that genuinely read a file |
| M2 | Write tools + permissions + snapshots | Agent fixes a bug in a fixture repo; undo restores it exactly; permission denied with no client attached |
| M3 | Ink TUI: attach, chat view, collapsible tool blocks, opencode keys | A full interactive session; Esc cancels without corrupting history |
| M4 | Delegation: claude + opencode adapters, session mapping | @claude explain module X lands in context; a second @claude resumes |
| M5 | Consensus + custom agents + commands + skills | /consensus works; Tab switches agents; custom commands run |
| M6 | Onboarding wizard, retention prune, docs, Apache-2.0 npm release | Clean 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.