npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

titah-code

v0.9.0

Published

Coding agent CLI that can delegate a question to other agent editors (claude, opencode, ...)

Downloads

1,592

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 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 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.0 is 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 TUI

titah 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 TUI

The TUI requires a build. Node runs .ts directly but not .tsx — JSX is not stripped. Headless commands (run, serve, doctor, …) still run from source with node src/cli.ts; the TUI only runs from dist/.

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 account

Whichever 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 run runs 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_name and __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_details is left unbound by opencode. Titah binds it to Ctrl+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:

  1. A rule-level deny is absolute. Nothing can open it — that is how you write a wall: "network(*)": "deny". A class-level deny is a default deny: an explicit allow rule 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 every allow rule under a denied class silently dead — the same failure class as #12, where a written rule never fires.
  2. Between ask and allow, the most specific pattern wins — measured in non-wildcard characters, not file order. Ties go to ask: 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 httpsfile: 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 shellnpm 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:

  1. permission.<tool> from the active agent, then from the config — "deny" refuses, "allow" skips the dialog
  2. permission.allowlist from 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)
  3. --auto mode
  4. no client connected → auto-denied
  5. 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 session

One 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:

  • tools removes a tool from the list the model can see. Tools not listed stay enabled — the list is an exception list, not an allowlist.
  • permission leaves the tool visible but refuses its use, overriding the global permission. 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 a to 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 detail

This 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 read of 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: reserved is 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. reserved is 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 read and wrong for task: 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 smallModel declaring 4,096 — 19.3× — and providers do not reject that, they truncate it. When smallModel declares no window the bound falls back to the turn model's — the same thing titah doctor tells 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 smallModel when one is configured, falling back to the turn's model. /compact makes 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, Esc reaches the summariser itself — a smallModel that 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
  }
}
  • discover reads 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.
  • paths adds explicit directories, either as a bare string or as { path, as } to override the namespace that directory would otherwise get.
  • always names skill ids that are loaded in full on every turn, rather than only appearing one line each in the catalogue. An agent's own skills: [...] 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:

  1. the plugin manifest's name (.claude-plugin/plugin.json), if one exists for that directory or its parent;
  2. otherwise the skill directory's own folder name;
  3. 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/skills into the opencode namespace rather than skills.

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