titah-code
v0.9.0
Published
Coding agent CLI that can delegate a question to other agent editors (claude, opencode, ...)
Downloads
1,592
Maintainers
Readme
Titah
A coding agent CLI that can call other agent editors to get answers.
Titah is a coding agent in its own right — it has its own LLM loop, tool set,
and session management. What sets it apart: it can delegate a question to
claude, opencode, or any other agent CLI installed on your machine, and
bring the answer back into the conversation. Including consensus mode: one
question fanned out to several agents at once, synthesised, with the
disagreements marked.
Status: 0.9.0, usable. All milestones M0–M6 are done. 0.2.0 closed six gaps measured against
opencode1.18.4 and Claude Code 2.1.233 rather than guessed: structured output, cost and limits, hooks, background turns, a web client, and a bash sandbox. 0.3.0–0.4.x added the extension system: side panels contributed by npm packages, a registry to find them, and a version check that refuses the ones that would not load. 0.5.0 was about moving around the terminal — a cursor that walks visual rows and words, tool blocks that open by default, and a full-page transcript for any sub-agent. 0.6.0 turns each side panel into a stack: as many extensions per side as you list, each in its own box, foldable to a single line. 0.7.0 separated the two version numbers that had been one, so a Titah minor release stops killing every installed extension. 0.8.0 lets a panel answer with text for the prompt, so what you select in a panel can become what you ask — the first time an extension contributes anything but pixels. Every new axis is off or absent by default.What remains before tagging
v1.0.0is not code: using Titah to build Titah for a full week. See DESIGN.md and CHANGELOG.md.
About the external agents
Titah does not redistribute, bundle, or bypass Claude Code, opencode, or any other agent. It only spawns the CLI you installed, with your own credentials, as an ordinary subprocess — exactly as if you had typed it in your terminal. If an agent is not installed, Titah reports it as unavailable and keeps working fully without it.
Requirements
- Node.js ≥ 22.6 (developed on v26)
- Linux or macOS (Windows is not supported in v1 — see DESIGN.md §2)
Getting started
npx titah-code init # detect a provider, write the config
npx titah-code # open the TUItitah init looks for keys in the environment (ANTHROPIC_API_KEY,
OPENAI_API_KEY, OPENROUTER_API_KEY), then probes local endpoints such as
Ollama and LM Studio — and only asks about what it genuinely cannot work out on
its own. Pass -y to take the first thing it finds without a dialog, which
suits scripts and Dockerfiles.
From source:
npm install
npm run build
node dist/cli.js doctor
node dist/cli.js # open the TUIThe TUI requires a build. Node runs
.tsdirectly but not.tsx— JSX is not stripped. Headless commands (run,serve,doctor, …) still run from source withnode src/cli.ts; the TUI only runs fromdist/.
titah doctor checks your environment, config, credentials, and which external
agents were detected. Add --probe to also test network reachability per
provider.
Configuration
Global config lives at ~/.config/titah/titah.json, merged with the project's
./titah.json. The format is JSONC — comments and trailing commas are fine.
Point $schema at config.schema.json for editor autocomplete.
{
"$schema": "./node_modules/titah/config.schema.json",
"model": "ollama/qwen3.5:27b",
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"options": { "baseURL": "http://localhost:11434/v1" },
"models": { "qwen3.5:27b": { "name": "Qwen3.5 27B" } }
}
}
}Any provider that speaks OpenAI-compatible is a first-class citizen —
self-hosted endpoints, routers, Ollama, LM Studio. Anthropic is available
through "npm": "@ai-sdk/anthropic".
Credentials
Do not put API keys in the config as plaintext. There are two paths:
# 1. a separate auth.json, automatically mode 0600
titah auth set 9router # the key is read from stdin
# 2. an environment variable, referenced from the config
# "apiKey": "${env:VAR_NAME}"Resolution order: provider.options.apiKey in the config → auth.json → a
conventional environment variable (ANTHROPIC_API_KEY, or
TITAH_<PROVIDER>_API_KEY).
An unset ${env:...} does not fail config loading — the key is dropped and
recorded, and only complained about if the model you actually use needs it. A
config may name five providers while you use only one.
Account (optional)
Titah does not need an account. The agent loop, the tools, delegation, consensus and sub-agents never ask who you are; your provider credentials are yours and your sessions are on your own disk. An account adds the web dashboard, and nothing else.
That is why the first run on a new machine offers two answers, and treats the second as a real one:
Welcome to Titah 0.1.0.
1. Sign in to your Titah account
2. Continue without an accountWhichever you pick is recorded, and you are never asked again — a question that keeps asking is a question that stops being read. A non-interactive run (a script, a Dockerfile, CI) is never asked and never has a choice recorded on its behalf, and a failed sign-in does not stop the session: Titah says why and carries on without an account.
titah login # prints a code; confirm it in any browser
titah whoami # asks the server whether the session is still valid
titah logout # revokes the token, then deletes it locally/login, /logout and /account do the same from inside the TUI, and Esc
cancels a sign-in that is waiting.
Why a code, not a redirect
Titah uses the Device Authorization Grant (RFC 8628) rather than the loopback
redirect that browser-first tools use. A coding agent is very often run over SSH,
in a container, or on a machine with no browser — and there a redirect to
127.0.0.1 lands on the loopback interface of the wrong machine, dead-ending
with no useful error. A code typed into any browser behaves identically
everywhere: one code path, no port to forward, no callback URL. The cost is that
you type eight characters.
Pointing at your own server
titah-web is ordinary Django and plenty of people will run their own. The server
is taken from TITAH_ACCOUNT_SERVER, then account.server in the config, then
the default — https://titah.dev:
{ "account": { "server": "https://titah.internal" } }The environment wins over the config so one shell can be pointed elsewhere without editing a file other people share. The token records which server issued it, so changing servers asks you to sign in again rather than silently reusing a token that means nothing there.
The token lives in ~/.local/share/titah/account.json at mode 0600 —
deliberately not in auth.json. That file holds your provider keys; this one
holds your identity, and mixing them would let titah auth remove anthropic take
your login with it. Neither is ever written into titah.json.
titah doctor reports the account, and --probe additionally verifies it
against the server — which is the only way to notice a token you revoked from the
dashboard.
Tracking
Signed in, Titah reports metadata only about each folder you work in, so the
dashboard has something to show: project name, language, git remote and branch,
and the same session/token/cost figures titah stats prints. No file contents,
no transcripts, no tool output, ever.
{ "tracking": { "enabled": true, "exclude": [], "git": true } }Four ways to turn it off, widest first:
| | Scope |
|---|---|
| titah logout | Nothing is sent without an account, whatever the config says |
| "enabled": false in the global config | Every project |
| "enabled": false in a project's ./titah.json | That project — and it travels with the repo |
| "exclude": ["~/clients/*"] | Named paths, for folders you will not put a file in |
exclude uses the same glob dialect as permission.allowlist — one dialect
in the whole config, not two — where * already crosses /, so one star covers
a whole subtree. ~ is expanded first.
"git": false drops the remote URL and branch. A remote URL frequently names a
client, which leaks more than the folder name does.
It is sent after a turn ends, at most once every five minutes per project,
and never blocks or fails a turn. The debounce lives in SQLite rather than in
memory because titah run is one process per turn — an in-memory window would
never fire there, which is exactly where a script runs it a hundred times.
It never says anything, succeeding or failing. titah run --output-format
json promises nothing human touches stdout, and a "heartbeat sent" line breaks
the caller's JSON.parse in a way the caller cannot diagnose. What there is
instead: one line per attempt in ~/.config/titah/tracking.log, and a
Tracking section in titah doctor saying which of the four switches is in
effect and when it last sent.
Cost only appears for models that declare a
price— see Cost. Without one the tokens are still counted and the cost reads 0.
Session sync
Off by default, and it needs two switches: tracking.sync here and the
per-project toggle in the dashboard.
{ "tracking": { "sync": true } }Two, because a dashboard toggle is remote policy. Anyone who reaches your
account can turn it on; they cannot edit a file on your machine. It is the same
reason external_directory is evaluated when the config loads and never at
runtime — a structural boundary must not become a runtime question.
Defaulting off is also deliberate, and unlike enabled above. The argument that
makes the heartbeat default on — signing in is the opt-in — does not stretch
this far. Signing in is consent to be counted, not consent to be read.
What goes up: your prompts, the model's answers, and the order of tool names that ran between them. What never does:
| | Why |
|---|---|
| Tool output | That is where secrets live — read .env, bash env, grep -r password. Filtering them automatically is not an option: a secret filter you can rely on does not exist, and one that catches AKIA… while missing an internal token is worse than none, because it manufactures confidence |
| Tool arguments | Subtler, and still no. edit carries oldString and newString, which is code; write carries a whole file. ▸ edit says something was edited; the arguments say what it said |
| Reasoning | Not for its size. Titah separates it from text because text is the answer and this is the road to it — the longest and least re-read material is the worst candidate to leave the machine |
Capped at 32 KB per message — the same number Titah already uses for tool output — and 512 KB per transcript. Over that, the oldest messages are dropped and a marker says how many. A silently truncated transcript looks complete, and somebody will draw a conclusion from a conversation that was not all of it.
Only sessions that get a turn after you switch it on are uploaded. There is no backfill: seventy sessions arriving at once because somebody flipped one toggle is a surprise in the wrong direction.
Commands
| Command | Purpose |
|---|---|
| titah init [-y] | First-time setup (auto-detect + wizard) |
| titah login | Sign in to your Titah account through the browser |
| titah logout | Sign out and revoke this machine's token |
| titah whoami | Who this machine is signed in as, verified against the server |
| titah | Open the interactive TUI (spawns a local server) |
| titah attach <url> | Open the TUI against a running server |
| titah run "<prompt>" | Run one turn and stream the answer |
| titah undo | Revert every change from the last turn |
| titah serve [--port N] | Headless HTTP + SSE server |
| titah sessions list [--all] | Sessions for this folder; --all for every project |
| titah sessions prune --older-than 30d | Delete old sessions plus orphaned blobs and snapshots |
| titah config path | show | File locations / merged config (redacted) |
| titah export [-o file] | Portable config bundle, credentials left out and listed |
| titah import <file> [-y] | Preview what a bundle changes; -y applies it |
| titah plugin list | Load the configured plugins and report what each provides |
| titah extension list | Load the configured side panels and report what each provides |
| titah extension install <pkg> | remove <pkg> | Download a panel and record it in config, or drop it |
| titah extension update [<pkg>] | Move the lockfile to the newest version whose engines.titah accepts this Titah — and name the newer ones it had to skip |
| titah upgrade | Check npm for a newer Titah and print how to install it — never installs by itself |
| titah web [--port N] | Start the server and open the browser client |
| titah stats [--since 7d] [--all] | Tokens and cost so far, by model and by day |
| titah hooks list | Shell hooks from config, and which tools each matches |
| titah bg list | logs <id> [-f] | stop <id> | Background turns started with run --bg |
| titah mcp list | Configured MCP servers, their transport, and sign-in state |
| titah mcp login <server> | logout <server> | OAuth against a remote MCP server |
| titah permission explain <kind> [argument] | What a call would be allowed to do, and which rule decides |
| titah auth list | set <p> | remove <p> | Manage credentials in auth.json (0600) |
| titah models | List configured models |
| titah doctor [--probe] | Check environment, config, external agents |
Frequently used options: -m/--model <provider/model>, -a/--agent <name>,
-s/--session <id>, --auto, --bg, --output-format, --json-schema,
--since <age>, --all, --older-than <age>, --port, --hostname,
--server <url>, --no-browser, -y, --probe, -o <file>, -f.
titah --help prints the same list.
In-session commands are separate — /tim <task> fans out to the super agents
in externalAgent, split by specialist; see
Command palette for the rest.
titah runruns the core in process, not over HTTP. It uses the exact same agent loop and storage, just skipping the network layer.titah(the TUI) spawns a server and attaches to it.
The TUI
titah with no arguments spawns a local server on a random port and attaches
the TUI to it. It feels like a single process, but the architecture has been
client/server from the start — which is why titah attach http://host:4096
works with no extra code.
It takes over the alternate screen buffer, the way nvim does: whatever was in your terminal stays hidden while Titah runs and is restored intact on exit.
The opening screen centres the prompt beneath the ASCII logo. After your first prompt the layout switches to an info panel on top, history in the middle, and the prompt pinned to the bottom, with a spinner above it while work runs.
Markdown
Assistant answers are rendered as markdown: headings, bold, italics, inline code, fenced code blocks, lists, quotes, and rules.
Hasil
• Item satu
• Item dua
┌ ts
const a = 1
└Two deliberate choices:
- Your own prompts are never rendered. What you typed is not markdown, and rendering it would hide characters you meant to write — for instance when asking about markdown syntax itself.
- Underscores are never emphasis. Coding-agent answers are full of
snake_case_nameand__init__; treating_as emphasis turns identifiers into wrong-looking italics.*italic*and**bold**still work.
Code blocks are not parsed as markdown either — * and _ inside code are
code, not emphasis.
The header panel carries the Titah mark beside the session information. On short terminals (under 26 rows) the mark is dropped — conversation history is worth more than decoration when space is scarce.
Command palette
Ctrl+P opens the palette without typing anything — the same key as opencode:
│ Commands · 15 · ↑↓ move · tab/enter select · esc close │
│ › /model Switch the model for this session │
│ /agent Switch the agent for this session │
│ /skill Insert a skill into your prompt │
│ /consensus Fan one question out to every agent │Fifteen are built in — /model, /agent, /session, /new, /skill,
/consensus, /tim, /compact, /agents, /skills, /commands, /login,
/logout, /account, /exit — and your own from command are listed after
them. Undo is not among them: it is Ctrl+X U, or titah undo.
Commands that need no arguments run immediately when you select them, and
those with sub-options drill into a second menu. Picking /model opens the list
of models from your config; picking one switches the model for this session
right away:
│ Switch model · 6 · ↑↓ move · tab/enter select · esc close │
│ › 9router/ocode nemotron-3-ultra (via ocode) │
│ 9router/gapis gemini-3-flash (via gapis) │
│ ollama/qwen3.5:27b Qwen3.5 27B │Commands that take arguments (/consensus, your own custom commands) are
inserted into the prompt instead, so you can type the argument.
/session lists your saved sessions newest first, with the current one marked.
Sessions belong to the folder you opened Titah in. A conversation is nearly
always tied to the code being worked on, so a list mixing every project on the
machine buries the one you actually want. titah sessions list follows the same
rule; pass --all to see every project, which also prints each session's path.
The match is on the exact directory, so ~/proj and ~/proj/src are separate
projects. Paths are normalised first, so a trailing slash or a relative path finds
the same sessions — losing your history to a stray / would be indefensible.
Picking one loads its history and points the stream at it; /new starts a fresh
session in the same directory. Both clear whatever you had half-typed — carrying
a draft into a different conversation is never what you meant.
Popups while typing
| Trigger | Shows |
|---|---|
| @ | External agents, internal agents, and files in the working directory |
| / | Every command, built-in and custom |
↑↓ moves, Tab or Enter selects, Esc closes. Triggers only fire at the
start of a word, so [email protected] and /etc/hosts never open a popup nobody
asked for.
Keys
Keybindings follow opencode's defaults, with ctrl+x as the leader:
| Key | Action |
|---|---|
| Enter | Send the prompt |
| Ctrl+J | Newline inside the prompt |
| Esc | Cancel the running turn |
| Ctrl+P | Open the command palette |
| ↑ / ↓ | Recall the previous / next prompt — on a draft that spans more than one row, these move the cursor between rows first, wrapped rows included |
| Ctrl+← / Ctrl+→ | Move the cursor a word at a time. Alt+← / Alt+→ and Alt+B / Alt+F do the same, because no single sequence reaches every terminal |
| Shift+↑ / Shift+↓ | Scroll the history one line |
| Tab / Shift+Tab | Switch agent forwards / backwards (or select inside a popup) |
| Ctrl+R / Ctrl+X R | Cycle effort: default → low → medium → high → default |
| Ctrl+X N | New session |
| Ctrl+X L | List sessions |
| Ctrl+X D | Collapse every tool block, and again to bring them back — works mid-turn, and a running tool shows its arguments |
| End / Ctrl+X B | Jump to the newest message |
| Ctrl+X M | Toggle mouse capture — turn it off to select and copy text |
| Ctrl+X ↓ | Toggle the sub-agent panel — it owns the keyboard while open: ↑/↓ select, Enter (or a click) opens that sub-agent's transcript, x x cancels it, Esc closes |
| Ctrl+X S | Open a sub-agent's transcript — every sub-agent this session has run, including turns already finished |
| Ctrl+X ← / → | Toggle the left / right side panel — below panel.floor columns of history they close themselves rather than squeeze the conversation |
| Ctrl+X E | Refresh both side panels. They also refresh when you send a prompt, when a turn ends, and when a panel opens |
| Ctrl+X F | Cycle the keyboard through the side panels — Esc returns it without closing anything |
| Ctrl+X Z | Fold or unfold the focused panel. Clicking its title row does the same |
| + / - / = | While a panel has the keyboard: widen, narrow, or reset it to the width in config. Widening stops at panel.floor |
| Click a panel row | Reaches the extension's onClick and moves the keyboard to that panel |
| Ctrl+X X | Extensions — search the registry, install with Enter |
| Click a tool line | Collapse/expand just that block |
| Mouse wheel | Scroll the history |
| Ctrl+X U | Undo the last turn's changes |
| Ctrl+X ? | Short help |
| Ctrl+D | Quit |
| Ctrl+C | Clear the input; twice on an empty input quits |
| Ctrl+Alt+U / Ctrl+Alt+D | Scroll half a page |
| PageUp / PageDown | Scroll a page |
| Ctrl+G / Home | Jump to the start |
| y / a / n | Answer a permission dialog: once / always / deny |
Inside the prompt the usual readline keys work: Ctrl+A / Ctrl+E for the
start and end of the current line, Ctrl+B / Ctrl+F to move a character,
Ctrl+U to delete to the start of the line.
There is deliberately no <leader>q. Four ways out — Ctrl+C twice,
Ctrl+D, <leader>q, /exit — meant three had to be remembered without ever
being used, and each was a key that could be hit by accident. What is left:
Ctrl+D for fingers, /exit for people who type, Ctrl+C twice for reflex.
More than one extension per side. A side is a stack: extensions listed for
the same side are drawn as separate boxes, top to bottom in config order. Height
runs out faster than width does, so a box can be folded to a single title line —
with Ctrl+X Z, by clicking its title, or by setting "collapsed": true. A
folded panel is not rendered at all, so folding a git panel also stops it running
git status on every refresh. When a side runs out of rows, the bottom box folds
itself and says so.
Reading what a sub-agent did. A sub-agent runs in its own session, so its
transcript is a real one: Enter on a row of the sub-agent panel — or Ctrl+X S
for any sub-agent this session has run — opens it as a full page, following along
live while the work is still going. The page is read-only and Esc returns; a
prompt box there would promise messages you could send into a session the
coordinator is already driving.
Super agents are the exception. They run a CLI outside Titah and only send their final answer back, so their page says so rather than drawing an empty screen.
Selecting text to copy. While Titah tracks the mouse, the terminal stops
using clicks to highlight text — the two cannot both be on. Ctrl+X M turns
tracking off so normal selection works, and the footer keeps saying ✂ mouse off
until you turn it back on. Most terminals also let you hold Shift while
dragging to bypass tracking without toggling anything.
All of it is configurable through keybinds; "none" disables an action.
{ "keybinds": { "session_interrupt": "ctrl+g", "tool_details": "none" } }Two honest notes about "1:1 with opencode":
- opencode defines 184 actions; Titah implements the subset that matters here, with identical keys.
tool_detailsis left unbound by opencode. Titah binds it toCtrl+X D, because a collapsible tool block with no key to collapse it is a feature nobody can find.- Tool blocks open by default — the work you just asked for should not sit behind one more keypress. Reasoning blocks stay folded, because they are usually far longer than the answer they lead to; both use the same mechanism, only the default differs.
input_newline in opencode is shift+return,ctrl+return,alt+return,ctrl+j.
Most terminals cannot tell Shift+Enter from Enter, so the one that actually
works is Ctrl+J.
HTTP API
titah serve exposes a server that holds the entire core. The TUI is just one
of its clients — and curl is another, which is what makes the core testable
without a TUI at all.
| Route | Purpose |
|---|---|
| GET / | The web client (HTML) for browsers, the /health payload (JSON) for everything else |
| GET /health | Status, version, pid — always JSON |
| GET /event?session=<id> | SSE stream of all events (optional per-session filter) |
| POST /session | Create a session ({"directory": "...", "title": "..."}) |
| GET /session[?directory=…] | List sessions; without the filter, every session on the machine |
| GET /session/:id | DELETE /session/:id | Read or delete one session |
| GET /session/:id/status | {"running": bool} — is this session working right now |
| POST /session/:id/discard | Delete it only if it has no messages |
| GET /session/:id/message | Message history |
| POST /session/:id/message | Send a prompt ({"text", "auto", "model", "agent", "effort"}) |
| POST /session/:id/abort | Cancel the running turn |
| GET /session/:id/permission | Pending permission requests |
| POST /session/:id/permission/:permID | Answer ({"decision": "once"\|"always"\|"reject"}) |
| GET /session/:id/question | Pending questions from the question tool |
| POST /session/:id/question/:qID | Answer ({"answer": "..."}; an empty string means "no answer") |
| POST /session/:id/undo | Revert the last turn's changes |
/ splits on Accept rather than on a new path: both it and /health answer
the same question — what is here — and the honest answer differs by who is
asking. /health stays JSON unconditionally so a health checker never guesses.
discard is not a plain DELETE because the caller is throwing away a session
it merely believes is unused, so the emptiness check runs on the server.
Send Accept: text/event-stream on POST .../message to receive the answer as
a stream:
SID=$(curl -s -X POST localhost:4096/session -d "{\"directory\":\"$PWD\"}" | jq -r .id)
curl -N -X POST "localhost:4096/session/$SID/message" \
-H 'accept: text/event-stream' \
-d '{"text":"How many .ts files are in src/? Use glob."}'Events follow a hybrid model: text.delta per token for assistant text, and
message.updated carrying a whole-message snapshot for every tool state
change. A client that renders tool status must track what it has already shown —
snapshots resend every part each time.
Tools
| Tool | Purpose | Permission |
|---|---|---|
| read | Read a text file with line numbers, supports offset/limit | — |
| list | Recursive directory listing, skipping node_modules/.git/dist/… | — |
| glob | Find files by pattern, newest first | — |
| grep | Search contents by regex, results as file:line: text | — |
| edit | Exact text replace; must be unique; fails hard on no match | edit |
| write | Write full file contents, creating parent directories | write |
| bash | Run a shell command with a timeout | bash |
| skill | Load a skill's full instructions on demand | — |
| task | Dispatch a sub-agent, one level deep | — |
| plan | Record a plan that survives compaction | — |
| webfetch | Fetch a URL; HTML is stripped to readable text | network |
| websearch | Search the web; backend set by search.backend | network |
| patch | Several edits to one file, all or nothing | edit |
| move | Move or rename; never overwrites | write |
| remove | Delete a file, or a directory with recursive | delete |
| bash_start | Start a long-running command in the background | bash |
| bash_output | Read what a background process printed since last look | — |
| bash_stop | Stop a background process and its whole group | — |
| diagnostics | Run diagnostics.command and return its output | bash |
| memory | Record a durable project fact, recalled into every request | — |
| question | Ask the user and wait for their answer | — |
| exit_plan | Offer to leave Plan mode, and switch if accepted | — |
| github | Run the gh CLI: PRs, issues, releases, runs, code search | network when it only reads, bash when it changes something |
| (from MCP) | Every tool an MCP server offers, as <server>_<tool> | mcp |
That is 23 built-in tools, plus whatever your MCP servers contribute.
All filesystem access is confined to the session working directory — paths that
escape it are refused. Tool output larger than 32 KB is written to
~/.local/share/titah/tool-output/, and only the head plus a pointer enters the
context.
Permissions have three dimensions, evaluated in one function
(src/core/decide.ts) that titah permission explain also calls — so what is
explained can never differ from what runs.
| Dimension | Question | Example |
|---|---|---|
| class | what kind of harm? | "bash": "ask" |
| argument | which call exactly? | "bash(git *)": "allow" |
| situation | what is happening around it? | repeated call → doom_loop |
Two combining rules, and they are the whole design:
- A rule-level
denyis absolute. Nothing can open it — that is how you write a wall:"network(*)": "deny". A class-leveldenyis a default deny: an explicitallowrule can narrow it. That is what makes "refuse everything except this" expressible at all, and it is the shape every firewall and IAM policy uses. Treating it as a wall instead made everyallowrule under a denied class silently dead — the same failure class as #12, where a written rule never fires. - Between
askandallow, the most specific pattern wins — measured in non-wildcard characters, not file order. Ties go toask: two equally specific rules that disagree are an ambiguous config, and guessing loose on ambiguity is how you get permissions nobody intended.
The situation dimension can only tighten: doom_loop never allows anything,
it only interrupts something already allowed.
titah permission explain bash "git push origin main"
titah permission explain network https://docs.python.org/3/external_directory widens the working-directory boundary, but only per path
named in permission.rules, and only at config load — never at runtime. That
boundary is a structural wall every file tool relies on; making it a runtime
question would trade a guarantee for a policy.
The web tools sit on their own network permission axis rather than borrowing
bash. They touch no files; what they risk is confidentiality — they are the
only tools that send repository content off the machine, and none of the older
axes says that. network: "deny" turns both off outright.
Private addresses are deliberately not blocked: checking your own dev server
on localhost is one of the things a coding agent is for. webfetch does refuse
every scheme except http and https — file: in particular, since it would
sidestep the working-directory confinement that every filesystem tool relies on.
websearch defaults to the ddg backend, which needs no API key and scrapes
HTML — so it can break without warning. titah doctor says so out loud. Set
search.backend to brave or tavily with search.apiKey for something
stable.
patch extends that promise to several edits at once: they apply in order, each
seeing the previous one's result, and any miss leaves the file byte-for-byte
untouched. A half-edited file is worse than an unedited one — it fails to compile
in a way that looks like nobody's fault.
move refuses to overwrite, and that is what keeps it on the write axis: if it
could overwrite, it would be able to destroy a file without ever passing through
delete, which would make that axis a fence with a gate beside it.
bash_start runs its command in its own process group, so bash_stop kills the
whole tree — a dev server that spawns children is the normal case, and killing
only the parent leaves the port held. Output is kept in a ring buffer, newest
kept, and the number of bytes dropped is reported rather than silently lost.
Background processes do not survive Titah exiting.
diagnostics runs only the command you declare in diagnostics.command. It
never guesses one: a wrongly guessed checker fails in ways that read far worse
than "not configured". A non-zero exit is a finding, not a tool failure —
otherwise "three type errors" would look exactly like "the checker is broken".
memory and plan differ on exactly one axis: how long the fact stays true.
plan is intent for the work in progress and dies with the session; memory is
a fact about the project that is still true tomorrow, in a different session.
Both are stored outside model_message, so compaction cannot reach either.
Memory is recalled eagerly — the whole store, every request — rather than retrieved. With a 32-fact cap that is cheaper than a retrieval step that can pick wrong, and when retrieval picks wrong the missing fact leaves no trace.
Plan mode explores; the file tools are what it cannot use. edit, write,
patch, move, remove, and MCP tools are refused. Everything for reading
works, and so does the shell — npm test, git log, find, whatever the
project actually uses.
The shell was briefly restricted to an allowlist of read-only commands. That is right on security and wrong on usefulness: a shell allowlist has to anticipate every tool worth running, which is a list that never finishes, and Plan mode could not run the project's own checks.
So the honest statement is narrower than "nothing changes": what Titah
enforces is that the file tools refuse. A shell can still write files, and the
prompt tells the model not to — redirection, sed -i, and git checkout over
someone's work are all named. That is trust, not enforcement, and the mode
description says so rather than promising a guarantee it no longer keeps.
It offers one through exit_plan. When the user asks for a change while in Plan
mode, the model calls it with the plan it would carry out, and the user gets a
choice that actually switches mode. Refusing an edit and then reporting the
refusal — or quietly writing a plan instead of doing what was asked — are both
worse than saying which mode you are in.
question is the only other tool that stops and waits for a human. With no client
connected it does not hang: it returns "nobody answered, continue with your best
assumption and say what you assumed", which is what headless and CI need.
MCP and language servers
Both speak JSON-RPC 2.0 over a subprocess's stdio, so they share one transport
(src/core/rpc.ts). They differ only in framing: LSP uses Content-Length
headers, MCP uses one JSON object per line.
"mcp": { "github": { "command": "mcp-server-github", "args": [] } },
"lsp": { "ts": { "command": "typescript-language-server",
"args": ["--stdio"], "extensions": [".ts", ".tsx"] } }MCP support is stdio only, tools only — that is what closes the gap, and
saying so is better than half-building everything. Its tools are namespaced
<server>_<tool>, because two servers both offering search is ordinary and
the second would otherwise silently replace the first. They sit on their own
mcp permission axis: an MCP server is code Titah did not write and cannot
classify, so forcing it onto edit or bash would ask the user to approve
something other than what happens. A server that fails to start loses its tools
and says why — it never brings the turn down with it.
Language servers give automatic diagnostics: after edit, patch, write,
or move, the diagnostics for that file are appended to the tool result, so the
model sees the error it just introduced without having to remember to look. Only
initialize/didOpen/publishDiagnostics are used — a full LSP client would
be an editor, and what is needed here is a checker. No diagnostics at all is
reported as unknown, deliberately distinct from an empty list, which means
checked and clean.
edit fails hard on purpose: if oldString is missing or appears more than
once, the tool refuses and writes nothing. Refusing is far better than
silently writing in the wrong place.
Rendering
Answers are rendered to lines, wrapped at render time — not handed to the terminal to wrap. That matters more than it sounds: the history is scrolled by slicing a list of lines, so a logical line that the terminal silently wraps into three screen rows makes the slice under-count, the content overflow its box, and the scroll position drift by one row per wrapped paragraph.
Wrapping is span-aware, so bold, inline code, and links survive it, and list items get a hanging indent — a continuation lines up under the text, not under the bullet. Code blocks are the exception: they are cut, not wrapped, since wrapping code at spaces changes its indentation, and indentation is part of what code means. Markdown tables are laid out as aligned columns.
Permissions & undo
Every tool that changes something goes through the permission engine. Checks, in order:
permission.<tool>from the active agent, then from the config —"deny"refuses,"allow"skips the dialogpermission.allowlistfrom the config, then the session allowlist built from "always" answers — except for a dialog raised by a sub-agent, whose "always" is scoped to the coordinator's turn instead (see Sub-agents)--automode- no client connected → auto-denied
- only then is the dialog shown
Point 4 is non-negotiable. Hanging while waiting for an answer would freeze the
agent in CI; auto-allowing would turn headless mode into a silent security hole.
For automation the path is --auto or an explicit allowlist.
{
"permission": {
"edit": "ask",
"write": "ask",
"bash": "ask",
"allowlist": ["git *", "npm test"]
}
}Before the first change of every turn, Titah takes a snapshot into a
shadow git repo under ~/.local/share/titah/snapshot/ — a separate git dir
whose work tree points at your project. Your own .git is never touched: no
stray commits, no disturbed staging area, no surprise stashes.
titah undo # revert the entire last turn
titah undo -s <id> # for a specific sessionOne snapshot per turn means one undo reverts the whole turn, not a single
tool. Files the agent created are deleted too — a half undo that leaves new
files behind is not an undo.
Modes: Plan, Build Manual, Build Auto
Titah ships three built-in modes. Tab switches between them (the same key as
opencode), or -a/--agent from the CLI.
| Mode | Id | Behaviour |
|---|---|---|
| Plan | plan | Drafts a plan only. Every attempt to change a file or run a command is refused |
| Build Manual | build | Does the work, but every change asks for your confirmation. The default |
| Build Auto | build-auto | Works to completion on its own, no confirmations |
titah -a plan # open the TUI straight into planning mode
titah run -a build-auto "fix the failing tests"Change the default with "defaultAgent": "plan" in the config.
Plan mode locks changes through permissions, not by removing the tools. The difference is visible: the model can still try, is refused with a clear reason, and passes that on to you —
⊘ create result.txt (1 lines) — Denied by agent "plan": write = "deny".
Refused — plan mode cannot change files. Switch to Build mode
(Tab in the TUI, or --agent build) to use write.When the tools were removed entirely, the model instead stopped without a single word. The safety is identical — permission is checked before execution, so nothing ever runs.
Build Auto still takes snapshots, so titah undo still reverts the whole
turn even though you were not watching it happen.
Custom agents
The three modes above are ordinary agents — you can override them by id, or add
your own. An agent is a prompt + tool filter + permission override + model
override behind a name. mode and delegate give it a second role — being
dispatched as a sub-agent by the coordinator's own model — covered next.
{
"defaultAgent": "explore",
"agent": {
"explore": {
"description": "Codebase explorer — read only",
"prompt": "Always verify with tools, never guess. Answer very briefly.",
"tools": { "write": false, "edit": false, "bash": false }
},
"qc": {
"description": "Run tests and report",
"model": "9router/gapis",
"permission": { "bash": "allow", "edit": "ask" },
"skills": ["team:project-analyzer"]
}
}
}Two ways to restrict an agent, both applied before anything runs:
toolsremoves a tool from the list the model can see. Tools not listed stay enabled — the list is an exception list, not an allowlist.permissionleaves the tool visible but refuses its use, overriding the globalpermission. Fields not mentioned inherit the global value.
Use permission when you want the model to know why it was refused; use tools
when you do not want it thinking about the tool at all.
steps caps how many tool-calling iterations one turn may take for this agent —
five for a scout, sixty for a refactor. Unset, the cap is 40, and declaring
limits.turnTokens lifts it to 200 — at that point steps have stopped being
what bounds the turn. When the cap is reached, the final iteration runs with no
tools at all, so the model has to report what it found rather than stopping
mid-air, and the stop is announced rather than silent.
effort (low | medium | high) sets how much closing analysis an answer
ends with, not how hard the model thinks. Unset means the model decides; ctrl+r
cycles it live.
skills loads those skills into this agent's system prompt in full, and
takes fully-qualified ids (namespace:name), exactly like skills.always —
see Skills. A bare name such as "project-analyzer" never resolves;
run /skills or titah doctor to see the ids you have and which configured
ids were not found.
Sub-agents
Titah's own model can run several of its own configured agents as sub-agents, in parallel, inside one turn — without leaving the process or spawning an external CLI.
{
"agent": {
"explore": {
"mode": "subagent",
"description": "Codebase explorer — read only",
"permission": { "edit": "deny", "write": "deny", "bash": "deny" }
},
"qc-developer": {
"mode": "all",
"permission": { "bash": "allow" }
}
}
}mode decides whether the coordinator's own model may hand an agent work:
only "subagent" and "all" are ever offered to the task tool; a
"primary" agent is refused outright if something tries to dispatch it
anyway. The default is "primary", not "all" — flipping that default
would have quietly handed every agent already in your config, including ones
with a wide-open permission, to the model the moment this feature shipped,
without you writing a single line asking for it. mode does not otherwise
change an agent: it is still selectable as your own top-level agent with Tab
or --agent, exactly as before sub-agents existed.
delegate routes a sub-agent through an external CLI listed in
externalAgent (see Delegation) instead of Titah's own loop —
the same engine @claude uses, reached from task//tim instead of a
mention. It is mutually exclusive with model: an agent has one engine,
and config setting both is rejected when it loads.
permission does nothing to a delegating agent. The external CLI runs
under its own policy and never asks Titah for anything, so Titah's edit /
write / bash settings are not applied to it, and no permission dialog will
appear on its behalf. Because Titah cannot know what that CLI will touch, a
delegating agent is always treated as a writer — see below — no matter what
its permission block says. If you want to restrain it, restrain it in that
CLI's own configuration.
Readers run together, writers take turns
Whether a sub-agent may run at the same time as others is read from its
permission, never from tools: an agent counts as a reader only when
edit, write, and bash are all explicitly "deny", and it does not
delegate. Everything else — an agent whose config never mentions
permission at all, and every agent with delegate set, whatever its
permission block says — is treated as a writer. bash counts as writing on purpose: an allowed shell can
run sed -i just as well as the edit tool can, and treating it as read-only
would open exactly the hole this rule exists to close.
Readers run with no limit on how many are concurrent. Writers are serialised
on a queue keyed by working directory, so two sub-agents editing the same
project never race over the same shadow-git snapshot — one waits its turn
while the panel below shows it "waiting for a turn". This is why /tim tells
the model not to order writers itself: Titah already does.
task and /tim
The coordinator hands work to a sub-agent with the task tool; several calls
in the same step run concurrently. The prompt asks the model for one call per
agent, but nothing enforces it: the same agent named twice in one step is two
sub-agents, and if it can write, the second waits for the first on the write
lock below. /tim <task> is not a
separate orchestration engine — it is an ordinary turn, with one extra
system-prompt section listing the current dispatchable roster and instructing
the model to split the work and dispatch it with task, doing any leftover
work itself rather than inventing an agent for it. The model does the actual
coordinating; Titah only tells it who is on the roster.
Running /tim with no dispatchable agent configured does not hang or guess —
it answers directly with what to add: an agent block with "mode":
"subagent" (or "all") in titah.json.
A sub-agent never gets the task tool itself, no matter its own mode —
dispatch depth is capped at exactly one level, so nothing can spawn a tree of
sub-agents that burns through your provider quota with no way to stop it.
A sub-agent inherits the coordinator's ceiling
A dispatched sub-agent's permission is computed from its parent, not from
config.permission, and then clamped by the stricter of parent and child on
every axis (narrower in src/core/permission.ts). One sentence: a parent
can never hand out more than it holds.
One rule differs from top level, deliberately, and in both directions:
"Always" from a sub-agent lasts the turn, not the session. Answering
ato a dialog raised by a sub-agent adds the pattern to an allowlist that is discarded when the coordinator's turn ends — unlike the same answer at top level, which lasts the whole session. And for that turn it covers every sub-agent, not only the one that asked: five agents doing the same job would otherwise ask the same question five times, and dialogs that repeat are dialogs that stop being read.
That ceiling is what makes Plan mode a real boundary. Under a plan
coordinator whose write and edit are "deny", a sub-agent that declares
nothing inherits the deny, and one that declares "write": "allow" is clamped
back down. It used to leak: task carried no gate of its own and the child fell
through to its own config, so /tim in Plan mode could run a qc-developer with
"bash": "allow" and get a shell command executed with no confirmation.
The same change fixed the mirror image. build-auto opens every axis so it never
interrupts, but sub-agents fell back to the user's global block — usually ask
everywhere — so the mode kept its "no confirmations" promise exactly until it
delegated, then asked about ls. Inheriting from the parent removes the dialog
without widening anything: the child still cannot do what the parent could not
have done itself, directly, without asking anyone.
rules and allowlist are merged rather than intersected. They work at a
different layer — the axis decides the class of action, the rule decides the
argument — and a child's allow rule cannot open an axis that is already
deny, because decide() checks the axis first.
Watching them work
Ctrl+X then ↓ toggles a panel listing every sub-agent of the current
turn, with each row's status (queued, running, done, failed, stopped). Rows
that have started show a running clock; a queued row shows none, on purpose —
a clock on something that has not begun reads exactly like something stuck.
The list is cleared when you send your next message, so it is always about the
work in front of you. It lives in the TUI's memory, not in the session: quit
and come back, or switch away with /session and back, and the panel starts
empty even though the sub-agents' own child sessions are still on disk.
While the panel is open it owns the keyboard — ↑ / ↓ select a row,
Esc closes it, and everything else is swallowed rather than typed into your
prompt behind it. Ctrl+X chords, Ctrl+C, and Ctrl+D still reach through —
the last one quits, same as everywhere else in the TUI.
x cancels the selected sub-agent, and asks first: the first press arms that
row and says so in the panel's title, a second x on the same row does it,
and any other key — including moving the selection — disarms it. Stopping
cannot be undone, so a single keystroke is not allowed to do it.
A cancelled sub-agent reports rather than fails. Its task call returns
STOPPED BY USER after 48s. as an ordinary tool result, the coordinator reads
it like any other result and carries on with the rest of the team, and the
history line is marked ⊘, not ✓. This holds for both engines: a sub-agent
running an external CLI has that CLI killed, exactly as an internal one has
its turn stopped.
Undo can revert more than the /tim turn — read this before relying on it
Sub-agents change nothing about the mechanism of undo: it still reverts
to the last assistant message, in the given session, that actually took a
snapshot — never a single tool call in isolation (see
Permissions & undo). The part that changes is which
message that turns out to be. A sub-agent's own writes take their snapshot on
the sub-agent's own (child) session, not on the coordinator's turn — so if
a /tim turn's coordinator dispatches work and writes nothing itself, its own
turn has no snapshot at all. Undo does not stop there: it walks the
session's messages for the most recent one that does have a snapshot, which
skips straight past the empty /tim turn to whatever the coordinator last
wrote in an earlier turn — reverting that older turn as well, silently,
in the same undo.
Concretely: edit a file in Build mode, then run /tim … where only sub-agents
write a dozen new files, then press Ctrl+X U. It does not report "nothing
to undo." It reverts the Build-mode edit and deletes all twelve files the
sub-agents just created — files created after a snapshot are removed, same as
any other undo — and reports it as one ordinary "undo: N files restored."
There is no way to undo just one sub-agent's slice of a /tim run, and no way
to undo a /tim turn in isolation if its own coordinator wrote nothing: you
either revert back to the coordinator's last real write, however far back
that is, or there is nothing to revert at all.
Ctrl+X U and a bare titah undo never reach into a sub-agent's session
directly — they operate on the session you're in (Ctrl+X U) or, for a bare
titah undo, on the most recently updated top-level session in the current
directory (titah undo -s <id> targets a specific one; a child session is
never picked automatically).
Custom commands
{
"command": {
"review": {
"template": "Review the code in {{.Input}} and suggest improvements",
"description": "Quick code review",
"agent": "explore"
}
}
}Call it with /review src/core/agent.ts. The {{.Input}} placeholder follows
opencode; Claude Code's $ARGUMENTS is accepted too.
Built-in commands — /consensus, /tim, /compact, /model, /skill,
/agents, /skills, /commands — cannot be overridden, because they change
the execution flow rather than merely expanding a prompt.
Context management
Titah compacts automatically once the context approaches the model's window, and
/compact runs the same thing on demand.
/compact # summarise everything but the last turns
/compact the database schema # same, but keep that material in full detailThis is about correctness, not tidiness. When history exceeds the window,
providers do not reject the request — they truncate the oldest part, and the
model then answers confidently about decisions it can no longer see. ollama
truncates at num_ctx (4096 by default) without a single warning.
Automatic compaction needs to know how large the model's window is, and nothing is guessed. Declare it per model:
"provider": { "ollama": { "models": { "qwen3:14b": { "contextWindow": 32768 } } } }Without it, automatic compaction is off for that model — titah doctor lists
every model missing one, and the TUI says so once per session, quietly. That
notice is information, not a failure: the turn runs normally. /compact still
works either way.
Tuning, with the defaults shown:
"compaction": {
"auto": true, // turn the whole thing off with false
"reserved": 8192, // tokens held back for the next answer and the summary itself
"tailTurns": 2, // recent turns kept verbatim, never summarised
"prune": true // drop old tool output first — free, and usually enough
}reserved is a headroom, not a hard token count. It covers two things, both
of which are absolute rather than proportional to the conversation: the next
response, and the summarisation call itself. It does not cover the growth of
the next step — one more tool result. That is budgeted separately and
automatically, from the largest tool result seen so far in the running turn, and
it is forgotten as soon as the turn ends. The trigger is therefore
contextWindow - reserved - (largest tool result this turn)with both subtractions capped at a quarter of the window each. Without the cap on
reserved, the 8192 default would equal the whole window on a common 8k local
model, push the threshold to zero, and fire compaction on every single turn
regardless of how little context was actually in use. The cap on the growth
margin exists for the mirror-image reason: a single result larger than a quarter
of the budget will not fit after any compaction, so reserving room for it would
move the overflow rather than prevent it. titah doctor reports every model
where the reserved cap is biting, and the value actually in effect for it.
What it does, precisely:
- Only what is sent to the model shrinks. The transcript on screen and in SQLite is untouched — scroll up and the real conversation is still there.
- The last few turns (
tailTurns) are kept verbatim, so the reply right after compacting does not lose the detail you just typed. - The cut always lands on a user message. Cutting between a tool call and its result would leave an orphaned result that providers reject.
- Compacting again re-summarises the previous summary rather than stacking a new one on top, so the summary cannot grow without bound.
- When the context fills up, old tool output is dropped first (
prune), because it is the bulk of an agentic turn and costs nothing to discard — the model can re-read a file. Only if that is not enough is a summary written. - This happens mid-turn too, not just between turns. One long turn reading thirty files is the case that overflows most often, and there is no user message in the middle of it where a between-turns check could fire.
- Mid-turn, the recent messages kept verbatim are bounded by size, not only
by how many there are: at most a quarter of the available budget, and at least
one message whatever its size. Counting messages alone bounds nothing when one
readof a 22 KB file is a message — measured, that single case sent 2.4× the window for an entire turn. - If pruning outside the kept tail and summarising are both still not enough, old
tool output inside the tail is pruned too, as a last resort. Pruning never
removes a message, so nothing is orphaned; the model can re-read the file.
That last resort is measured against the window itself, not the threshold:
reservedis headroom for the answer, not a wall, and a result that still fits is delivered rather than thrown away. A 22 KB file on an 8k window reaches the model; a 30 KB one cannot and is replaced by a marker saying so. - The trigger counts what has already arrived. A tool result that landed after the last measurement is part of the next request whether or not the provider has counted it yet, so it is added before comparing against the threshold. Without that, a single result larger than the growth margin slipped in unnoticed and the next request was already over the window — measured at 110% of an 8k window with a 30 KB read.
- "Was pruning enough?" is answered by measuring the request that is about to
be sent — the messages plus the system prompt — not by subtracting an
estimate from a number the provider reported for a different request one step
ago. That arithmetic could not be right, and it was wrong in the direction that
costs money: measured, at a 28 KB result on an 8k window the request that would
actually go out was 490 tokens — 6% of the window — while the summariser fired
on 29 of 30 steps. One ruler now answers both this and "will it fit".
The measurement counts the system prompt and the prompt you just typed, both of
which ride the request without being in the stored history. What it cannot count
is the tool schemas, and a byte-per-token ratio is an estimate: token-dense
content (code, CJK, base64) can make the real count a third higher than
measured.
reservedis the headroom that absorbs that, and the trigger — which reads the provider's own number — fires again the next step. - Sub-agent results are exempt from ordinary pruning. The marker tells the
model to re-run the tool, which is right for
readand wrong fortask: recovering a sub-agent's answer costs another full nested turn. Summarisation handles them instead — lossy, but not destructive. In the last-resort tail prune nothing is exempt, because a silently truncated request is worse still, but there the marker says what was lost and what it costs to get back. - The summariser's own prompt is bounded too, by the window of the model that
writes it. A transcript larger than that window is summarised in chunks —
each chunk small enough to fit, then the chunk summaries summarised in turn.
Without this the prompt was unbounded: measured at 78,964 tokens against a
smallModeldeclaring 4,096 — 19.3× — and providers do not reject that, they truncate it. WhensmallModeldeclares no window the bound falls back to the turn model's — the same thingtitah doctortells you it will do — and only when nothing at all is declared is chunking off, which is the same rule the rest of compaction follows. The user's focus text (/compact <focus>) is counted against that budget and clamped to a quarter of it, because it rides every chunk. - If any chunk comes back empty — a provider error, or
Esc— the whole summarisation is abandoned and nothing is saved. A summary missing one chunk, stored as though complete, is exactly the failure this feature exists to prevent. - A failed compaction — a broken
smallModel, a provider error — never fails the turn. That step's compaction is simply skipped and the turn continues with whatever context it already had. - The summary is written by
smallModelwhen one is configured, falling back to the turn's model./compactmakes the same choice, so a session's automatic and manual summaries are never written by two different models. - Compaction is cancellable. Because it now runs unbidden,
Escreaches the summariser itself — asmallModelthat hangs ends the turn instead of holding the session open.
The summariser is instructed, above everything else, never to invent: identifiers are copied verbatim, and anything it cannot confirm is recorded as unresolved. A summary that drifts is worse than no summary, because it reads as an agreed record that the model has no way to check.
A command name must be followed by whitespace or end of line, so pasting an
absolute path like /home/user/notes.md into a prompt is never misread as a
command.
Skills
A skill is a markdown file with frontmatter, loaded into context when used.
Two layouts are recognised: <dir>/<name>/SKILL.md (superpowers/Claude Code
style) and <dir>/<name>.md. name and description are read from the
frontmatter.
Config
{
"skills": {
"discover": ["claude", "opencode"], // read installed registries automatically
"paths": ["~/.config/opencode/skills", { "path": "./.titah/skills", "as": "team" }],
"always": ["superpowers:using-superpowers"] // loaded in full every turn, not just catalogued
}
}discoverreads the Claude Code (~/.claude) and opencode (~/.config/opencode) skill/plugin registries so anything already installed there works with zero configuration. Set it to[]in tests or in any setup that must not touch those directories.pathsadds explicit directories, either as a bare string or as{ path, as }to override the namespace that directory would otherwise get.alwaysnames skill ids that are loaded in full on every turn, rather than only appearing one line each in the catalogue. An agent's ownskills: [...]is the per-agent counterpart and takes the same fully-qualified ids.
Ids and namespaces
A skill's id is always fully qualified as namespace:name — the bare name
alone is never accepted, since two different sources can otherwise define the
same name. The namespace comes from, in order:
- the plugin manifest's
name(.claude-plugin/plugin.json), if one exists for that directory or its parent; - otherwise the skill directory's own folder name;
- except when that folder is literally called
skills, which tells you nothing on its own — in that case the parent directory's name is used instead. This is what turns~/.config/opencode/skillsinto theopencodenamespace rather thanskills.
Invoking a skill
Type /plugin:skill <message> to run one directly, e.g.
/superpowers:brainstorming a new caching layer. The transcript shows the
command you typed, not the skill's full body — the model receives the skill's
instructions followed by your message.
The model can also load a skill on its own, without being asked, via the
skill tool: it passes a fully qualified id (as seen in the /skills
catalogue) and the skill's instructions are inserted into the conversation.
This is how a skill whose description matches the current task gets used
without a slash command.
A skill assigned to an agent through that agent's skills: [...] (see
Custom agents) is loaded in full into its system prompt from
the start. Every other skill is only catalogued one line each — loading
everything up front would exhaust the context window before any work begins.
When something is misconfigured
Two situations are tolerated rather than treated as fatal, because a
misconfigured skill should not take down a session: a duplicate id (same
namespace:name from two sources) keeps the first one found and drops the
second, and an id in always or in an agent's skills that resolves to
nothing is simply skipped. Both are silent at the point they happen — but
/skills and titah doctor report the skill count per namespace and, only
when there is something to flag, list every conflict, every configured path
that yielded no skills, and every unresolved id together with where it was
configured.
Two things skills do not do
- opencode plugins are not supported. opencode has two different extension mechanisms that share a folder structure but are not the same thing: skills are plain markdown files, and Titah reads those like any other skill source. Plugins are JavaScript modules written against opencode's own runtime API (hooks, event handlers, etc.) — there is no markdown to load, so Titah cannot run them and does not try to.
- A skill cannot make Titah do anything by itself. A skill is text inserted into a prompt — instructions, not code. It cannot open a browser, call an API, or perform any action beyond wha
