The schema is generated from src/core/schema.ts into config.schema.json —
do not edit that file by hand. Point $schema at it for editor autocomplete:
{ "$schema": "./node_modules/titah-code/config.schema.json" }
Global config is ~/.config/titah/titah.json, merged with the project’s
./titah.json. The project file wins.
Top level
| Key | Type | Default | Meaning |
|---|---|---|---|
$schema | string | — | Path to config.schema.json, for autocomplete |
model | string | none | Default model as provider/model. No default is guessed — a picker is shown instead |
smallModel | string | unset | Cheap model for side work, above all compaction summaries |
defaultAgent | string | "build" | Which agent a session starts in |
provider | object | {} | Provider definitions, keyed by name |
agent | object | {} | Agent definitions, keyed by id |
externalAgent | object | built-ins | External CLI adapters |
permission | object | ask-by-default | Permission axes, argument rules, and the allowlist |
command | object | {} | Custom slash commands |
keybinds | object | opencode defaults | Key overrides; "none" disables |
skills | object | claude + opencode | Skill discovery, paths, always-loaded ids |
compaction | object | see below | Context compaction tuning |
search | object | { backend: "ddg" } | websearch backend and key |
diagnostics | object | unset | The project checker command |
mcp | object | {} | MCP servers, stdio only |
lsp | object | {} | Language servers, per extension |
account | object | unset | Titah account server. The token never lives here |
plugin | object | {} | Plugins, named one by one — never auto-discovered |
panel | object | see below | Side-panel geometry: widths, the history floor, the box floor |
extension | object | {} | Side-panel extensions, named one by one — never auto-discovered |
hooks | object | empty | Shell commands at tool.before / tool.after |
sandbox | object | { bash: false, network: true } | OS-level fence around bash |
limits | object | see below | Token budgets, and automatic continuation |
delegation | enum | "ask" | When work is handed to sub-agents |
instructions | string | object | array | [] | Extra instruction files to read, and create if missing |
scaffold | boolean | true | Create declared instruction files, skill folders and a starter AGENTS.md |
tracking | object | { enabled: true, exclude: [], git: true } | What is reported to your account dashboard |
artifacts | object | { enabled: true } | Whether the artifact tool may publish pages |
logLevel | enum | "INFO" | DEBUG | INFO | WARN | ERROR |
provider
"provider": {
"<name>": {
"npm": "@ai-sdk/openai-compatible",
"options": { "baseURL": "https://…/v1", "apiKey": "${env:VAR}" },
"models": {
"<model-id>": { "name": "Human label", "contextWindow": 131072 }
}
}
}
| Key | Notes |
|---|---|
npm | @ai-sdk/openai-compatible or @ai-sdk/anthropic |
options | Passed to the SDK provider. baseURL, apiKey, headers |
models.<id>.name | What appears in /model and titah models |
models.<id>.contextWindow | Required for automatic compaction. Never guessed |
models.<id>.price | { input, output, cacheRead } per 1M tokens. Required for cost in titah stats. Never guessed |
cacheControl | Send cache_control in the request body. Only for OpenAI-compatible gateways that forward it to Anthropic — ask your gateway operator first. Default false |
permission
"permission": {
"edit": "ask", "write": "ask", "bash": "ask",
"network": "ask", "delete": "ask", "mcp": "ask",
"external_directory": "deny",
"doom_loop": "ask",
"allowlist": ["git *", "npm test"],
"rules": { "bash(git *)": "allow", "bash(git push *)": "deny" }
}
| Key | Default | Notes |
|---|---|---|
edit write bash network delete mcp | "ask" | The six harm classes |
external_directory | "deny" | Paths outside the working directory. Opened per path through rules, never with a blanket allow |
doom_loop | "ask" | A repeating call. It can only tighten — "allow" means “never interrupt me” |
allowlist | [] | Shell command patterns always allowed |
rules | {} | Argument-level rules, "<axis>(<pattern>)": policy |
A rule-level deny is absolute; among ask and allow the most specific
pattern wins, measured in non-wildcard characters rather than file order. Full
rules in Permissions & undo.
agent
"agent": {
"<id>": {
"description": "Shown in /agents and to the coordinator",
"prompt": "Extra system prompt for this agent",
"model": "provider/model",
"mode": "primary | subagent | all",
"delegate": "<externalAgent name>",
"tools": { "write": false },
"permission": { "bash": "allow" },
"skills": ["namespace:name"],
"effort": "low | medium | high",
"steps": 40
}
}
| Key | Notes |
|---|---|
mode | Default "primary". Only "subagent"/"all" are dispatchable |
delegate | Mutually exclusive with model. Config setting both is rejected at load |
tools | An exception list, not an allowlist. Unlisted tools stay enabled |
permission | Overrides the global block. Unmentioned axes inherit |
skills | Fully-qualified ids only. A bare name never resolves |
effort | How much closing analysis an answer ends with. Unset means the model decides. Ctrl+R cycles it live |
steps | Tool-calling iterations per turn. Default 40, or 200 once limits.turnTokens is set; the last runs with no tools |
externalAgent
"externalAgent": {
"<name>": {
"command": "aider",
"args": ["--message", "{prompt}", "--no-stream"],
"resumeArgs": [],
"format": "stream-json | json | text",
"sessionMode": "generate | discover",
"specialist": "deep architectural reasoning, cross-module refactors",
"timeout": 600000,
"enabled": true
}
}
| Key | Default | Notes |
|---|---|---|
args | [] | Arguments for the first call. {prompt} and {session} are substituted |
resumeArgs | [] | Arguments for resuming. Empty means reuse args |
format | "stream-json" | How to read the CLI’s output |
sessionMode | "discover" | Who owns the session id — see Delegation |
specialist | unset | What this agent is best at, written for the model to read. /tim uses it to decide who gets which part, and skips a super agent that has none — naming who it skipped |
timeout | 600000 | Milliseconds |
enabled | true | false removes it from /consensus and the @ popup |
skills
"skills": {
"discover": ["claude", "opencode"],
"paths": ["~/.config/opencode/skills", { "path": "./.titah/skills", "as": "team" }],
"always": ["superpowers:using-superpowers"],
"learn": { "enabled": false, "minTools": 8, "max": 30 }
}
Set discover to [] in tests or anywhere that must not read ~/.claude and
~/.config/opencode.
learn key | Default | Meaning |
|---|---|---|
enabled | false | Let Titah write reusable skills after long turns. Costs one smallModel call per qualifying turn |
minTools | 8 | Tool calls a turn needs before it is considered |
max | 30 | Cap on learned skills; at the cap existing ones are still updated |
Full behaviour in Skills → Learned skills.
compaction
"compaction": { "auto": true, "reserved": 8192, "tailTurns": 2, "prune": true }
| Key | Default | Meaning |
|---|---|---|
auto | true | Automatic compaction on/off |
reserved | 8192 | Headroom for the next answer plus the summary call. Capped at a quarter of the window |
tailTurns | 2 | Recent turns kept verbatim |
prune | true | Drop old tool output before summarising |
Full behaviour in Context & compaction.
search
"search": { "backend": "ddg | brave | tavily", "apiKey": "${env:BRAVE_API_KEY}" }
ddg needs no key and scrapes HTML, so it can break without warning — titah doctor says so.
diagnostics
"diagnostics": { "command": "npm run typecheck" }
Never guessed. A non-zero exit is a finding, not a tool failure.
mcp and lsp
"mcp": { "github": { "command": "mcp-server-github", "args": [] } },
"lsp": {
"ts": { "command": "typescript-language-server", "args": ["--stdio"], "extensions": [".ts", ".tsx"] }
}
MCP is stdio only, tools only, namespaced <server>_<tool>. See
MCP & language servers.
keybinds
"keybinds": { "session_interrupt": "ctrl+g", "tool_details": "none" }
Defaults follow opencode. "none" disables an action. See The TUI.
command
"command": {
"review": {
"template": "Review the code in {{.Input}} and suggest improvements",
"description": "Quick code review",
"agent": "explore"
}
}
{{.Input}} follows opencode; $ARGUMENTS is accepted too. Built-in commands
cannot be overridden.
account
"account": { "server": "https://titah.dev" }
Only the server address belongs here. The token does not — it lives in
~/.local/share/titah/account.json at mode 0600, for the same reason as
provider keys: the config is the file people paste into GitHub issues.
Resolution order is $TITAH_ACCOUNT_SERVER, then this key, then the built-in
default https://titah.dev. See Account & sign-in.
limits
"limits": {
"turnTokens": 400000,
"sessionTokens": 2000000,
"continueTurns": 5
}
| Key | Default | Meaning |
|---|---|---|
turnTokens | 5× the model’s contextWindow | Token budget for one turn, summed across steps. Setting it also lifts the step ceiling from 40 to 200 |
sessionTokens | unset | Budget across every turn in a session. Checked before a turn starts, never mid-turn |
continueTurns | 0 | How many times to continue automatically after a limit, while the plan still has unchecked - [ ] items |
Full behaviour in Cost & limits.
delegation
"delegation": "ask"
"ask" (default), "auto", "always", or "never". "never" does not even
send the roster to the model, so no tokens go on a list nobody uses. See
Modes & custom agents.
hooks
"hooks": {
"tool.before": [{ "match": "^bash$", "run": "scripts/guard.sh", "timeout": 30000 }],
"tool.after": [{ "match": "edit|write", "run": "npm run format" }]
}
match is a regex over the tool name; omit it to match every tool. timeout
defaults to 30000 ms. tool.before refuses the call on a non-zero exit;
tool.after cancels nothing but appends its stderr to the tool output. See
Hooks.
sandbox
"sandbox": { "bash": false, "network": true }
| Key | Default | Meaning |
|---|---|---|
bash | false | Run bash inside the OS sandbox. Refuses to run bash at all if no sandbox exists on this platform |
network | true | Allow network from inside the sandbox. npm and git need it |
See Sandbox.
plugin
"plugin": {
"@acme/titah-audit": { "options": { "file": "audit.log" } },
"./plugin/lock-migrations.mjs": {},
"market:prettier": {}
}
Named one by one, never auto-discovered from node_modules — plugins run
unsandboxed inside Titah’s process, and “installed” is not “trusted”.
"enabled": false means the module is not imported at all. See
Plugins.
panel and extension
"panel": {
"floor": 40,
"boxFloor": 2,
"left": { "width": 20 },
"right": { "width": 20 }
},
"extension": {
"@titah/extension-git": { "side": "right" },
"@titah/extension-tests": { "side": "right", "rows": 6 },
"./panel/notes.mjs": { "side": "left", "collapsed": true, "key": "n" }
}
panel key | Type | Default | Meaning |
|---|---|---|---|
floor | integer | 40 | Columns of history that must survive. Below it, side panels close themselves rather than squeeze the conversation |
boxFloor | integer | 2 | Rows an open box needs. Below it, boxes fold themselves from the bottom up, and a notice says how many |
left.width / right.width | integer (min 8) | 20 | Starting width of that side, in columns |
extension key | Type | Meaning |
|---|---|---|
enabled | boolean (true) | false means the module is not imported at all — top-level code in it never runs |
side | "left" | "right" | Overrides the side the extension asked for |
width | integer (min 8) | Overrides that side’s width while this box is on it |
rows | integer | Requested height. A request, not a guarantee: what is left after the other boxes is still the ceiling, and with no number the remaining height is split evenly |
collapsed | boolean | The starting state. Folding with a key does not write back here — a config that changes itself because of a keypress stops reading as a statement of intent |
options | object | Passed to the extension’s factory |
key | string | The leader key this box answers to, resolved at install time when two extensions ask for the same one |
A side is a stack: extensions listed for the same side are drawn as separate
boxes, top to bottom in config order — the only order you can see and
change. Extensions run unsandboxed in the TUI process, so like plugin they are
named one by one and never auto-discovered. See
Extensions.
instructions and scaffold
"instructions": "titah-instruction.md"
"instructions": { "path": "titah-instruction.md" }
"instructions": ["a.md", { "path": "b.md" }]
All three forms mean the same thing. The single form is accepted because it is
what people write first, and the object form follows skills.paths, which
already takes string | { path } — two axes pointing at files should be written
the same way.
Declared files that do not exist are created on the first turn of a session,
and a project with no instruction file at all gets a starter AGENTS.md.
"scaffold": false
turns all of that off — nothing is written that you did not ask for. See Project instructions.
tracking
"tracking": {
"enabled": true,
"exclude": ["~/clients/*"],
"git": true,
"sync": false
}
| Key | Default | Meaning |
|---|---|---|
enabled | true | Report project metadata. Nothing is ever sent when you are not signed in, whatever this says |
exclude | [] | Paths never reported. Same glob dialect as permission.allowlist; ~ is expanded first |
git | true | Include the remote URL and branch. A remote URL often names a client |
sync | false | Upload transcripts. Needs this and the per-project toggle on the server. Prompts, answers and tool names only |
Per-project opt-out needs nothing here — put {"tracking": {"enabled": false}}
in that project’s ./titah.json. Full behaviour in
Project tracking.
artifacts
"artifacts": {
"enabled": true
}
| Key | Default | Meaning |
|---|---|---|
enabled | true | Allow the artifact tool to publish pages to your dashboard. Nothing is ever published when you are not signed in, whatever this says |
On by default, unlike tracking.sync, and the difference is not an
inconsistency. sync uploads automatically, with nobody in the loop, so it
has to be switched on deliberately. Publishing a page happens only when the
model asks and the network permission dialog — naming the host, the title and
the byte count — is approved. You are in the loop every single time, so a
switch that started off would add a step without protecting anything.
Set it to false to remove the tool from the roster entirely. Full behaviour in
Artifacts.
Checking your config
titah config path # where the files are
titah config show # the merged result, credentials redacted
titah doctor # plus what resolved, what did not, and what is missing