MCP already covers third-party tools. What it does not cover is everything that
is not shaped like a tool at all: run a formatter after every write, refuse an
edit on a particular file, log every tool call to your own audit system.
Before plugins, each of those meant a fork.
First: a plugin is code running inside Titah’s process
There is no sandbox, and none is promised. A plugin can read any file Titah can read, reach the network, and see every tool input before you approve it.
That is why plugins must be named one by one in the config. There is no
auto-discovery from node_modules — enabling a plugin is a trust decision on the
same order as npm install.
Installing
{
"plugin": {
"@acme/titah-audit": { "options": { "file": "audit.log" } },
"./plugin/lock-migrations.mjs": {},
"market:prettier": {}
}
}
| Key form | Means |
|---|---|
@acme/titah-audit | An npm package, resolved from your project’s node_modules |
./plugin/x.mjs | A local file, relative to the session directory |
market:<id> | Marketplace — the shape exists, the contents do not yet |
titah plugin list
This actually loads them and reports what each provides and what failed. Reading the config is not enough: a plugin that is written down but cannot be imported looks identical to one that works.
"enabled": false means the module is not imported at all, not merely
unused — top-level module code runs on import.
Writing one
A plugin default-exports a factory. It may be async.
export default function ({ cwd, config, options }) {
return {
name: "lock-migrations",
// Runs BEFORE the permission dialog.
"tool.before": ({ tool, input }) => {
if (tool === "edit" && String(input.path).startsWith("db/migrations/")) {
return { deny: "a migration that has already run must not be edited" }
}
},
// Runs after the tool, before the built-in checkers.
"tool.after": ({ tool, output }) => {
if (tool === "bash") return `${output}\n[logged]`
},
}
}
options is whatever you wrote in the config, verbatim — its shape belongs to
the plugin.
Two hooks, and the ordering is the point
tool.before — before permission
It runs before the permission dialog, not after. After would mean a plugin that rewrites the input has you approving one thing and something else running — which destroys the entire meaning of the dialog.
Return:
{ deny: "reason" }— the call stops, and permission is never asked{ input: <new> }— the input is replaced, and the dialog shows the new one- anything else, including nothing — carry on
Several plugins run in sequence, and the second sees the input the first already changed. Running them in parallel would make the result depend on who finished first.
A plugin that throws here refuses. tool.before is a guard, and a guard that
breaks and is then ignored is the same as no guard — failing precisely on the
call it might have been there to stop.
tool.after — after the tool, before the built-in checkers
Return a string to replace the output, or nothing to leave it.
It runs before the LSP formatter and the built-in diagnostics. In that order, a plugin that rewrites a file has finished by the time diagnostics run, so what is reported is the file’s real state.
A plugin that throws here is ignored — the opposite of tool.before. This
hook only shapes output for work that already happened, and discarding a
successful tool result because its logger broke throws away real work for
something inessential.
A failing plugin never brings the session down
A plugin that fails to load loses its hooks, the session runs on, and you are told once through a notice — the same rule as an MCP server that dies. A session refusing to start because one audit-logging plugin is broken punishes you for something you did not ask for at that moment.
One thing worth knowing
Node caches modules by URL. Editing a plugin file mid-session has no effect until Titah is restarted. Busting the cache would leave every version resident in the module registry for the life of the process, and for the common case — an npm package that only changes when it is reinstalled — the cache is the correct behaviour.
Marketplace
Not there yet, and the shape being present is deliberate.
market:<id> is already recognised today and fails with a sentence naming the
situation, rather than being quietly treated as an npm package name and
producing a “module not found” that points at the wrong cause.
Adding the form later would mean guessing how it will be written, and every wrong guess becomes a change that breaks somebody’s config. What remains is filling in the resolver.
One decision is already made, because it determines the config shape: a
marketplace entry maps to an npm package and an exact version, so
market:prettier on two machines means the same code. A registry that returns
“the latest package” cannot promise that.
Plugins versus hooks
They attach to the same two hook points. Pick by weight:
| Plugin | Hook | |
|---|---|---|
| Written as | A JavaScript module | A shell command in the config |
| Can rewrite tool input | Yes | No |
| Worth it for | Real logic, state, an npm package | “Run the formatter after every edit” |
Plugins run first, then shell hooks — and the shell hooks see the input plugins already reshaped.