Skip to content
Trilha
Chapters

Reference

CLI

The trilha commands and their options.

trilha new <dir> [--module path] [--template blog|app] [--with recipes] [--lang en|pt] [--agents]
    [--trilha-dir ../trilha] [--no-tidy]
trilha gen [--check] [--package name]
trilha generate page|route|test <url> | component <Name>
    [--methods GET,POST] [--bind Type] [--form Type] [--layout file] [--force] [--dir path] [--lang en|pt]
trilha dev [--addr :3000]
trilha build [-o bin/<name>]
trilha export [-o out] [--base /prefix]
trilha openapi [-o file] [--title T] [--version V] [--server URL] [--check]
trilha routes
trilha check [--json] [--fix]
trilha ctx [--json] [--routes|--types|--all]
trilha audit [--no-vuln]
trilha ui [--force] [--css-only|--js-only]
trilha ui describe [Name] [--json]
trilha ui patterns [name] [--json]
trilha agents [--force] [--lang en|pt]
trilha mcp [--write]
trilha mcp --from-routes [--include /api/v1/*]
trilha version
CommandWhat it does
newcreates a project with go.mod, layout, home page, 404, one API route, public/style.css and .gitignore; runs go mod tidy and gen
genscans app/ and writes trilha_gen.go; fails with one line per violated convention
generatewrites one skeleton — a page, an API route or a component — in the folder the convention asks for
generate crudreads a struct and writes the whole thing: listing, create, edit, delete, a store — in memory or in SQL — and a test
addwrites a framework recipe into the project: the audit trail, API keys, a settings section
devgen + go build + runs the app on an internal port + proxy on --addr + reload over SSE + route inspector on /_trilha/routes
buildgen + go build -trimpath -ldflags="-s -w" with CGO_ENABLED=0
exportgen + go build + runs with TRILHA_EXPORT to produce static HTML
openapiwrites the OpenAPI 3.1 document of the API routes (-o - to stdout)
routesprints METHODS PATTERN SOURCE for each route
islands buildcompiles islands/*.ts into public/ with the project's tsc; --check compares
checkthe single gate: gen, gofmt, vet, test, audit and openapi, in that order, stopping at the first failure
ctxthe map of the project — routes, API, types, setup — in one read, as Markdown or JSON
auditsecurity checklist before publishing (see Security); among the checks, a route that guards a module held by unit — auth.Grant("edit", auth.ScopeUnit) — and never calls Requirement.In(c, unit) with the record's unit
agentswrites AGENTS.md, CLAUDE.md, the quality skill and the pre-commit hook so a coding agent finds the conventions
hookswrites .githooks/pre-commit and points core.hooksPath at it
precommitthe staged files against the size limits: 5,000 tokens per file, 500 lines per source file
mcpMCP server over stdio for an agent without a shell; read-only unless --write

Commands run in the folder containing app/. The project's import path comes from the nearest go.mod, plus the subfolder, so an app can live inside a larger module.

trilha new --template#

--template picks the shape of the project:

ShapeWhat it writes
blog (default)a home page, an API route and a 404: the smallest thing that runs
appthe management app — login, shell, dashboard with charts, a listing and a form, with tests

The app project comes green: it compiles, trilha check passes and go test ./... passes without a single edit. Every route below app/ is behind a session because the middleware sits at the root of the folder — including the routes you add tomorrow — and the first administrator comes from ADMIN_EMAIL and ADMIN_PASSWORD, which the sign-in screen says out loud in dev when they are missing. No password is written into the project.

What comes in app#

Beyond the skeleton, it asks trilha add for the login and for the screens every internal application grows in its first month:

ScreenWhat it is
/entrar, /sairthe session, from the login recipe — the template has no login of its own
/perfilthe account of whoever is asking: name, password, e-mail confirmed at the new address, sessions
/organizacoeswhich organisation the session is in: switch, create, deactivate, its settings
/admin/auditoriathe trail of who did what, with ui.AuditTable
/admin/chavesAPI keys: issue, revoke, and the key shown once
/admin/configa settings section, drawn from the struct that declares it
/admin/usuariosthe people: invite, role, deactivate, reset
/admin/permissoesthe permission matrix, the roles, and what the viewer can do

They are the recipes and not a second copy — one source, so the template cannot age apart from what trilha add writes. That includes the login since 0.91.0, and since 0.100.0 the users, permissions, profile and tenant screens: they are written on the table the login recipe owns. app/admin/ requires the admin role, and somebody signed in without it gets 403 and not a redirect to the login: they are known, just not permitted, and sending them back to a login they already passed is a loop with no exit. /perfil and /organizacoes stay at the root, because they are about the person asking and not about administering.

--with decides which recipes a new project starts with, on any template:

trilha new minha-app --template app --with ""              # login only: the skeleton needs it
trilha new meu-site --with audit                           # on the blog template too
trilha new minha-app --template app --with audit,settings  # pick your own — login comes along
trilha new loja --with login,blob,mail                     # several, on the blog template

An empty --with is a choice, not an absence: typing it asks for the skeleton and gets it. --template app already asks for its own eight recipes when --with is absent; typing --with at all — even with one recipe — replaces that list rather than adding to it, so keeping the eight and adding one means naming all nine.

app/layout.go and app/middleware.go of the app template import the session login writes, so login is added to whatever --with names when it is missing — printed to stderr, so it is not mistaken for something that was asked for. A name --with gives that no recipe answers to refuses before anything is written, listing the ones that do:

$ trilha new demo --template app --with ""
template "app" needs recipe "login"; adding it automatically
...
$ trilha new demo --with bogus
error: unknown recipe "bogus"; valid recipes: api-keys, approvals, audit, blob, ...

Language#

CLI messages follow TRILHA_LANG, then LC_ALL, LC_MESSAGES and LANG: a value starting with pt (any case) selects Portuguese; anything else, including an unset variable, selects English. Messages from the runtime, the scanner and the generator (the ones that end up in your code and logs) are always in English.

trilha new --lang en|pt chooses the language of the generated texts (home page, 404, <html lang>); the default is the CLI's language.

trilha dev#

Besides the proxy and the reload, the supervisor serves the route inspector on /_trilha/routes: the table of routes in precedence order with layouts and middlewares per route, and a box that answers which pattern would serve a given path. The page belongs to the supervisor, not to the app, so it does not exist in the binary trilha build produces — see Development and production.

What only the browser can see#

Two of the errors that hurt most in the beginning never reach the terminal on their own: the CSP refusal that leaves an iframe grey, and the fragment that comes back as a whole page and ends up inside itself — a 200, so nothing errors anywhere. In dev the browser has somebody to tell:

⚠ CSP refused frame-src: http://localhost:8080/nota.pdf
  at /documentos/12 — use ui.Preview to show the file, or allow it with Security.CSPExtra{"frame-src": {…}}

⚠ the fragment of /documentos came back as the whole page
  the route answered without looking at c.Fragment(): return only the piece when it is asked for

The policy gains report-uri and Reporting-Endpoints only in dev, pointing at trilha.ReportPath (/_trilha/csp) — answered by trilha dev and not by the application, because the point of the report is the terminal — and the kit's script only reports when window.__trilha is there, which the dev script writes and production never does. In the binary trilha build produces there is no reporting endpoint, no extra directive and nothing to switch off.

The server catches a third one by itself: a c.Flash on a response that is not going anywhere. The message would show up on the next navigation, out of nowhere, and the log says so on the spot with the route.

trilha build#

-o names the binary; without it, bin/<project folder>. On Windows the output gets the .exe the system needs to run it, whether the name came from -o or from the default — trilha build -o bin/app writes bin\app.exe, because a file called app is one exec.LookPath refuses to execute. A name that already ends in .exe is left alone, and nothing changes on Linux and macOS.

trilha generate#

The convention is what costs to remember: that /blog/{slug} lives in app/blog/slug_/, that a catch-all folder ends in __, that a group ends in -. generate takes the URL and does the translation:

trilha generate page /blog/{slug}     # app/blog/slug_/page.go
trilha generate route /api/itens/{id} # app/api/itens/id_/route.go
trilha generate component Aviso       # internal/components/aviso.go

The page and the route come out compiling, with c.Param already reading each parameter, and trilha_gen.go is regenerated at the end — the URL answers before you open the editor. A component is a function returning h.Node, so it composes like any other; --dir puts it somewhere else (internal/icons, for instance).

The package name is the one already declared in the folder, when there is one; otherwise it comes from the folder name (slug_ → slug, relatorio.csv → relatoriocsv, type → type_).

An existing file is not overwritten without --force, and --force does not cover the one refusal that is a convention: a folder answers either a page or a route, never both.

The contract, not only the folder#

Without flags the skeleton is generic, and what is left to write — the struct, the Bind, the validation, the answer, the test — is exactly where a signature gets typed wrong. The flags write that part:

trilha generate route /api/posts/{id}/comments --methods GET,POST --bind Comment
trilha generate page /contact --form Contact --layout app/layout.go
trilha generate test /api/posts
  • --methods writes one handler per method, in the signature the scanner reads, with c.Param("id") already there for each parameter of the path.
  • --bind Type makes the methods that carry a body do c.BindJSON(&in): returning that error is the 422 with the fields, so there is nothing else to handle. A type the project already declares is imported from where it is; one it does not have is born in the route's package with example json and validate tags. A name declared in two packages is refused, and the message says to write posts.Comment.
  • --form Type on a page writes the whole round trip: trilha.CSRFInput, one ui.Field per field with the message beside it, 422 with trilha.FieldErrors when the Bind refuses and POST → redirect → GET when it accepts.
  • --layout <file> writes the layout.go that is missing above the page. A path that does not wrap the page is refused: the scanner would never apply it, and finding that out costs a round trip.
  • generate test <url> writes the test next to the route, in its own package, with one case per method the scanner finds — and a body built from the tags when it can read the type the handler binds. Right after generating, trilha check is green with nobody editing anything.

--lang en|pt chooses the language of the comments in the skeleton; identifiers, field names and error messages stay in English.

trilha ui#

Writes or updates the UI kit in public/: ui.theme.css (only created; it is your theme), ui.css and ui.js (updated; if edited locally, only with --force). --css-only and --js-only limit what is touched. trilha new runs the same step. See UI kit.

trilha ui describe#

Prints the catalogue of the kit: with no argument, every component grouped and summarised in one line each; with a name, its signature, what it is for, the fields of an options struct, an example and the symbols the documentation cites.

trilha ui describe            # everything, grouped
trilha ui describe Field      # one component
trilha ui describe ui.field   # the same one: the prefix and the case are optional
trilha ui describe --json     # the whole catalogue as JSON

The catalogue is built from the kit's own doc comments and ships inside the binary, so it answers with or without a project around it and cannot drift from the code it describes. An unknown name exits non-zero with the closest names. --json is there for the agent writing the screen: one call and it knows what exists and how each thing is spelled, instead of guessing at a name and finding out at compile time.

trilha ui patterns#

Prints the kit's screen patterns — the compositions, where describe prints the parts: a listing with filter, an async form, an approval inbox, a dashboard, master-detail, an upload with progress. With a name, the whole pattern: the components, the data the page needs, the accessibility notes and a complete page.go of at most 60 lines that compiles.

trilha ui patterns                   # one line each
trilha ui patterns list-with-filter  # one of them, in full
trilha ui patterns --json            # every pattern, the shape an agent reads

The snippets are the files in examples/patterns, compiled with the repository; the same data is ui.Patterns() in Go and the tool get_pattern over MCP. See Patterns.

trilha client#

Generates the Go client of an API that already exists, from the OpenAPI document that API publishes. It is the other direction of trilha openapi, which writes the document of your routes.

trilha client openapi.json                       # writes internal/api/client.go
trilha client https://api.example.com/openapi.json --out internal/acervo
trilha client openapi.json --check               # fails when the file is out of date
trilha client openapi.json --verbose             # names every untyped operation
trilha client openapi.json --fail-on-untyped     # exit code, for CI

One file, deterministic, committed like trilha_gen.go. Inside it: a struct per schema of components/schemas, with json tags and — where the document says required, minLength, format: email, enum — the validate tags of Validation, so the same type is the answer of the API and the Bind of a form. A method per operation, grouped by tag: path parameters in the signature, query parameters in a struct, a JSON body as the schema's type, and a binary answer as the *http.Response, so it streams into c.Pipe.

The method's name is the operationId minus the tail that only repeats the path and the method, which is what FastAPI writes: list_documents_api_documents_get is ListDocuments. What is left loses the tag in one position and no other — the tag spelled exactly, at the start of the name, on a word boundary — so config_reset in the tag config is Config.Reset(), and that cut is a line of the report, because a name the generator shortened is a name the document does not have. Everywhere else the name stays whole: the tag at the end (obter_resumo_dos_idiomas in the tag idiomas is Idiomas.ObterResumoDosIdiomas()), the singular of the tag (responder_card in the tag cards is Cards.ResponderCard()) and the tag that merely spells the start of a word (config in configurar_regra is Config.ConfigurarRegra()). Documents.ListDocuments() is repetitive next to the group and it is right: a repetitive name is read, while an amputated one — ObterResumoDos(), a preposition with no object — sends whoever is writing the screen to open client.go.

Every identifier in the file is ASCII, and no two of them are the same. A tag is the label of a page — verificação de assinaturas is what the API's /docs shows — so an accented letter falls to the letter underneath it: the group is c.DadosPublicosMCP(), not a name anyone has to type with a dead key, and the label the document wrote stays in the comment of the type, which is where it is read. When a tag spells the name of a schema — the tag auditoria and the schema Auditoria — the group is the one that yields, because the schema's name came from the document and the group's is this generator's invention: the type becomes AuditoriaAPI, while the call stays c.Auditoria() and the query struct still starts with Auditoria. Each name the command had to invent is a line of the report, instead of news that waits for the go build of whoever calls the client.

A query parameter the document declares as T | null — which is how Pydantic writes every competencia: str | None = None — is a pointer in the struct, and the pointer is the answer to "is this one being sent?". nil leaves the parameter out of the URL; anything else sends the value behind it, the empty string included, which is how you filter by the empty value. A field that is not a pointer keeps the rule it had: the zero value stays out, because there it is the only way to say "not this one". A nullable list is still a list — one parameter per element, none of them when it is nil.

An optional scalar with an OpenAPI default is a pointer for the same reason. It preserves omitted versus explicitly sent zero (false, 0, ""), which a value field with omitempty cannot represent. Generated names and comments remain valid UTF-8 when a description begins with a multibyte character.

since := "2026-09"
page, err := c.Documents().ListDocuments(ctx, api.DocumentsListDocumentsParams{
	Page:  1,
	Since: &since, // ?since=2026-09
	State: nil,    // not in the URL at all
})

A field of a schema follows the same rule, whichever way the document spells nullable — "type": ["string", "null"] in 3.1, nullable: true in 3.0, the anyOf of Pydantic — and it follows it even when the document also says required:

// in the document: the colour always comes, and it is null when nobody chose one
"primary": { "type": ["string", "null"], "description": "Primary colour in hex, or null." }
// Primary colour in hex, or null.
Primary *string `json:"primary"`

The pointer is there so that null has a way of being said: without it null decodes to the zero value, and the app cannot tell "not configured" from "configured empty" — harmless in a colour, and a difference that disappears in silence in an ["integer","null"] whose zero means something. The required is not in the validate tag, because over a pointer required means "not nil", and "primary": null is an answer the server is right to give: a tag that called it invalid would blame the server for being correct. The other rules the document states — minLength, format, enum — stay. A slice, a map and a json.RawMessage are left as they are: nil already says null there, and *[]string is not a type anybody wants to write.

A multipart/form-data body is read as the form it is — through the $ref, because a FastAPI never writes that schema inline: it declares a Body_<operation> component and points at it. One binary field and nothing else stays two arguments — file io.Reader, filename string. Anything else becomes a typed struct, so no field of the form is silently dropped:

_, err := c.Certificates().UploadCertificate(ctx, api.CertificatesUploadCertificateForm{
	File:  api.FilePart{Filename: "cert.pfx", Content: f}, // io.Reader: it streams
	Senha: "…",                                            // required, so it always travels
})

_, err = c.Documents().ImportDocuments(ctx, api.DocumentsImportDocumentsForm{
	Files:   []api.FilePart{a, b, c}, // three parts under the same name, in this order
	Comment: "September batch",       // a text field of the same form
})

A []FilePart becomes several parts under one name, in the order of the slice, which is what a list[UploadFile] on the other side reads. An optional scalar left at its zero value does not travel, the same rule the query follows. Nothing is buffered: the body is an io.Pipe, so a file larger than the memory of the process crosses it.

New(base, WithHeader(...), WithClient(...), WithResponse(...)) is the whole surface of the constructor: the client holds no credential of its own, and WithHeader runs per request, which is where the session's token goes. The generated file imports the standard library and nothing else — not even Trilha — so it also works in a job, in a test, in a binary that is not a web app.

WithResponse is the other direction: it runs once for every response that arrives — including the ones that become an *Error — and hands over its status and its headers. It is how you read what is not in the body: the Set-Cookie of a login on an API authenticated by cookie, the ETag or the Location of a POST that creates, the Link of a page, the Retry-After of a refusal. The hook takes the context of the call, and that is what makes it safe on a server: the collector lives in the context, so each request fills its own. A http.CookieJar on the http.Client would not — the jar belongs to the process, so one person's credential would go out on the next person's call.

type harvestKey struct{}

var Acervo = api.New("http://api:8801", api.WithResponse(func(ctx context.Context, r *http.Response) {
	if h, ok := ctx.Value(harvestKey{}).(*[]*http.Cookie); ok {
		*h = r.Cookies()
	}
}))

func login(c *trilha.Ctx, in api.Credentials) error {
	var harvest []*http.Cookie
	ctx := context.WithValue(c.Context(), harvestKey{}, &harvest)
	if _, err := Acervo.Sessions().Login(ctx, in); err != nil {
		return err
	}
	for _, ck := range harvest { // this request's credential, and nobody else's
		c.SetCookie(ck)
	}
	return nil
}

The body is not part of the deal: it belongs to the operation, which decodes it — or to the caller, when the answer is bytes — so reading it inside the hook takes it away from whoever asked. What the body holds, the operation already returns.

A status outside 2xx is an *Error with Status, Body, the Detail pulled out of problem+json or of the {"detail": ...} a FastAPI writes, and — when the answer is the validation error of a FastAPI, {"detail": [{"loc": [...], "msg": "..."}]} — one message per field in Fields. AsError is errors.As without the variable, and Fields is a map[string]string, which is FieldErrors underneath, so the form re-renders itself with each message beside its input instead of parsing Body by hand on every page:

if _, err := c.Users().Create(ctx, in); err != nil {
	if e, ok := api.AsError(err); ok && e.Status == 422 {
		return c.Render(page(in, trilha.FieldErrors(e.Fields)))
	}
	return err
}

The loc loses the part that only says where the value travelled — body, query, path, header, cookie — so the key is the name the form uses: email, itens.0.valor. An error that is not that shape leaves Fields nil and Detail exactly as before.

What it will not guess at, it says out loud: oneOf and anyOf and a schema with no type come through as json.RawMessage, and each one is a line of the report the command prints. allOf is flattened into one struct, a $ref that closes a cycle becomes a pointer, and an operation with no operationId gets its name from the method and the path — also a line of the report.

An operation whose answer has no schema is the same thing, one order of magnitude bigger: an API written without response_model types nothing at all, and a client that gives back json.RawMessage a hundred times compiles and helps with nothing. So the command ends with the count:

  97/110 operations have no response schema — returned as json.RawMessage.
  In FastAPI, declare response_model=... so the client can type the answer.
  internal/api/client.go written, package api.

--verbose names each one — they are on the count instead of on the report above, because ninety-seven lines saying the same thing is not a report — and --fail-on-untyped turns the count into an exit code for a CI that wants it to reach zero. The file is still written either way.

The recipe An app in front of an existing API has the two halves side by side: the page reading the API through this client, the islands reading it through Config.Upstreams, one session token for both.

trilha vendor#

Puts one JavaScript module in the repository. An island that needs a helper — a template library, a chart, a component runtime — gets it as a file under public/vendor/, downloaded once and pinned:

trilha vendor preact@10.19.3           # public/vendor/preact.js + a line in vendor.lock
trilha vendor htm@3.1.1
trilha vendor                          # what is pinned
trilha vendor --check                  # re-hash the files against vendor.lock
trilha vendor preact@10.19.3 --from https://cdn.example.com

It downloads exactly what was asked for and resolves nothing: there is no dependency tree, no node_modules, no install step, and a module that needs a resolver is the wrong module for an island. vendor.lock holds the name, the version, the sha256 and the URL, and it is committed — so a version bump is a diff, and a file that changed under a version that did not is what --check catches. trilha audit says the same thing about a file in public/vendor/ that vendor.lock does not name.

The default source is https://esm.sh, which serves npm packages as ES modules; --from or TRILHA_VENDOR_BASE points anywhere else. Nothing about this is a framework dependency: the file is served like any other asset, and the island imports it by path.

trilha islands build#

An island that outgrows a file can be written in TypeScript, and the kit still has no bundler. The sources live in islands/, mirroring public/, with their own islands/tsconfig.json:

npm install --save-dev typescript      # the project's compiler; the kit brings none
trilha islands build                   # islands/game/island.ts → public/game/island.js
trilha islands build --check           # compile elsewhere and compare with public/

It runs tsc -p islands/tsconfig.json --rootDir islands --outDir public — the compiler from TRILHA_TSC, else node_modules/.bin/tsc, else the PATH — and does not emit anything when the types do not check. Every .js and .d.ts it writes starts with // Code generated by trilha islands build from islands/<file>.ts. DO NOT EDIT., and a source map is shifted by that line and points at the source from public/. The output is committed, because go build has no node: --check (and the islands gate of trilha check, which runs only in a project with islands/tsconfig.json) fails on a file that differs, one that is missing, and one that carries the banner but whose source is gone.

trilha migrate#

Reads a Next.js project and writes the two things that are mechanical about a migration: the folder tree of app/, with one Go file per screen, and a report of everything that is not.

trilha migrate next ../web --dry-run   # prints the report, writes nothing
trilha migrate next ../web             # writes app/ and MIGRATION.md
trilha migrate next ../web --out app --report MIGRATION.md --force
trilha migrate next ../web --out /tmp/x/app     # report goes to /tmp/x/MIGRATION.md
trilha migrate next ../web --actions   # only the Server Actions table, writes nothing

The report lands beside the tree, which is what its own first line promises: with no --out that is ./MIGRATION.md next to ./app, and with --out /tmp/x/app it is /tmp/x/MIGRATION.md. An explicit --report still wins, and the command ends by printing both paths in full.

What it writes compiles: each page is a Page function with a title and the route's parameters, each route.ts becomes the handlers it exported returning 501, and trilha gen right after leaves the project green. Nothing is overwritten without --force, so running it on a project that already has screens adds what is missing and keeps what is there — the count at the end says how many were written and how many were kept.

The doc comment above each function is the part that matters: it says which file it came from, how many lines it had, which hooks it used, which endpoints it called, and which of three shapes the screen probably is — A a form or a list with no island, B a page with one island, C an app that really is a client. That is a suggestion printed with its reason and the line the reason came from — C — live svg (fluxos/FlowCanvas.tsx:12) — not a verdict, and the line is there so a wrong one can be thrown out without opening the file. MIGRATION.md gathers the same thing in one table, plus the list of what has no equivalent here: loading states, templates, parallel and intercepting routes, middleware and rewrites, each with the sentence explaining what takes its place.

The suggestion is about the screen, and only about the screen. A component the page imports counts for it — a fifty-line page.tsx in front of a three-hundred-line component is not a fifty-line port — but the frame is not the screen: what a layout.tsx reaches is listed once, under Global dependencies, and does not promote the pages it wraps. Neither does a barrel: import { DataTable } from "@/components" follows the line of the index.ts that forwards DataTable and no other, because re-exporting is not using. Without those two rules a shell carrying a chat with an <svg> classified every screen of the application as C, which is an order of work that says start anywhere.

A module counts for what the page imported from it, by name. import { entrarNoPortal, portalPublico } from "@/lib/portalApi" is read as those two functions and as whatever they call inside that file; the portalUpload beside them is somebody else's screen. Without that, a form that reads an invitation and sets a password came back as an upload island, and the reason pointed at a line it never runs. What the module does on its own account still counts: a function body runs when something calls it, but a top-level value — const es = new EventSource("/api/chat") beside the functions — is evaluated the moment anything imports the file, so it belongs to every screen that touches it. Nobody has to say which names came in for a default or a namespace import (import * as api), so there the whole module counts and the reason says whole module — which is the marker to look for when a suggestion seems too conservative.

Server Actions get a table of their own. A page that writes through "use server" calls no endpoint, so a report organised by URL used to describe what every screen draws and say nothing about what it changes — in an App Router application that is half the product, and the A printed beside those screens reads like "nothing to do". The Server Actions section lists each action the screens import with the file, the line and the screens that call it; the class cell carries the count (A — no island signal · 4 server actions); and --actions prints that table alone, which is where whoever writes the contracts starts. Each one becomes a write handler and a form that posts to it — the handler writes and redirects, the page renders. An action is not an island signal: it is the opposite of JavaScript in the browser, and it does not change the class.

A modal is a B signal even when the component was renamed: identifiers ending in Dialog, Modal, Drawer, Sheet or Popover count, as do showModal(), confirm(), role="dialog" and aria-modal. The report therefore recognizes a project's StartInstanceDialog instead of requiring the literal kit component name.

For the same reason an <svg> on its own is not a drawing surface. A logo, an icon, a chevron: almost every screen has one, and reading the tag as C put four of the twenty screens of a real migration in the hardest class there is. What makes it C is something working on it — a ref on the element, onWheel, requestAnimationFrame, a drawing library — or a pointer handler and a chart library, which classify on their own anyway. A <canvas> still counts by itself: nobody puts one there for decoration.

Media pulls the other way, and the direction of the error is why it matters. A screen that records voice — getUserMedia, MediaRecorder, SpeechRecognition — has no <canvas> and may have no handler at all, and it used to come back A: a form. But a device permission, a stream and an object with a life of its own are the purest case of the browser doing the work there is, and there is no post-redirect-get that replaces them, so capture is C. Playing is not capturing: new Audio(, an <audio> or a <video> is an element the server can draw, so it is B. A false positive costs one check; a false negative makes somebody port half a screen as a form before finding the microphone.

Every signal is looked for with the comments taken out. A JSDoc saying "the SpeechRecognition transcript" in a pure module used to make each screen importing it — even a single type — a C; strings stay, because type="file" and role="dialog" are signals.

The screens themselves are not translated. A page's body is business logic, and a machine guessing at it would cost more to review than to write — the guide From Next.js to Trilha has the React pattern beside the line that replaces it.

trilha agents#

Writes three files, and only when asked: support for coding agents is opt-in, so trilha new on its own leaves neither behind. trilha new --agents adds them at creation time.

FileWho owns it
AGENTS.mdthe framework: the conventions, the commands, the ten rules of how code is written here, and what not to do
.claude/skills/trilha-quality/SKILL.mdthe framework: the checklist behind the ten rules — renders, lazy loading, requests and cache, queries and N+1, images, fonts and scripts, design patterns, clean code, reuse, review for size, OWASP and NIST — with what to grep for. Claude Code loads it as the trilha-quality skill; any other agent reads it by the link in AGENTS.md
CLAUDE.mdyou: three lines pointing at AGENTS.md, plus whatever this repository needs

AGENTS.md and the skill carry a stamp with the hash of their own body (the skill's sits in its front matter), the same rule the ui kit uses. An untouched copy from an older version is refreshed in silence on the next run; one you edited is only overwritten with --force, and without it the command stops and says so. CLAUDE.md is never overwritten.

--lang en|pt picks the language of both files and defaults to the CLI's.

Run it again after upgrading the CLI: AGENTS.md names the commands of the version that wrote it, so a copy from an older release keeps sending the agent to commands that were replaced. The whole sequence for a project coming from an older version is in Migration.

trilha hooks and trilha precommit#

trilha agents also writes .githooks/pre-commit and, in a git repository, points core.hooksPath at it; trilha hooks does only that. The hook lives in the repository, so every clone has the same one, but core.hooksPath is local: whoever clones runs trilha hooks once. A repository whose core.hooksPath already points elsewhere is left alone — call .githooks/pre-commit from your own hook. The script is the framework's, stamped like AGENTS.md.

The hook runs trilha precommit (or go run of the version in go.mod, without the CLI on the PATH), which reads the staged files from the index — what the commit carries, not the working tree — and stops the commit when one is over a limit:

LimitApplies to
5,000 tokens (bytes/4)every file an agent writes, Markdown and tests included
500 linessource: .go, .js/.ts/.tsx, .sql, .sh, .py and the like; tests (_test.go, .test., .spec., testdata/), docs, CSS and Markdown are exempt

Not checked at all: lockfiles (go.sum, package-lock.json, yarn.lock…), vendor/ and node_modules/, binaries, .golden files, and generated files — a Code generated ... DO NOT EDIT. header, the stamps of the framework's own files, or linguist-generated / linguist-vendored in .gitattributes. The message lists each file with its count and says how to split it: by seam, into files of the same package named after what they hold, never by line count. --all checks every tracked file, to size a project before turning the hook on.

Reads app/, deduces the document from the handlers and writes openapi.json. -o - writes to stdout; --title, --version and --server fill what the code cannot know (they default to the module name, 0.0.0 and no server). --check compares with the file on disk and exits 1 when they differ — the same line gen --check is, for the same reason:

- run: trilha openapi --check

The document also carries what the application sends: every webhook Emit with the event name written in it becomes an entry in the webhooks section of OpenAPI 3.1, with the payload's schema and the delivery headers. A document with only paths describes half of an integration.

What is deduced and the openapi: directives are in APIs.

trilha check#

Eight gates in one command, in the order that fails cheapest first: gen, islands (only if the project has islands/tsconfig.json; see islands build), gofmt, vet, test, audit (without the vulnerability scan, which needs the network), openapi (only if the project keeps the document — the root openapi.json and every copy under app/, such as the one mcp.FromRoutes embeds) and i18n (only if the project keeps a catalog folder). It stops at the first failure — what comes after a broken build says nothing about the project — and the steps that never ran say so:

✓ gen
✗ gofmt (failed)
    app/blog/page.go: not gofmt'd
    → run gofmt -w (or trilha check --fix)
- vet (not run)
- test (not run)
- audit (not run)
- openapi (not run)
- i18n (not run)

Every problem carries the file, the line and the sentence that resolves it. --fix rewrites trilha_gen.go and the formatting before judging them, and the step then reports fixed. --json writes the report a tool reads, with the same fields:

{
  "ok": false,
  "steps": [{ "tool": "gen", "status": "failed" }],
  "problems": [
    {
      "tool": "gen",
      "file": "app/page.go",
      "line": 3,
      "message": "page.go must export func Page(c *trilha.Ctx) (h.Node, error); found func Render",
      "fix": "rename the function to Page, or delete page.go if this directory is not a page"
    }
  ]
}

Exit 1 when anything failed, so in CI it is the single line:

- run: trilha check

trilha i18n#

The keys c.T uses live in the code; the sentences live in <locale>.json inside the catalog folder — app/i18n/, next to the package whose //go:embed i18n carries it, or i18n/ at the root of the project; the CLI takes whichever exists. These two commands are how the two stay in step — no build, no types: the code is read for c.T("key") under app/ and internal/.

$ trilha i18n extract                 # every key the code asks for, sorted
atendimento.bemvindo
atendimento.protocolos
$ trilha i18n extract --write         # adds the missing ones to the default locale
✓ 24 keys used, 3 added to app/i18n/pt-BR.json
$ trilha i18n missing ht              # what Haitian Creole still lacks (exit 1 if any)
atendimento.protocolos

--write puts the key itself as the text, so the screen says something readable while the translation is written, and it never touches a message that is already there. The default locale is the first of cfg.Locales in app/setup.go, or en when the project never said.

The i18n step of trilha check is the same reading: a key used in the code and missing from the default locale fails the check — that is the screen showing atendimento.titulo to somebody — and a key another locale has not translated yet is a warning line, because a translation always arrives after the code that needs it and the fallback chain answers meanwhile.

trilha generate crud#

Between trilha new --template app, which brings one finished CRUD as an example, and trilha generate page, which writes an empty page, sits the task people repeat most: I have a struct, I want the screen. Ten times in one project, each with the same skeleton and a different small mistake — one has no delete confirmation, one does not show the validation error in the field, one loses the page on the way back.

trilha generate crud docs.Tipo --at app/admin/tipos
  + internal/docs/tipo_store.go       TipoStore: List/Get/Create/Update/Delete, and memory behind it
  + app/admin/tipos/page.go           listing: ui.DataTable, search, sorting, pagination, delete
  + app/admin/tipos/new/page.go       create: ui.Field per field, 422 comes back with the messages
  + app/admin/tipos/id_/page.go       edit: the same form, filled in
  + tipo_crud_test.go                 empty list, create, edit, 422, delete
  + app/setup.go                      the store, where the pages find it

What comes out is code somebody reads and edits, in the shape templates/app already proved idiomatic — not a runtime that hides the screens behind a call. It exists so the tenth screen of a project looks like the first.

How the fields become the screen#

The struct is read by static analysis, the same way --form already reads one: no compiler, no module download.

  • ID is the key, and a struct without one is refused with that sentence. The generator does not pick a field: the wrong key does not show up until the first Update, and by then there are five screens written on top of it.
  • CriadoEm/CreatedAt/AtualizadoEm/UpdatedAt and json:"-" stay out of the form. They belong to the system, and a date somebody types by hand is a bug waiting. The store stamps them: created stays created on an update, because that is the one date people go looking for.
  • validate:"required,max=80" becomes required and maxlength on the control, and the message beside the field when it fails.
  • The label comes from the label: tag when the struct has one, and from the field name in title case when it does not. An accent and a unit — "Retenção (anos)" — are not derivable from a field called Retencao, and the same label goes to the column and to the field.
  • A bool is Yes/No, in the language asked for: true on a screen is the name of a variable, not an answer. A time.Time the store stamps is a read-only column, written by ui.Date in the app's language and zone — it stays out of the form, because a date somebody types by hand is a bug waiting.
  • A field the CRUD cannot draw yet is said out loud, one line per field, and the file carries a comment where it would have gone. A field that disappears in silence is a field somebody looks for later.
  • The first four fields become the table's columns. Four is what a table shows before it starts scrolling sideways.

A CRUD under a closed folder#

The generator looks at what is above the destination. If any middleware.go sits between app/ and --at, the screens are behind something, and the generated test has to open a session before its first request — otherwise it answers 401 and reads as "the generator is broken".

When the project has a known way to do that — the login recipe's internal/sessao/sessaotest, or the app template's internal/session/sessiontest — the test calls it and goes on proving what it exists to prove. When it does not, the test carries a t.Skip naming the file that closes the folder: a Skip that explains is better than a 401 that does not. Either way the command says which of the two happened.

Nothing is overwritten, and there is no --force#

A file that already exists is a refusal naming it. That is deliberate: a generator that overwrites is a generator nobody runs twice, and a CRUD is precisely what you generate after having edited one.

Running it again is worth something anyway, because the reason somebody does is that the struct grew a field. With every screen in place the command writes nothing and says what changed:

$ trilha generate crud docs.Tipo
already generated — nothing was written.

  Descricao is not in the listing
    add {Key: "descricao", …} app/tipos/page.go:21
  Descricao is not in the form
    add ui.Field("descricao", …) app/tipos/new/page.go:34
  Descricao is not in the form
    add ui.Field("descricao", …) app/tipos/id_/page.go:37

It exits 0: running it again is a normal thing to do, and what it prints is information. It does not add the field for you — writing inside a file you have edited is overwriting with extra steps — it hands you the line and the place. With the struct unchanged it says that too, in one line. Half a CRUD on disk is still the refusal that names the file: that is a different problem.

The single exception is app/setup.go, which the generator edits rather than refuses — one line, into a function whose shape the framework defines. Without it the CRUD compiles and answers 500 on the first request, which is the worst outcome a generator can have, because it looks like it worked. The command names that file in its output, because a generator that changes something you did not ask for owes you the sentence.

The store is an interface, with memory behind it and SQL beside it#

The same choice every store in this framework makes, and here it pays twice: what comes out of the generator runs — the screen opens, the form saves, the generated test passes — and the database is the same five methods, which --store writes for you.

type TipoStore interface {
	List(ctx context.Context, q TipoQuery) ([]Tipo, int, error)
	Get(ctx context.Context, id string) (Tipo, error)
	Create(ctx context.Context, v Tipo) (Tipo, error)
	Update(ctx context.Context, id string, v Tipo) (Tipo, error)
	Delete(ctx context.Context, id string) error
}

Both halves of that signature earn their place. The context is the request's, taken from c.Context() by every screen: when the person closes the tab or the route's deadline runs out, the query stops instead of going on for nobody. "It is not there" is an error — trilha.ErrNotFound, which the framework already turns into the app's own 404 — and not a second return value, so a handler forwards it in one line and a database that is down is never written down as a row that is gone. That is the one lie a screen must not tell, and a store that can only answer yes or no has no way to avoid it.

--store sqlite|postgres#

trilha add store                 # once per project: the pool, the dialect, the migrations
trilha generate crud docs.Tipo --at app/admin/tipos --store sqlite
  + internal/docs/tipo_store_sql.go        the same interface over database/sql
  + internal/docs/tipo_store_sql_test.go   the statements, asserted without a database
  + migrations/0002_tipos.sql              the table, in the dialect you asked for
  ~ app/setup.go                           the SQL store, after store.Setup

It is written against the store recipe, which is what that recipe exists for: store.Sortable and store.OrderBy for the ordering that came from the URL, store.Paginate for the page ceiling, store.Like for the search box, d.Arg(n) for every value, store.NotFound for sql.ErrNoRows. Nothing a visitor types is ever concatenated into a statement, and the generated test asserts exactly that, in your project, with no database to run. Without the recipe the flag is a refusal naming trilha add store: a generator that writes against a package that is not there hands you a project that does not compile.

Three things worth knowing before you run it:

  • The memory store is still written, and on purpose: it is the reference implementation of the five signatures, and the store a unit test of yours gets without booting a database. A compile-time var _ TipoStore = (*TipoSQL)(nil) keeps the two from drifting apart.
  • The Provide goes at the end of Setup, after store.Setup(a) — store.DB does not exist before it, and a pool captured as nil fails on the first request rather than at boot.
  • The generated CRUD test skips without DATABASE_URL. Booting the app means opening the database, and the driver behind it is a go get your project makes and the framework cannot make for it. trilha check is green anyway, and the store's own test — the one about the statements — runs regardless.

Tenant, policy and compact schema forms#

The three optional flags compose, including with --store:

trilha generate crud docs.Tipo --at app/admin/tipos \
  --store postgres --tenant --policy docs --schema
  • --tenant carries c.Actor().Tenant through every read and write, adds tenant_id to SQL migrations and predicates, and writes a RequireTenant middleware. The login recipe must exist.
  • --policy docs writes a folder middleware using the permissions recipe's matrix at the administrar level. The value is the policy module, not a role name.
  • --schema keeps Bind and the typed store, but renders the form from a compact trilha.Schema with ui.SchemaForm instead of one explicit ui.Field call per field.

When --tenant and --policy are combined, the generated middleware applies both checks. The command refuses before writing anything when the required login or permissions recipe is not present; guessing an authentication package would produce code that only looks generated.

trilha add#

Half of what a management app needs is a pattern, not a primitive: the trail of who did what, the screen that issues API keys, the settings section somebody edits instead of redeploying. In the framework they would be rigid; as documentation they are work to copy. trilha add writes them into the project as files it then owns.

trilha add              # what there is, one line each
trilha add audit
trilha add audit --dry-run
trilha add login users audit --lang pt   # several at once, in order; a flag counts anywhere
trilha add --list --json    # for the MCP server and an editor's agent
  + internal/auditoria/store.go
  + app/auditoria/page.go
  ~ app/setup.go (one line added)

Run `trilha dev` and open /auditoria. Guard it: the trail says who did what, and that is
not for everybody.
Doc: https://trilha.dev/reference/observability

The difference from generate is the direction: generate crud writes from your code — a struct becomes a screen — and add writes from a recipe of the framework's. Both end with gen, and both leave trilha check green.

Several names go in one call and are applied in the order given, each with its own block of output; --lang and --dry-run count wherever they stand. Every name is checked before the first file is written, so trilha add login typo writes nothing and names the typo.

The price of each recipe#

Every line of trilha add (and every entry of --list --json, as ctx_pack_tokens) carries a price: what trilha ctx --pack <recipe> costs, in estimated tokens, once the recipe is in a minimal project — the reading an agent does to know what arrived. It is measured, not guessed: a test installs every recipe and holds the number to the pack, so a recipe that grows a screen grows its price in the same commit. add ends with the price measured in your project, after gen — a real project, with layouts and the conventions it uses, costs a little more than the minimal one — so it is the number trilha ctx --pack will cost there, labeled est. like every number from the shared estimator.

  billing           ~366 tok (est.)   charging without a coupled provider: …

Running it twice adds; it does not start over#

A file that is already there is skipped with a note, not overwritten and not refused. By the second run the file belongs to whoever received it, and they have probably edited it.

The one file a recipe edits is app/setup.go, and the insertion is marked:

func Setup(a *trilha.App) error {
	// trilha:add audit
	a.Config().Audit = auditoria.Store
	return nil
}

That marker is what makes the second run recognise its own line instead of adding a second copy of a store nobody wanted twice. Three recipes leave one import block, not three one-line groups — the file is somebody's to read.

--dry-run prints all of that and writes nothing, which is what to run before letting a command touch a project that already has code.

The recipes#

RecipeWhat it writes
adminthe backoffice: app/admin/ denied by default, made of users, audit, approvals and search (page)
auditthe sink Config.Audit receives, and the screen that reads it with ui.AuditTable
billingcharging without a coupled provider: plans, subscriptions on a closed state machine, signed idempotent webhook, dunning, CSV (page)
blobkeeping files somebody sent: upload, list, serve — with the key as the digest
api-keysthe issuer, the screen that creates and revokes, and the key shown once with ui.SecretOnce
approvalsthe queue that waits for a person: open, decide, and the deadline that expires
channel-whatsappreceiving from the WhatsApp Cloud API: signature, normalised message, dedup, and the two ways to answer
connectionsthe external services this app talks to: name, URL, sealed secret, and the Test button
logina session of your own: the users table, the sign-in screen and the way out
mailthe file this app sends e-mail from: one function per message, and a test with no network
notifynotifications by e-mail, webhook or WhatsApp: preferences, quiet hours, daily digest, per-channel limit, outbox (page)
permissionsthe permission matrix as data, the screen that edits it, roles created and removed, who has what
profilethe account screen: own name, password, e-mail confirmed at the new address, sessions — the id comes from the session
public-lookupa code plus a second factor: the public timeline of one case, without an account
share-linka signed link with a deadline: access to one thing, without an account
settingsa section declared as a struct, and the screen ui.SettingsForm draws from it
tasksthe work that does not fit in a request: queue, dedupe, retry, and the screen
tenantorganisations: create, switch, activate or not, members, per-organisation settings
webhookswhat this app announces to the outside: signed delivery with retry, and the screen for it
usersthe people screen: invite, role, deactivate, reset — written on the login recipe's table

Nine of these — admin, approvals, billing, channel-whatsapp, notify, permissions, profile, tenant, users — declare a Needs: a file another recipe writes that has to be there first. trilha add checks it before writing anything, and refuses by name — "run trilha add login first" — rather than leaving a project with files that do not compile because the package they belong to was never written. Needs is why login is worth adding before the rest: it is the one the others wait for.

login is the one the others wait for — a screen that invites somebody or changes their role is a screen about a table of people. It writes no password: usuarios.New reads ADMIN_EMAIL and ADMIN_PASSWORD from the environment, and with neither the table comes up empty and says so, because a recipe that seeded admin/admin into a real project would be a door left open by a tool somebody trusted. It also writes its own test — the sign-in, the refusal and the way out — which is what trilha check runs a minute later.

users is the first recipe written on top of another: it adds a file to the package login wrote, so trilha add users without it refuses and says what to run first, instead of leaving five files that do not compile in a project somebody now has to clean up. Inviting creates the person with no password and hands out a link — an administrator who picks somebody's password is an administrator who knows it — and the link's token is stored as a hash, worth 48 hours and one use. Deactivating does not delete: the audit trail points at who did what, and a deleted row leaves the trail talking about an id that is no longer there. The invitation page lives outside the guarded folder, because whoever opens it has no session yet.

permissions is the one that removes the most code: with a matrix, the if role == "admin" spread over eighteen files stops being written. It writes the policy as data — modules, ordered levels, what each role has — the functions that read it, and the ui.PolicyGrid screen. The screen that edits the matrix is guarded by the matrix itself, because a permissions screen behind an if on the role is a matrix with one exception living outside it. Below the grid are the roles: who has each one, a form that creates one (born with what leitor has, so it is safe before anybody edits its row) and a button that removes one — refused while somebody still has it, because taking a role away from a person silently is a person who can do nothing and does not know why. Last, what I can do: the matrix from the viewer's side, module by module, which is the answer to "why can't I" before it becomes a ticket.

profile is the one where the same bug is written most often: the form carries the id and the server trusts it. There is no id on that screen, in any form — it comes from the session, the only place that knows whose account is being changed. Changing the password asks for the current one even with a session open (a machine left unlocked for two minutes should not become an account somebody lost), ends every other session with LogoutOthers, and then this one, because signing in again is the proof that the new password is the one they meant. The sessions card lists where else the account is open and offers to end the others. Changing the e-mail is two steps: a one-use link (c.Link, an hour) goes to the new address, and only the route it points at — under /perfil, so the session and the link have to agree — changes anything. Without anybody to send the link, the screen refuses and says so: an e-mail changed without a confirmation at the new address is an account handed to whoever typed it.

tenant is the organisation picker, and the one place about organisations: create one (whoever creates it is in it), switch — refused where the person is not a member, and refused into an organisation that was deactivated — deactivate and activate back (nothing is deleted; whoever is inside stays until the session ends, because the switch is the moment the check runs), the member count, and the settings of the organisation the session is in: a trilha.Settings section per organisation, under its own key, drawn with ui.SettingsForm, so that ACME's time zone does not become everybody's.

Some recipes tie themselves together when both are there. users and permissions: the matrix owns the list of roles, so app/setup.go gets usuarios.Papeis = acesso.Papeis and the invite form offers the matrix's roles. profile and mail: the e-mail confirmation goes out through correio.Confirmacao. Each pair carries the same line under the same marker, conditioned on the other's file, so the tie happens whichever recipe is added second, once — and never a line that imports a package the project does not have.

webhooks is the one whose pieces were furthest from being found: the webhook module has existed since 0.65.0 and examples/blog uses it. It writes the closed list of events this application announces, the deliverer wired to the app's Env — http:// only in dev, a private address refused either way — and the screen whoever integrates with you uses. It writes no middleware, because it cannot know how this project authenticates; the command's last line says to guard that folder, and so does the file.

Each one comes with memory behind it, so the screen works from the first request, and a comment saying where a database goes. Each also says, in the file, that the folder needs guarding: an audit trail names people, and a keys screen issues credentials.

That is every recipe #116 listed. The next one is whichever pattern an application repeats and this list does not have.

Why the CI applies every one of them#

A recipe that broke quietly is worse than no recipe, because whoever ran it already has its code inside their project. So the test does not read the templates: it creates a project, applies every recipe to it, and runs trilha check — compile, vet, test, audit — with nobody editing anything.

trilha ctx#

The map of the project in one read: the module, whether trilha_gen.go is up to date, every route with its file, methods, parameters, layouts and middlewares, each API operation with its query, body and responses, the types those operations exchange, and what app/setup.go provides:

# example.com/store

- trilha 0.37.0 · 8 routes (6 pages, 2 APIs)
- trilha_gen.go: up to date
- app/setup.go: Setup, Config

## Routes

- `GET /` — app/page.go · layouts: app/layout.go
...

Two more sections appear only when the project has them, so a project that uses neither pays nothing — no lines, no tokens:

- `GET POST /docs` — app/docs/page.go · middleware: app/docs/middleware.go · needs docs:ver · POST needs docs:editar

## Enums

- `doc.situacao` — `rascunho` (Rascunho), `enviado` (Enviado · info) · app/setup.go

What a route demands is read from the middleware.go that declares it, and inherited the way the middleware chain is: the deepest folder wins, and a MiddlewarePOST shows on that method alone. It resolves the two shapes the documentation teaches — the guard called inline, and the package-level var the Middleware function returns — including a wrapper of your own that hands two strings to RequirePolicy, which is the idiom examples/local-login uses. What it cannot read does not appear: a module that comes from a variable would take a compiler, and a map that guesses is worse than a map that is quiet about one folder.

The enums are the domain lists trilha.RegisterEnum registered, joined to the declaration they came from. They are here because somebody who does not know a list exists invents a second one, and then two screens spell "in review" two ways. The same lists are readable at runtime with trilha.LookupEnum and trilha.RegisteredEnums.

The default is compact Markdown for reading. --routes and --types print one section alone, --all elides nothing (the per-method middlewares, every error response, the Problem type), and --json writes the same model as a document, sorted and free of clocks and absolute paths, so two runs of the same tree produce the same bytes.

The API section and the types come from the same inference behind trilha openapi, so the map and the document can never disagree. Like openapi.json, the output itself is a machine document and is not translated.

trilha gen --check#

Generates in memory, compares with the committed trilha_gen.go and exits 1 with the differing lines when they diverge — one line in the CI, and a folder added to app/ without running trilha gen stops being a 404 nobody can explain:

- run: trilha gen --check

trilha check runs this same comparison as its first gate, which is why a project that uses it needs no separate gen --check line. trilha audit runs the comparison as a warning, and also compares the CLI's version with the library's in go.mod: a newer CLI writes code the library may not have yet, and the error then shows up inside generated code — the worst place to look for it.

Generated file#

trilha_gen.go is deterministic (same tree, same bytes), carries the header // Code generated by trilha. DO NOT EDIT. plus a //go:generate trilha gen directive (so go generate ./... works without knowing the tool's name) and must be committed: go build ./... works without the CLI installed. It defines newApp() *trilha.App and main(); if another file in the package already has func main(), the generator omits its own (see App).

An app inside a binary that already exists#

The generated file takes the package the folder declares, so a Trilha app can be a normal, importable package inside a net/http server you already run:

// internal/crm/crm.go — package crm, written by hand
// internal/crm/app/…    — the routes
// internal/crm/trilha_gen.go — package crm, func NewApp() *trilha.App

mux.Handle("/", crm.NewApp().Handler())

Precedence, most explicit first: --package <name>; the package the hand-written .go files of the folder declare; the package an existing trilha_gen.go declares; main. The third step is what makes the flag a one-off — the generated file remembers the choice, so trilha gen --check in the CI needs no flag of its own.

Outside package main the constructor is exported (NewApp, since the caller lives in another package) and no func main() is written. trilha dev and trilha build refuse such an app and say what runs it: there is no binary here, the host has one.

Exit codes#

0 success; 1 generation, compilation or execution error; 2 incorrect usage.