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. See Types.

Every informational command takes --json:

Terminal window
henri routes --json # the expanded routes with their helpers and roles
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 read the files, without starting anything:

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.
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.
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.

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. What filterParameters masks in the logs is masked here, in the log lines, in the recorded parameters, in the query rows and in the records; 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).
  • Bounded, and it says so. 500 log lines kept, 25 errors, 100 rows a query, 25 records a page, 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.