Skip to content

Coding agents

henri is built to be driven by a coding agent as well as by a person. The conventions are written down where an agent will read them, every command can answer JSON, failures carry a code instead of prose, and an MCP server exposes the parts of the framework an agent needs to see. None of it is required: a person typing commands gets the same framework.

File Role
AGENTS.md The conventions of this application, generated from it: the layout, the models, the routes, the commands that apply.
CLAUDE.md A pointer to AGENTS.md, so a tool looking for either finds the same text.
.mcp.json Starts the henri MCP server (henri mcp) for the project.

AGENTS.md is not a template with the app’s name filled in: henri generate agents reads the application and writes what is true of it. The renderer and its page extension, the stores and their adapters, the models and the marks they carry (personal, encrypted, paranoid, a retention rule), the routes as the router expands them, the controllers, the policies, the jobs, the mailers, the workers, the modules and which henri packages are installed — all of it is read from the files, and nothing is booted. An application without @usehenri/jobs gets no paragraph about the queue; one on Drizzle gets the Drizzle model API and the migration commands and no sentence about Mongoose. The last line of its “Do not” section names the packages the application does not have, which is what stops an agent reaching for an API that is not installed.

Terminal window
henri generate agents # write or refresh AGENTS.md, CLAUDE.md and .mcp.json
henri generate agents --json # { created, updated, skipped } like any generator

Run it whenever the application changes shape — a store swapped, a renderer changed, a package installed, a resource scaffolded. henri doctor says when it is time.

Everything henri writes sits between two markers:

<!-- henri:agents 1 app=045d7a7b2a0f gen=42e21bae9532 -->
# app: conventions for coding agents
...
<!-- /henri:agents -->
## House rules
Always run `make check` before a commit.

Regenerating replaces the bytes between the markers and copies everything before and after them through untouched. So the place for your own conventions is outside the region, and there is nothing to merge and nothing to lose.

The opening marker carries two short digests: app, what the application was when the file was written, which is what henri doctor compares; and gen, what henri wrote in the region, which is what tells henri’s own text from a hand edit. That gives four cases, and in three of them the command writes nothing you did not ask for:

The file henri generate agents
is missing Writes it whole (created).
has the region, untouched Rewrites the region only (updated). Text around it is kept.
has the region and it was edited by hand Writes nothing (skipped), and says why. --force rewrites it.
has no region at all (yours, or an older henri) Writes nothing (skipped), and says why. --force replaces it.

--force is the only way past either refusal, so the failure mode is “your text is kept” and never “your text was kept unless”.

AGENTS.md is loaded on every task, so it is budgeted rather than allowed to grow: the generated region is held to 150 lines, and a fresh application lands around a hundred. A line earns its place by being one of three things — a convention that changes what an agent writes here, a fact about this application it cannot get from the documentation, or a command that will actually run here. Everything else is a manual, and a manual belongs where an agent can fetch it when it needs it.

That is how the three pieces fit together and why none of them repeats another: AGENTS.md is the always-loaded part, the MCP server is the part fetched on demand (guide is henri’s documentation at the version installed, routes, models and config answer for this application), and henri doctor is what checks the claims.

Every published package ships hand-written TypeScript declarations, and the jsconfig.json of a scaffolded application already points at them. An agent editing a controller sees the signature of res.render(), the twelve names on res.boom, the keys config/default.json accepts and the actions resources expands to, instead of guessing them from the docs.

The generators write the one JSDoc line that binds a file to its shape (/** @type {import('@usehenri/core').Controller} */), so a generated controller, model or routes file is typed from the moment it exists.

The other half is generated from the application rather than shipped with the framework. .henri/types.d.ts holds an interface per model — its columns, their types, the union of an enum column’s values — and the union of every path helper config/routes.js expands to. Every development boot and every hot reload writes it, so it is there without anyone running anything; henri types writes it where no server has run, and henri build writes it in CI.

Reading it is already useful: it is the shortest accurate answer to “what is this column called” and “what is this route’s helper”. Checking it is the point:

Terminal window
# one file
# @ts-check <- the first line of app/controllers/posts.js
# or the whole application, in jsconfig.json: "checkJs": true
npx tsc --noEmit -p jsconfig.json

post.titel, post.status = 'published' on a column whose enum says live, and pathFor('taks_path') are then compile errors rather than a page that renders undefined and a link that goes nowhere — which is the difference between a mistake an agent can correct itself and one nobody sees. What is not caught is a static henri does not own (Model.findAll and its neighbours are any, because the three ORMs disagree), and Types says exactly where that line is.

The documentation, offline and version matched

Section titled “The documentation, offline and version matched”

The pages of this site ship inside the framework: scripts/prepublish.js copies them into @usehenri/core/docs at publish time, so an application carries the documentation of the henri version it runs, with nothing to fetch.

Terminal window
henri docs # the index: every page and what it covers
henri docs guides/policies # one page, as markdown
henri docs guides/policies --json

That is the answer to an agent recalling henri from its training data, which recalls a framework that was asleep for years: the pages next to the code are the ones that describe the code. They are read by henri docs, by the guide tool of the MCP server and by anything that opens node_modules/@usehenri/core/docs/<page>.md itself – one copy, one version, three ways in.

An agent that does have the network can read the whole site instead: /llms.txt is the index in the llmstxt.org format, and /llms-full.txt is every page concatenated. Those describe the version deployed on usehenri.io, which is the latest one – henri docs is the one that matches the application.

Every informational command takes --json:

Terminal window
henri routes --json # the expanded routes with their helpers and roles
henri types --json # the models and the path helpers it wrote, and what it skipped
henri doctor --json # the report, with one entry per problem
henri generate scaffold Post title:string! --json
henri destroy scaffold Post --json
henri db:status --json
henri about --json

The output is the result only. Progress and logs go to stderr, so henri db:status --json | jq is safe.

A command that fails prints one object on stderr and exits with a stable code:

{
"error": {
"command": "generate",
"message": "app/models/Post.js already exists",
"hint": "Pass --force to overwrite it",
"code": "HENRI_CLI_EXISTS",
"exitCode": 1
}
}

The code is one of henri’s error codes: a stable name that never changes meaning between versions, carried by every failure the framework raises — in the boot log, in the JSON error body of the API, in --json and in the answers of henri mcp. Look the code up rather than matching the message, which may be reworded.

Exit code Name Meaning
0 OK Success.
1 FAILED The command failed; henri doctor found problems; the tests failed.
2 USAGE Unknown command, missing or invalid argument.
3 NOT_A_PROJECT Not a henri application: run the command from the root of the app.
4 NEEDS_TTY An interactive prompt was needed but stdin is not a terminal: pass the flag.

An agent can branch on exitCode without reading the message, and hint says what to do next. Generators never prompt when a flag can answer the question, so they are safe to run unattended.

henri doctor checks an application against the conventions without starting it: nothing is booted, no database is needed. It is the fastest way for an agent to find out whether an edit left the app coherent.

Terminal window
henri doctor # a report, exit 1 when something is wrong
henri doctor --json # the same as JSON

It checks the Node version, every config/*.json — its syntax, then the whole file against henri’s configuration schema, so a wrong value or a misspelled key is found without booting — the secret and the .env that holds it, the git ignore rules, the routes file and every route’s controller, action and policy, controller naming and unused controllers, model naming, location and store, the page files a resources route needs, the test configuration, the dependencies declared in package.json and the ones actually installed, and the presence of AGENTS.md.

It also reads what an edit breaks only at the next boot, which for an agent is the difference between a green run and a green run that means something: a model naming a store no environment configures, a store adapter only config/production.json asks for, a route asking for a policy that is not there, a file of app/jobs with no perform, a recurring schedule naming a job that does not exist, a mailer action with no view, an app/modules file whose name is taken or whose needs nothing provides, and the henri packages installed at two versions. One check asks a database: whether the store holds the migrations of db/migrations — and when it cannot reach it, it says so rather than guessing. That one needs the dependencies installed, since the store adapter is resolved from the application.

AGENTS.md itself is checked against the application: agents.stale fires when the file no longer describes it, which is exactly when an agent that trusts it writes the wrong code. Because the file is generated it carries a digest of what the application was, so the check is exact — a model added or a package installed is drift too, not only a switched renderer or store. henri generate agents rewrites the generated region and keeps everything you added around it.

Problems are reported as errors or warnings, each with a stable check name, a file, a hint saying what to run — and a code, the henri error code the boot would raise, when the check predicts a failure the framework has a name for. The full list is in the CLI reference.

henri audit answers the other half: whether the application does the things a web application is judged on. Same shape as doctor — files only, nothing booted, a stable check name and a stable exit code — mapped to the ASVS requirement and the OWASP Top 10 category each finding falls under.

Terminal window
henri audit # exit 1 on a medium or high finding
henri audit --json # { ok, findings: [...], summary }
henri audit --checks # what it can determine, and against what

An agent should read --checks before it reads the findings: it says what the audit covers, which is the difference between “nothing was found” and “nothing was looked for”. The Security page carries the other half of that answer, the table of what henri does for every application, so an agent does not spend a turn adding a protection the framework already has.

henri mcp starts a Model Context Protocol server over stdio for the application in the current directory. henri new writes a .mcp.json that starts it, so an MCP-aware editor or agent picks it up with no configuration.

Tools that work from the files, without starting anything (generate, destroy and types write, the rest only read):

Tool What it does
routes The expanded routes with their verbs, paths, controllers, helpers and roles.
openapi The OpenAPI 3.1 description of the HTTP surface, in one call.
models The models and their schemas.
types Writes .henri/types.d.ts, the models and the path helpers as types.
controllers The controllers and their actions.
config The configuration of the app, including which adapter and renderer it uses.
doctor The henri doctor report.
audit The henri audit report: the security findings, with their category and level.
generate Runs a generator and returns the files written, the files skipped and routes added.
destroy Undoes a generator.
test Runs the app’s tests and returns the result.
lint Runs the linter and returns the findings.
guide This documentation, at the version installed, with the versions next to it.

Resources: AGENTS.md itself, henri://conventions (the framework conventions), henri://routes, henri://runtime and henri://help.

The server acts on the application in its working directory and nothing else, and every write goes through the same generators a person would run, so what an agent produces is what henri generate produces.

Reading the files answers what the application says. The rest of the tools answer what it does, against a booted application:

Tool What it answers
errors The last errors with their stack and the request that caused each one, filterable by X-Request-Id.
logs The lines henri.pen wrote, by level, by text or by request id, with filterParameters still applied.
query One read against a store, through its adapter. Reads only, bounded, redacted.
records A page of a model, or one record, read through the model rather than the driver.
runtime_routes The routes the router actually mounted, including the ones whose controller is missing and the endpoints henri adds.
schema What the databases really hold: the tables, their columns and types, their indexes, and the tables no model claims.
request One request against the application: status, headers, body and the request id, so a fix can be checked at once.

errors is the one to reach for first. henri stamps every request with X-Request-Id, every log line of that request quotes it, and the recorded error keeps it, so one failure gives an agent the stack, the parameters, the controller it reached and every line written while it was handled – without reproducing anything. request closes the loop: make the call, read the id it answers with, ask errors and logs for that id.

models reads the files, and a model file does not know what its table is called. The physical table name, the column a rename produced (externalId is external_id in every SQL store henri writes), the type the dialect chose for a string, whether a column is nullable, an index somebody added by hand, a table nothing declares any more – all of that lives in the database and in the ORM objects of the running process. So an agent about to write SQL for the query tool should read schema first and never guess the names off app/models.

It is not henri db:status either, and the two are asked of the same database without being computed from one another: db:status says what is wrong – the migrations still pending, or, on a store with none, what the database and the models disagree about – and says nothing at all when the answer is “nothing”. schema says what is there. A SQL store carries both: the tables, plus a drift (the same report db:status prints, without the DDL – nothing on this surface writes) or a migrations block, whichever its adapter keeps.

A mongoose store is the honest exception, and the answer says so rather than dressing a declaration up as a schema. Three of the four things it reports are real, server-side facts: which collections exist, which indexes each one carries, and which collections no model claims. The fourth, the fields, is not – MongoDB validates nothing henri gave it – so the answer carries read: "models", enforced: false and a note saying that a document written by anything else can hold any shape at all.

Column names are never masked: naming the columns is what the tool is for, and password is a column an agent has to be able to see. A column default is a value, so it goes through the same rule everything else here does – masked under a filtered name, and masked by shape if it looks like a hash or an encrypted column’s envelope.

henri db:schema is the same answer for a person at a terminal, and it needs no running server. See the CLI reference.

These tools talk to a henri development server over the loopback interface. The MCP server attaches to the one already running when it finds it, which is the point: the errors and the logs worth reading are the ones that happened in the process you are working in, and with the default disk store the database only exists inside it. When nothing answers, the MCP server starts one itself (loopback, on a port it picks) and stops it when the editor disconnects. Every answer says which of the two happened, and on which url:

{
"app": {
"cwd": "/srv/app",
"env": "dev",
"pid": 4213,
"stores": { "default": { "adapter": "drizzle", "queryable": true } }
},
"source": "attached",
"url": "http://127.0.0.1:3000"
}

HENRI_MCP_AUTOSTART=0 in the MCP server’s environment forbids starting one: the tools then say NO_SERVER and name the command to run.

The rules are enforced by the running application (base/runtime.js in @usehenri/core), not by the MCP server, so a refusal is henri’s refusal:

  • Development only. The endpoints are mounted only when NODE_ENV is neither production nor test, nothing is recorded in production, and there is no flag that turns either on. Pointed at a production application, the tools answer PRODUCTION and stop; NODE_ENV=production is refused before a server would be started.
  • This machine, and no browser. The loopback check of /_routes and /_mailers, plus a required X-Henri-Runtime: 1 header and a refusal of anything carrying Origin or Sec-Fetch-Site.
  • Reads only, proved before the store is touched. One statement, of SELECT, WITH ... SELECT, EXPLAIN, SHOW or DESCRIBE, with the strings and the comments removed first so nothing hides in them. A statement carrying INSERT, UPDATE, DELETE, DROP, SET, LOCK, PG_SLEEP or a second statement comes back as REFUSED with the word that refused it and never reaches the database. Values travel as parameters. records refuses anything but a flat where of equalities, so no $where and no operator.
  • Redacted, by name and by shape. What filterParameters masks in the logs is masked here, in the log lines, in the recorded parameters, in the query rows, in the records and in a schema’s column defaults — never in a column’s name, which is the one place a name has to survive; password is masked whatever the configuration says, and reading through the model keeps the adapter’s own protections (a hash is not selected, a soft-deleted row does not come back). Those rules match a key, and the key of a query row is whatever the SQL said — SELECT password AS p renames the column to something no rule knows — so two values are read as well: a password hash and an encrypted column’s envelope are masked under any name, at any depth. Neither can be a value you asked to see, so nothing legitimate is lost. It is not a general secret detector: a deliberate SELECT of something else still returns it, which is one of the reasons this surface exists in development only.
  • Bounded, and it says so. 500 log lines kept, 25 errors, 100 rows a query, 25 records a page, 50 tables a store, 2000 characters a line, 40 stack frames. Anything cut carries truncated: true and the limit that cut it.

request is the one tool that can change data, because it is the application’s own endpoint doing it: a POST really posts, exactly as a browser would. It is never implicit – the method is GET unless the agent names another one.