tilegen
Compile a small S-expression spec into Go scaffolding by successive lowering passes and tree tiling - the instruction-selection technique compilers use - then hand the remaining, precisely described holes to an LLM.
Deterministic structure (module, types, interfaces, signatures, wiring, SQL) is compiled. Only method bodies are left open, each with a contract, an intent, and constraints.
flowchart LR S[spec.sexp] --> V[parse + validate] V --> E[expand] E --> C["concretize<br/>(config.sexp)"] C --> T["select<br/>(tiling)"] T --> M[emit] M --> A[go.mod] M --> B[*.go] M --> Q["db/*.sql + sqlc.yaml"] M --> L[tilegen.tasks.json]
Every pass maps S-expressions to S-expressions. Run with -dump to see each
stage in <out>/.tilegen/ - the same idea as GOSSAFUNC in the Go compiler.
Quick start
Go is the only prerequisite. go.mod asks for 1.26, and any Go 1.21 or
newer fetches that automatically the first time you build, so you do not
have to install it by hand.
brew install vinodhalaharvi/tap/tilegen # or: go install github.com/vinodhalaharvi/tilegen@latest
tilegen -config examples/shop/config.sexp -out out/shop -dump examples/shop/spec.sexp
cd out/shop && go mod tidy && go build ./... # compiles; bodies panic until filled
Or from a clone, which is what you want in order to read the passes or write a tile:
git clone https://github.com/vinodhalaharvi/tilegen.git && cd tilegen
make build && make check # build, then everything CI runs
make demo # generate examples/shop and prove it compiles
make dump # the S-expression after every pass
make help # every target
Two guides go deeper:
- docs/RUNNING.md - setting up locally, every make target, what each generated file is, the sqlc paths, and troubleshooting.
- docs/TILES.md - the tile registry in full, worked selections with real numbers, the cost model, and how to write a tile.
The spec
(project shop
(module github.com/acme/shop)
(go 1.22)
(require (uuid github.com/google/uuid v1.6.0))
(package orders
(entity Order
(field ID uuid.UUID)
(field PlacedAt time.Time)
(store get list save delete
(constraint "Save must be idempotent for the same ID.")))
(interface Pricer
(method Quote (params (o *Order)) (returns int64 error)))
(llm "Write a Pricer that applies a 10% discount to orders over 100.00.")))
| Form | Meaning |
|---|---|
(module path) (go 1.22) |
go.mod basics, validated by x/mod |
(require (alias module version [importpath])) |
dependency + the qualifier types use |
(package name ...) |
a Go package |
(entity Name fields... (store ops...)) |
struct + NameStore interface + implementation stub |
(struct Name (field N T (tag "...") (doc "..."))...) |
plain struct |
(interface Name (method N (params (n T)...) (returns T...)) (embed T)) |
an interface |
(implement Iface (as Name) (field n T)...) |
an implementation of any interface in the package |
(enum Status pending paid shipped) |
a string type with constants, StatusValues, Valid(), ParseStatus() |
(events (event Name (field ...))...) |
event structs, a typed Bus interface, and an in-process LocalBus |
(http (route GET "/x/{id}" (get X))...) |
a Handler and ServeMux over the package's stores |
(api (get X) (save X)... (typed-clients)) |
a server for those operations over HTTP and JSON, Connect or gRPC, whichever its clients need |
(secrets (secret DatabaseURL)... (rotating)) |
a typed Secrets interface, read from the environment, mounted files or Vault |
(events ... (outbox Order)) |
an Order and its events saved in one transaction, and a relay that publishes them |
(events ... (inbox OrderPlaced ID)) |
a wrapper that makes a handler take effect once per event, however often it is delivered |
(app) |
the program: cmd/<name>/wire_gen.go builds and serves every part, and a main.go starts it |
(llm "intent") |
explicit hole: pure intent, no structure yet |
(doc "...") |
doc comment on package, type, field, or method |
Types are Go syntax, parsed by go/parser. Quote them if they contain spaces
or parentheses: (field F "func(int) error"). Standard-library qualifiers
(time, context) need no declaration; anything else must come from a
require or a project package, or tilegen reports the exact line.
Every problem is reported at once, and likely typos come with the fix:
(entiy Note ...) gives unknown form ... (did you mean entity?). A typo of
a known form is an error; only a genuinely unknown form goes to the LLM. Types from
another project package are written qualified (orders.Order); tilegen adds
the import and rejects import cycles between packages.
Store operations
(store ...) inside an entity lists the methods of its NameStore
interface. Query methods are derived from fields, so they stay
deterministic down to the SQL: with (storage postgres) each one gets a
sqlc query, and the LLM only maps rows to domain types.
| Op | Method on ShareStore |
sqlc query |
|---|---|---|
get list save delete |
Get(id), List(), Save(share), Delete(id) |
GetShare, ListShares, ... |
count |
Count() (int64, error) |
CountShares |
(list-by NoteID) |
ListByNoteID(noteID) ([]*Share, error) |
ListSharesByNoteID |
(get-by Email) |
GetByEmail(email) (*Share, error) |
GetShareByEmail |
(count-by NoteID) |
CountByNoteID(noteID) (int64, error) |
CountSharesByNoteID |
(exists-by Email) |
ExistsByEmail(email) (bool, error) |
ExistsShareByEmail |
(delete-by NoteID) |
DeleteByNoteID(noteID) error |
DeleteSharesByNoteID |
(method Name (params ...) (returns ...) (doc ...)) |
custom, as written | none: a hole for the LLM |
Add or remove ops at any time: reconciliation appends new stubs to your implementation and never touches the methods you wrote.
Events
(events
(doc "Bus carries sharing events between the parts of the app.")
(event NoteShared (field NoteID int64) (field Email string))
(event NoteUnshared (field NoteID int64)))
generates, in <package>/events_gen.go and with no holes, one struct per
event, a typed Bus interface (PublishNoteShared(ctx, e) error,
OnNoteShared(h) func() returning an unsubscribe func), and LocalBus, an
in-process implementation. LocalBus delivers synchronously: Publish
calls every handler in subscription order, in the publisher's goroutine, and
returns their errors joined, so there are no goroutines to leak and errors
reach the publisher. It is tested for ordering, unsubscribing, error
joining, subscribing from a handler, and concurrency (under -race).
Asynchronous or networked buses are future event-bus tiles.
HTTP routing
(http
(doc "The links API.")
(route GET "/links" (list Link))
(route GET "/links/{id}" (get Link))
(route POST "/links" (save Link))
(route DELETE "/links/{id}" (delete Link)))
generates <package>/http_gen.go: a Handler over the package's stores,
Routes() returning an *http.ServeMux, and one handler per route that
parses the path value into the entity's ID type, decodes the body
(rejecting unknown fields), calls the store, and writes JSON with the right
status. Go 1.22's router matches methods and {wildcards} itself, so there
is no dependency, and 404 and 405 come from net/http.
Two things are judgment rather than structure, so they are the only holes, one pair per resource:
func (h *Handler) validateLink(link *Link) error // what makes a request unacceptable
func (h *Handler) statusForLink(err error) int // which status a store error deserves
Everything else is generated, and tested: tilegen's own suite fills those two holes and exercises every route, checking 200, 404 for a missing record, 400 for an unparsable id, for failed validation and for unknown fields, 204 on delete, 405 for the wrong method and 404 for an unknown path.
Outbox: save and publish, or neither
"Save the order and publish the event atomically" is one line in the package's events:
(events
(event OrderPlaced (field OrderID uuid.UUID))
(outbox Order))
No bus can commit together with a database. So the event is written to a
table in the same transaction as the order, and a relay publishes it from
there. (outbox Order) therefore becomes an outbox requirement on
OrderStore, and selection answers it: of the store tiles only
postgres-sqlc generates one, and tilegen explain says why each of the
others is out. Nothing in the spec has to name a database.
It generates the <package>_outbox table and its queries, and
<package>/outbox_gen.go, with no holes:
ob := orders.NewOutbox(pool)
err := ob.SaveOrderAndPublishOrderPlaced(ctx, order, orders.OrderPlaced{OrderID: order.ID})
// the order and the event are both committed, or neither is
relay := orders.NewOutboxRelay(pool, bus)
go relay.Run(ctx, func(err error) { log.Print(err) })
The save runs the store's own Save inside the transaction. The relay
publishes to whichever bus was chosen, oldest first, and deletes an event
once the bus has taken it. A crash between the two publishes it again:
delivery is at least once, so handlers must tolerate a repeat. One relay
keeps events in the order they were recorded; several can run at once and
share the work, giving that order up.
Inbox: delivered twice, handled once
At-least-once delivery means a handler may see an event twice. One more line makes it take effect once:
(events
(event PaymentTaken (field ID uuid.UUID) (field Cents int64))
(inbox PaymentTaken ID)) ; two deliveries with the same ID are one event
A handler cannot check "have I seen this?" and then act: a crash between
the two loses the event or repeats it. So the record that an event was
handled is written in the same transaction as whatever the handler
writes. That transaction has to cover every store the handler may touch,
so (inbox ...) puts an inbox requirement on each store in the
package, and they land on postgres-sqlc.
inbox := ledger.NewInbox(pool)
bus.OnPaymentTaken(inbox.OncePaymentTaken("billing",
func(ctx context.Context, q *db.Queries, e ledger.PaymentTaken) error {
// write through q: it commits with the record, or not at all
return ledger.NewPostgresBalanceStore(q).Save(ctx, balance)
}))
A repeat finds the record and returns nil without calling the handler.
A handler that fails leaves no record, so the redelivery runs. The name
("billing") lets two handlers of one event each run once. Prune
forgets old records; keep them longer than a delivery can be delayed.
With (outbox ...) on the publishing side and (inbox ...) on the
handling side, delivery is at least once and the effect is exactly once.
APIs: HTTP, Connect or gRPC
(http ...) is for writing the routes yourself. (api ...) says what the
package serves and what its clients need, and leaves the transport to be
chosen, the way a store's backend is:
(api
(get Order) (list Order) (save Order) (delete Order)
(typed-clients))
| Requirement | Meaning |
|---|---|
(typed-clients) |
clients are generated from a schema, not written by hand |
(browser-clients) |
a browser calls it directly, with no proxy in between |
(grpc-native) |
it must be grpc-go: its interceptors, reflection and health services |
| Tile | Legal for | Generates |
|---|---|---|
http-json |
no requirement, browser-clients |
exactly what (http ...) does, with a conventional route per operation |
connect |
typed-clients, browser-clients |
a .proto and a Connect server, which also answers gRPC and gRPC-Web clients |
grpc |
typed-clients, grpc-native |
the same .proto and a grpc-go server |
(watch OrderShipped) adds a server-streaming RPC fed by the package's
bus: a client receives every OrderShipped published from the moment its
call returns, until it goes away. A watch needs a stream, so it rules out
http-json by itself. The server subscribes on the Bus interface, so
whichever bus tile was chosen is what feeds it. Publishing never waits
for a client: one that falls 64 events behind is disconnected with
"resource exhausted" and reconnects. Who may see an event is the one
hole, allowOrderShipped(ctx, e) bool.
With no requirement the cheapest wins, which is http-json. Asking for a
browser and for grpc-go at once is refused with the reason: a browser
cannot speak gRPC. tilegen explain shows the choice, and (api grpc) in
the config makes it outright.
The two schema tiles write proto/<package>/v1/<package>.proto from the
entities, and buf.yaml and buf.gen.yaml beside go.mod.
buf turns the .proto into Go, as sqlc does for SQL:
tilegen -out out/api examples/api/spec.sexp
cd out/api && buf generate && go mod tidy && go build ./...
It needs buf, protoc-gen-go, and protoc-gen-connect-go or
protoc-gen-go-grpc on your PATH; tilegen tiles says which are
missing. The server in <package>/api_gen.go is generated whole,
conversions between an Order and its wire form included. As with HTTP,
the only holes are the two judgment calls:
func (s *APIServer) validateOrder(order *Order) error // what makes a value unacceptable
func (s *APIServer) codeForOrder(err error) connect.Code // which code a store error deserves
A field's number is its identity on the wire, so tilegen never works it
out from the order of the spec. Numbers are pinned in tilegen.lock:
(proto orders.Order
(number ID 1)
(number CustomerEmail 2)
(number Currency 9)
(reserved 3))
Reorder the fields and nothing moves. Add one and it takes the next number
nobody has had. Remove one and its number is reserved, in the lock and in
the .proto, so an old client's bytes can never be read as a different
field. -reselect chooses tiles again and leaves numbers alone. Commit the
lock.
Fields can be strings, numbers, bools, []byte, uuid.UUID, time.Time,
the package's enums, and pointers to those, which become optional. An
enum gets an UNSPECIFIED zero value, and a request that leaves one unset
is an invalid argument rather than a silent default.
The program: (app)
Everything above generates the parts of a service. (app) at the top of
the project asks for the program as well:
(project orders
(module github.com/you/orders)
(go 1.22)
(app) ; or (app ordersd) to name the command
(package orders ...))
| File | Whose | What |
|---|---|---|
cmd/orders/wire_gen.go |
generated | Deps, App, build and Serve |
cmd/orders/main.go |
yours, written once | opens what Deps asks for and starts the program |
The wiring is worked out from the Go this run produces, not from what
each tile says about itself. Every constructor (func New...) in a
package is read back. A parameter another constructor can supply is
supplied by it, so the bus the API streams from is the bus the outbox
relay publishes to. A parameter nothing can supply becomes a field of
Deps:
type Deps struct {
NatsConn *nats.Conn // for orders.NewNatsBus
OrdersOutboxDB orders.OutboxDB // for orders.NewOutbox, orders.NewOutboxRelay
Queries *db.Queries // for orders.NewPostgresOrderStore
}
build refuses to start with any of it missing and names what is.
Serve runs what runs (an outbox relay), serves what listens (HTTP and
Connect on $ADDR, gRPC on $GRPC_ADDR), and returns when the context
is done.
Because it reads the Go, it follows what you change: add a parameter to a constructor in a scaffolded file and the next run wires it. A tile tilegen has never heard of is wired the same way. If two constructors could supply the same interface, generation stops and says so rather than pick one.
With tiles that build from each other (memory, the local bus, secrets
from the environment), Deps is empty and the program starts as
generated: go run ./cmd/orders.
Secrets
(secrets
(secret DatabaseURL)
(secret SigningKey (doc "signs session tokens."))
(secret OAuthClientID (key oauth_client_id)))
generates <package>/secrets_gen.go: a Secrets interface with one
method per secret, and its implementation. A spec names secrets and never
holds one: a value written after the name is an error.
| Requirement | Meaning |
|---|---|
(rotating) |
a changed secret must be seen without a restart |
(audited) |
every read must be recorded, with who made it |
| Tile | Legal for | Reads |
|---|---|---|
env-secrets |
no requirement | DATABASE_URL, once, when the process starts |
file-secrets |
rotating |
<dir>/database-url on every call, as Kubernetes and Docker mount them |
vault |
rotating, audited |
the key database_url at one path of a Vault KV v2 mount, kept for a ttl you choose |
With no requirement the environment wins: it needs no dependency and
nothing to run. Vault is chosen only when (audited) asks for what the
others cannot do, or when the config says (secrets vault).
Nothing here is judgment, so there are no holes. Each constructor reads every secret once and reports all the missing ones together, so a misconfigured service fails when it starts:
secrets, err := orders.NewEnvSecrets()
// orders: secrets not set in the environment: DATABASE_URL, SIGNING_KEY
secrets, err := orders.NewFileSecrets("/run/secrets")
secrets, err := orders.NewVaultSecrets(ctx, client, "secret", "orders", time.Minute)
The Vault client arrives logged in: how a service proves who it is (a token, AppRole, Kubernetes) is decided where the client is made. If Vault cannot be reached once a read is in hand, that read keeps being served and Vault is asked again on the next call.
(key ...) names a secret outside the program where the name splits
badly: OAuthClientID alone would be O_AUTH_CLIENT_ID.
Implementing any interface
(interface Mailer
(method Send (params (to string) (subject string) (body string)) (returns error)))
(interface AuditLog
(embed io.Writer) ; embedded interfaces: project or standard library
(method Flush (returns error)))
(implement Sharer (as EmailSharer)
(doc "EmailSharer shares notes by email.")
(field store ShareStore) ; dependencies, injected by the constructor
(field mailer Mailer)
(constraint "Never share a note twice with the same email."))
(implement AuditLog (as FileAuditLog) (field path string))
implement works for any interface in the package, including generated
store interfaces. It scaffolds a struct, a New... constructor taking the
dependencies, one stub per method, and a var _ Iface = (*Impl)(nil)
check. It also writes tasks that list the dependencies and constraints.
Methods of embedded interfaces are included: project interfaces directly,
and standard-library ones like io.Writer from Go's own type checker, so no
method lists are hardcoded. Variadic last parameters are written
(args "...any"). Like every scaffolded file, it is reconciled as the
interface changes.
Starting from an existing project
Most projects already exist. tilegen import lifts one into a spec:
tilegen import ~/go-projects/myapp
wrote spec/00-project.sexp
wrote spec/10-catalog.sexp
wrote spec/NOTES.md
imported 5 of 8 exported type(s) in 1 package(s); 3 skipped (see spec/NOTES.md)
next: tilegen check spec # what the spec would regenerate, next to what you have
Everything it writes comes from the type checker
(x/tools/go/packages, go/types), never from names or source text:
structs and their field types and tags, interfaces and their method sets
(embedded and variadic included), defined string types with typed constants
(enums), which concrete type implements which interface
(types.Implements), and the module and its requires. If the project does
not type-check, import refuses and prints the errors, because a spec lifted
from types the compiler rejects would be quietly wrong; -force imports
the packages that do check.
What it cannot prove, it does not guess. Generic types, aliases, unexported
fields and anything else are listed in NOTES.md with the reason. Store
ops, needs and events are not inferred from method names, because a rule
like "a method called GetByX means (get-by X)" works on one codebase and
not the next. An interface that is really a store imports as a plain
(interface ...), which is always correct; turn it into
(entity ... (store get list save (durable))) yourself when you want
tilegen to own it.
It is a starting point, not a round trip. The spec covers what it covers,
the rest stays ordinary Go, and tilegen check shows the difference.
Specs across files
SPEC can be a directory. tilegen reads every .sexp file in it in name
order, and the merge pass links them into one project, like a linker:
one (project ...) header, plus any number of top-level (package ...),
(require ...), (repo ...) and (config ...) forms. Packages with the
same name merge, identical requires dedupe, and conflicts are reported with
both file positions. See examples/shopdir, which compiles to exactly the
same Go as examples/shop/spec.sexp.
Git and GitHub
(repo
(github acme/shop) ; or just shop: the owner is your `gh` login
(visibility private) ; private (default) | public | internal
(description "...")
(topics orders billing) ; added to derived ones: go, golang, tilegen, ...
(license mit)) ; mit (default) | none
The (repo ...) tile writes starter files, once and then yours:
README.md, LICENSE, Makefile and .gitignore. Without
(module ...), the module path is derived from the repository.
tilegen -git -name myshop examples/shopdir # repo and module follow -name
tilegen -git -dry-run -name myshop examples/shopdir # print the plan, write nothing
With -git, tilegen uses <out> as is if it is already a git repository.
If the GitHub repository exists, it clones it and generates into it, leaving
the changes for you to review and commit. If it does not, it runs
git init, generates, runs go mod tidy (and sqlc generate for postgres),
makes the first commit, creates the repository with gh repo create, and
adds topics. Without (github ...), -git makes a local repository only.
Workstation: worktrees and tmux
A (workspace ...) form says how tilegen runs on your machine. It never
changes generated code. Put the spec inside the repository it generates, and
one folder holds everything:
(workspace
(name myshop) ; default for -name
(out ..) ; default for -out: this spec lives at spec/
(worktrees ; checkouts at ../myshop.wt/<name>
(worktree billing (branch feat/billing))
(worktree pricing (branch feat/pricer)))
(tmux
(session myshop
(window code (dir .) (run "$EDITOR ."))
(window check (dir .) (run "make tidy check"))
(window billing (worktree billing) (run "claude"))
(window pricing (worktree pricing) (run "claude")))))
A workspace on its own is enough for up, status and down, so you can
open the session before there is anything to generate, and watch the project
appear:
tilegen -git spec/ # generate into ..; create or clone the GitHub repo
tilegen up spec/ # create missing worktrees, open or attach the session
tilegen status spec/ # per checkout: changes, open holes, branch
tilegen down spec/ # close the session; -prune also removes clean worktrees
A window can be split into panes, which is how you show a project rather than work in it: everything visible at once, no switching.
(window demo (dir .)
(split horizontal) ; horizontal (side by side) | vertical (stacked)
(pane (run "tilegen explain spec/"))
(pane (run "watch -n 2 tree -L 5")))
A pane takes the same (dir ...), (worktree ...) and (run ...) as a
window. Windows suit development, where you want one thing at a time;
panes suit a demo, where you want to watch a project appear as you
generate it.
Each worktree is a full checkout on its own branch, so separate agents can
fill different packages' holes in parallel without touching each other's
files. up is safe to re-run: it reuses existing worktrees, recreates
missing ones from their branches, and adds windows you added to the spec.
Inside tmux it switches your client; outside, it attaches. down -prune
uses git worktree remove, which refuses to delete uncommitted work, and
branches are always kept. Plain generation never runs commands: worktrees,
tmux and run only happen when you type up.
The config
Same spec, different config, different (equally valid) Go:
(config
(json-tags snake) ; snake | camel | none
(context-first yes) ; prepend ctx context.Context to interface methods
(storage auto) ; auto (cheapest legal per store) | memory | postgres | pgx
(api auto) ; auto (cheapest legal for its clients) | http | connect | grpc
(secrets auto) ; auto (cheapest legal) | env | file | vault
(layout flat)) ; flat | internal
The passes
expand removes sugar and knows nothing about the config:
(entity Order (field ID uuid.UUID) (store get))
=>
(struct Order (field ID uuid.UUID))
(interface OrderStore (method Get (params (id uuid.UUID)) (returns *Order error)))
(impl OrderStore (for Order) (ops get))
concretize makes every config decision explicit in the tree:
(field PlacedAt time.Time) => (field PlacedAt time.Time (tag "json:\"placed_at\""))
(params (id uuid.UUID)) => (params (ctx context.Context) (id uuid.UUID))
(impl OrderStore ...) => (impl OrderStore ... (backend memory))
select is instruction selection proper. It covers the tree with tiles
whose outputs are target forms (go/file, gomod, sql/query, llm/task).
Tiles are tried biggest-first (maximal munch). The impl tile is the big
one: through the package symbol table it covers the interface and struct
too, and emits an implementation file, a var _ I = (*T)(nil) assertion, SQL,
and one task per method. The last tile matches anything: forms nothing else
covers become LLM tasks (or errors under -strict), just as a compiler
guarantees coverage with a one-node tile per operator.
What is reused instead of written
| Job | Tool |
|---|---|
| go.mod create/merge | golang.org/x/mod/modfile |
| module path / version checks | x/mod/module, x/mod/semver |
| Go type grammar | go/parser.ParseExpr |
| Go AST, imports | go/parser, x/tools/go/ast/astutil |
| formatting, stdlib imports | go/format, x/tools/imports (the goimports engine gopls uses) |
| which stdlib packages exist | go list std |
| database access code | sqlc, fed by the postgres tile |
| protobuf and RPC code | buf and the protoc-gen plugins, fed by the connect and grpc tiles |
| type checking the result | go build, go vet |
Everything tilegen itself implements is the reader, the matcher, the tiles, and a thin text renderer: about 1,750 lines of code, not counting comments.
Handing off to an LLM
tilegen prompt spec/ # list the open tasks
tilegen prompt spec/ sharing.EmailSharer.ShareNote # one self-contained prompt
tilegen prompt spec/ -all # every prompt, separated by ---
To let an LLM fill the holes itself, tell tilegen which command to run, in
workspace.sexp (it is specific to your machine):
(workspace
(fill
(command "claude -p") ; any CLI that reads the prompt on stdin and prints the answer
(retries 1) ; re-ask once, with the compiler's errors
(timeout "5m")))
tilegen fill spec/ # every open hole
tilegen fill spec/ 'billing.*' # one package; patterns use path.Match
tilegen fill -dry-run spec/ # what would be filled; calls nothing
For each hole, fill sends the prompt, takes the method from the reply, checks it is exactly the method asked for with the same signature, splices it in with go/ast, fixes imports, and builds. If the build fails, it puts the hole back and asks again with the compiler's errors and the previous answer. A hole it cannot fill stays a hole, so the project never ends up broken. Drifted methods are fixed first; fill refuses to start if the project has any other build error. Pair it with worktrees to fill packages in parallel:
(window billing (worktree billing) (run "tilegen fill spec/ 'billing.*'"))
A prompt holds everything an LLM needs and nothing it has to go looking for: what to write (the exact signature, the hole's file and line), the intent and constraints, the rules for the reply, and the full text of every file involved, including sqlc's generated code for postgres stores. It is built from the same plan as generation, so it reflects the current spec even before you regenerate, and it writes nothing. Pipe it into any LLM CLI.
The prompt also names the modules the project depends on, with versions,
and says to import nothing else. Told only "modules already in go.mod", a
model fills the path in from memory, and library paths go stale: asked to
return 409 on a duplicate, one reached for github.com/jackc/pgconn, the
v4 module, in a project using github.com/jackc/pgx/v5.
It also carries the API surface of the packages that task touches: the
exported declarations of each one, with doc comments, signatures only,
printed from go/types. So a postgres store task gets pgx's real
ErrNoRows and Rows rather than the model's memory of them. Scope is
narrow on purpose: only packages the task's files import, plus any named in
its intent (map pgx.ErrNoRows to ErrNotFound) and resolved through
go.mod, never the standard library, at most four per prompt and trimmed to
a budget. Results are cached under <out>/.tilegen/api. See what a package
contributes with:
tilegen api github.com/jackc/pgx/v5
tilegen.tasks.json lists every open hole with file, line, symbol,
contract, intent, constraints, and context_files, plus instructions.
Give it to your LLM together with the listed files. Then:
- The LLM replaces
panic("tilegen:hole <id>")bodies. go build ./... && go vet ./...- the compile-time assertions reject any drift from the interfaces.- Re-run tilegen at any time. Holes are found by parsing the real files, so filled ones drop off the list and line numbers stay current.
For agents: -json
The read-only commands print structured output with -json, so a program
does not have to parse tables meant for people:
tilegen explain -json spec/ # every need: chosen tile, score, candidates, rejections
tilegen plan -json spec/ # the levels, with each node's dependencies
tilegen tiles -json # the registry: capabilities, offers, costs, legality
tilegen check -json spec/ # what would change, and whether that is a failure
{
"need": "orders.OrderStore",
"capability": "store",
"requirements": ["durable"],
"chosen": "postgres-sqlc",
"score": 29,
"chosen_by": "auto",
"candidates": [
{"tile": "postgres-sqlc", "score": 29, "own": 22,
"chain": [{"rule": "row-mapper", "score": 7, "detail": "1 entity"}]},
{"tile": "postgres-pgx", "score": 45, "own": 45}
],
"illegal": [{"tile": "memory", "reason": "an in-memory map loses its data on restart"}],
"position": "spec/10-orders.sexp:12:7"
}
An agent can then ask the compiler what it would do, and why, rather than
deciding architecture itself or guessing at reasons. Both renderings come
from one computation, so they cannot disagree; a test checks that they
report the same problems. check -json still exits non-zero on failure, so
it works in CI either way.
As a service
tilegen serve answers over HTTP: a spec in, a scaffolded project out.
Open it in a browser and it is the studio: tick what the system must do
("data survives a restart", "a browser calls it directly"), and watch each
need go to a tile, with the cost of every alternative and the reason each
other tile was ruled out. Every generated file is there to read, and the
project downloads as a zip. The page is in the binary, libraries and
typefaces included, so it works with no network.
tilegen serve # then open http://localhost:8080
curl -X POST localhost:8080/generate --data-binary @spec.sexp -o project.zip
curl -X POST localhost:8080/explain -d @spec.sexp | jq .
| Route | What it does |
|---|---|
GET / |
the studio: requirements in, choices with their reasons, files, a download |
GET /docs |
three walkthroughs, each spec shown twice with one decision changed |
POST /compile |
the choices, the plan and every file, from one compile, as JSON |
POST /gomods |
what a set of go.mod files show is already in use: engines, known libraries, unknown ones |
POST /draft |
Claude's draft tile for each module given; needs a key, and a server on this machine only |
POST /generate |
the project as a zip, named for the spec's project |
POST /explain |
which tile covered each need, with scores and rejections |
POST /plan |
what the run makes, in dependency levels |
GET /tiles |
the registry: capabilities, offers, costs |
It accepts a raw S-expression body, or JSON with spec, config, policy,
name and tiles fields.
Tools tilegen has no tile for
A team's stack is rarely only what tilegen ships tiles for. A draft declares one more tool for a single compile:
(tile cockroachdb
(offers store)
(doc "distributed SQL that speaks the Postgres protocol")
(module github.com/cockroachdb/cockroach-go/v2)
(engine cockroachdb)
(satisfies (durable) "a write is replicated to a quorum before it is acknowledged")
(satisfies (cross-process) "any number of clients connect to the cluster")
(illegal-when (no-broker) "it is a cluster to run")
(cost (llm-work 4 "plain SQL through pgx") (maintenance 3) (dependency 2) (runtime 2)
(operations 7 "at least three nodes, and their upgrades")))
It says what the tool is for and what it costs, which is all selection needs, so it takes part in every decision and explanation: "would the thing we already use win here, and what would it rule out?" It cannot generate code. When a draft wins a need, tilegen reports the decision and stops; the project comes when that tile is built into tilegen.
A draft is held to a stricter rule than a built-in tile, because nothing
has run it. It wins only what it claims (a requirement it does not
mention is unverified), and it pays uncertainty for being unproven. In
the studio a draft lasts for the visit: write one under "What you already
run", or let Claude write the first version.
Asking Claude to draft a tile
A go.mod says a team uses a library. It does not say what the library is for, what it can promise or what it costs to run. That is judgment, so it is the one place the studio can use an LLM, and only if you turn it on:
tilegen key set # prompts for an Anthropic API key and stores it in the OS keychain
tilegen serve # with a key set, listens on 127.0.0.1:8080 only
Give the studio your go.mod files, tick "Also ask Claude what these are", and each requirement no tile speaks comes back as a draft to read, edit and tick, or as one line saying it is not that kind of tool.
- Optional. Without a key the option is greyed out with the reason, and everything else works.
- The key stays on your machine. The server reads it from
ANTHROPIC_API_KEYor the keychain and makes the call; it is never sent to the page.tilegen key removedeletes it. - Never on a public server. Drafting is refused unless the server listens on this machine only.
- What is sent: the module paths and versions you asked about, and tilegen's own prompt. Nothing else from your go.mod or your spec.
- Checked, then yours to check. A draft passes the same parser as one written by hand, and goes back once with the reason if it fails. It is still Claude's reading of a library: read it before you tick it.
TILEGEN_MODELpicks the model; the default isclaude-sonnet-5-5.
It executes nothing. Generation writes into a plan in memory, which the
service zips directly, so no file is written and no command runs: no git,
no sqlc, no build. Those belong to whoever downloads the zip, and the
included GENERATED.md says which to run. Every request is bounded by body
size, a timeout, and limits on how large a spec may be.
Deploying it is one command, since it is stateless and scales to zero:
PROJECT=my-gcp-project deploy/cloudrun.sh
Two layers, kept apart
tilegen is a scaffold, not a code generator that finishes the job. The deterministic layer produces structure and typed holes, and stops. What fills the holes is interchangeable: an LLM, a person, another tool. Neither layer knows the other exists.
That is a property, not a slogan, and a test pins it: generation is byte-identical whether or not any hole was ever filled. Regeneration never reads filled code, never reacts to it, and never rewrites it. If that test fails, the layers have begun to know about each other.
It is why tilegen fill asks once, retries once with the compiler's
errors, and then restores the hole and reports. Converging on working code
is a well-served problem, and agents do it better than a generator's own
loop could; the product here is the hole, its contract and its prompt.
The plan graph
tilegen plan shows what a run makes and in what order, inferred from the
plan tilegen already builds. Nothing is declared and nothing is written:
$ tilegen plan spec/
level 1
file db/schema.sql (postgres-sqlc)
need sessions.SessionStore (memory: store, chosen by auto (score 12))
task sessions.MemorySessionStore.Get Look up id in s.m while holding s.mu. ...
level 2
tool sqlc generate (postgres-sqlc: run after tilegen, before building)
level 3
file internal/db/ (postgres-sqlc: written by sqlc generate)
level 4
task links.PostgresLinkStore.Get Call the sqlc-generated s.q.GetLink ...
The levels carry real information: a postgres store's holes cannot be filled until sqlc has produced the types they use, while a memory store's can be filled straight away. Edges come from what the plan already knows, which tile staged which file, what each task was given as context, and what each covering delegated to, so there is nothing for a tile to declare and nothing to get out of step.
tilegen plan spec/ # levels
tilegen plan -dot spec/ | dot -Tsvg -o plan.svg
A cycle is reported with the nodes involved rather than silently dropped. Later work (filling independent holes in parallel, invalidating only what a spec change touches, caching expensive fills) builds on this graph; today it is a projection, so you can see how the compiler arrived.
Checking in CI
tilegen check spec/ # fail on out-of-date files, drift, or open holes
tilegen check -allow-holes spec/ # while the LLM work is still in progress
check runs the same plan as generation and compares it with the disk,
writing nothing. It exits 1 if regenerating would change a file (someone
edited generated code, or the spec changed without regenerating), if a
method you implemented has drifted from the spec, or, unless
-allow-holes is set, while holes remain:
stale orders/orders_gen.go: differs from what the spec generates
stub Count to billing/memory_invoice_store.go: new in the spec
holes 10 open (listed in tilegen.tasks.json)
tilegen: check failed: 2 file(s) out of date. Run `tilegen spec` to update, then fill the holes
Generation itself is plan-then-apply, like terraform plan and apply,
so the two can never disagree.
Reconciliation: the spec changes, the code follows
Spec forms come and go, and every run brings the code in line. One rule: tilegen's own files follow the spec; anything you wrote is never changed.
| The spec changes | tilegen does |
|---|---|
| a method is added | appends a stub with a hole to your file |
| a method's signature changes, stub untouched | updates the stub |
| a method's signature changes, you implemented it | reports drift; it becomes an LLM task |
| a method is removed, stub untouched | removes the stub |
| a method is removed, you implemented it | reports an orphan; your code stays |
| a package or storage backend is removed | deletes its generated files, and scaffolded files that are still pure scaffolding; orphans with your code are reported |
An untouched stub is a method whose body is still just the hole, so it holds
nothing of yours. There is no state file: generated files carry a
Code generated by tilegen. DO NOT EDIT. header and scaffolded files a
Scaffolded by tilegen. one, so each run finds its files on disk and
compares them with the spec. Nested modules (folders with their own
go.mod) are never touched. Drift and orphans also appear in
tilegen.tasks.json, so an LLM can finish the job.
go.mod is merged, never rewritten: versions are only raised, so
go mod tidy results survive regeneration.
Choosing tiles
A node says what it needs; tiles say what they offer. Where more than one tile offers a capability they compete, and the cheapest legal one wins. Storage and event buses work the same way, through the same solver:
(entity Order
(store get list save
(durable))) ; this data must survive a restart
(events
(cross-process) ; handlers live in other processes
(event JobStarted (field JobID int64)))
Requirements are one shared vocabulary: (durable) means the same for a
store and for a bus. Each tile declares when it cannot serve one:
(tile local-bus
(offers event-bus)
(illegal-when (durable) "an in-process bus loses undelivered events on restart")
(illegal-when (cross-process) "an in-process bus only reaches handlers in this program")
(cost (llm-work 0) (maintenance 0) (dependency 0) (runtime 1)))
So the same spec gives you a LocalBus for a package whose events stay in
the process and a NatsBus for one whose do not:
$ tilegen explain spec.sexp
fleet.Bus (event-bus) needs: cross-process chosen by: auto
chosen nats-bus score 35 llm 5·4 + maint 3·3 + dep 4·1 + run 2·1
illegal local-bus an in-process bus only reaches handlers in this program
local.Bus (event-bus) needs: none chosen by: auto
chosen local-bus score 1 llm 0·4 + maint 0·3 + dep 0·1 + run 1·1
nats-bus score 35 llm 5·4 + maint 3·3 + dep 4·1 + run 2·1
Storage has three tiles: memory, postgres-sqlc (tilegen writes SQL, sqlc
writes the Go) and postgres-pgx (tilegen writes the schema, the LLM writes
the SQL). A config may name one for a capability, (storage postgres) or
(events nats), and then it decides, but naming one that is illegal for
some node is an error at that node.
The policy: what your team values
Tiles declare what they cost; a policy prices them and says what people
want. It lives beside the spec (any .sexp file in the folder) or in
-policy FILE:
(policy
(weights (llm-work 4) (maintenance 3) (dependency 1) (runtime 1) (uncertainty 5))
(prefer postgres-gorm
(strength required) ; required | strong | weak
(source client "approved-library list, contract §4"))
(margin 8) ; how far behind a weak preference may be
(avoid pgx "we standardised on sqlc")) ; never choose it
Weights price every tile and chain rule, so the same spec gives different teams different architectures with no tile edited.
The project as a whole
What you already operate is already paid for. A policy can say so, and a tile on one of those engines then costs nothing more to run:
(policy (running postgres nats))
chosen nats-jetstream score 35 llm 5·4 + maint 3·3 + dep 4·1 + run 2·1 + ops 0·3
cost nats-jetstream operations: 0 (policy: you already run nats)
The engines are the databases, brokers and vaults tiles run on: kafka,
mongo, nats, postgres, rabbitmq, redis, vault. The studio can
tick them for you from your services' go.mod files: a direct
requirement on pgx says there is a Postgres somewhere.
Each need is covered on its own, so a cost a project pays once is charged
for every need: five stores on postgres are priced as five servers.
tilegen explain -project prices the project as a whole instead. A server
(operations) is paid once per engine, a library (dependency) once per
tile, and each engine beyond the first within a capability costs the
policy's (extra-engine N), which is 0 unless you set it:
(policy (extra-engine 10)) ; a second database is one more thing to run
whole project a server and a library are paid once; each extra engine in a capability costs 10
need by need whole project
app.CommentStore badger 29 postgres-sqlc 44 changes
app.ProjectStore badger 27 postgres-sqlc 26 changes
app.TaskStore badger 27 postgres-sqlc 26 changes
app.TeamStore badger 27 postgres-sqlc 26 changes
app.UserStore sqlite-sqlc 48 postgres-sqlc 26 changes
total 158 148
engines badger, sqlite postgres
Each figure is what that need adds to the project. This is a report: tilegen still generates the need-by-need choice. A need whose tile the config, the lock or a preference decided keeps it.
What counts as one engine is something each tile declares. postgres-sqlc
and postgres-pgx both say postgres, so they share a server; the two
NATS tiles share nats; a tile that says nothing is its own engine, and
memory and local-bus have none. A tile written as a (tile-spec ...)
says it with (engine NAME).
Costs say what a tile is worth technically. Preferences say what people want, and they are not the same thing: a tile that loses on every engineering measure can still be right because a client requires it or a team knows it. So preferences live in the policy, with who holds them and how strongly, while tiles declare only technical costs, since a tile cannot know your team.
| Strength | What it does |
|---|---|
required |
a constraint, not a cost: every other tile becomes illegal, with the source as the reason |
strong |
wins over any legal candidate, however much cheaper it is |
weak |
wins ties and anything within (margin N); what a bare (prefer X) means |
explain then reads like an architecture decision record: the technical
ranking, what overturned it, who asked for that, and where each number came
from.
postgres-sqlc score 29 llm 3·4 + maint 2·3 + dep 3·1 + run 1·1 = 22
+ row-mapper 7 (1 entity)
chosen postgres-pgx score 45 llm 7·4 + maint 5·3 + dep 1·1 + run 1·1
cost postgres-pgx llm-work: 7 (derived: the LLM writes every query and scan by hand)
policy: strongly preferred; cost alone would have picked postgres-sqlc (29 vs 45)
[source: team, 9 of 11 engineers know pgx; nobody has used sqlc]
Cost numbers carry provenance, so an author's estimate is not mistaken for a
measurement: (source derived "..."), measured, user, or nothing for a
built-in default. A tile is never pruned from the registry, only for one
run: another team's policy can make it the winner again.
How the covering is found
The cheapest covering is computed bottom-up, the way a compiler's instruction selector does it: the cost of covering a node is the tile's own cost, plus the cost of covering the children it delegates, plus any chain that converts its form to the one its parent wants. Doing it greedily would be locally right and globally wrong as soon as a tile's cost depends on its children's.
Four properties hold, and each has a test:
| Property | What it means |
|---|---|
| Totality | every need is covered, or reported with every rejection and its reason |
| Determinism | the same spec, policy and lock always give the same covering; ties break by tile name |
| Legality before cost | an illegal tile is never scored, however cheap; legality depends only on the spec |
| Optimality | among legal coverings, the chosen one is cheapest under the policy's weights, checked against brute-force enumeration |
Adding a database
A SQL backend is a Dialect plus a registered tile. The query shapes are
shared, so a database says only how it spells things:
var SQLite = &Dialect{
Name: "sqlite", SQLPackage: "database/sql",
Types: map[string]string{"int64": "INTEGER", "uuid.UUID": "TEXT", "time.Time": "TEXT", ...},
Placeholder: func(i int) string { return fmt.Sprintf("?%d", i+1) },
Upsert: ..., Enum: ..., Overrides: ...,
}
sqlite-sqlc is that: the same shape as postgres-sqlc, writing
INTEGER PRIMARY KEY and ?1 placeholders, with engine: "sqlite" in
sqlc.yaml. It is durable, since the database is a file, and illegal for
(cross-process).
The cost model follows the dialect too, which is the part that makes the
comparison honest. sqlite has fewer dependencies, so it wins for a simple
entity, but it stores uuid.UUID and time.Time as TEXT, so its mapper
parses more and postgres wins for a uuid-keyed one:
orders.OrderStore (store) needs: durable
chosen sqlite-sqlc score 27 ... = 20 + row-mapper 7 (1 entity)
postgres-sqlc score 29 ... = 22 + row-mapper 7 (1 entity)
profiles.ProfileStore (store) needs: durable
chosen postgres-sqlc score 51 ... = 22 + row-mapper 29 (1 entity, 1 parsed field, 3 nullable fields)
sqlite-sqlc score 60 ... = 20 + row-mapper 40 (1 entity, 3 parsed fields, 3 nullable fields)
Switching a project needs no spec change, only a policy:
(policy
(prefer sqlite-sqlc (strength required) (source client "one file, no server")))
Chain rules
A backend's output has a form. memory and pgx hand back your domain types
directly; sqlc hands back its own row types (db.Order), so something must
convert them. Converters are chain rules: tiles that turn one form into
another, at a cost computed from the entity:
(tile row-mapper
(converts db-rows domain)
(cost-rule "1 per entity, +1 per enum field (ParseX), +1 per nullable field (pgtype), +2 per JSONB field"))
Selection adds the cheapest chain to each backend's own cost. A plain entity still picks sqlc; one heavy in enums and nullable fields tips to pgx:
profiles.ProfileStore needs: durable chosen by: auto
chosen postgres-pgx score 45 llm 7·4 + maint 5·3 + dep 1·1 + run 1·1
postgres-sqlc score 51 llm 3·4 + maint 2·3 + dep 3·1 + run 1·1 = 22
+ row-mapper 29 (1 entity, 1 enum field, 3 nullable fields)
illegal memory an in-memory map loses its data on restart
examples/auto uses all three storage tiles in one project. A tile whose
form no chain converts to domain is illegal. docs/TILES.md
works through that example store by store, with the mapper arithmetic.
The tile registry
Every tile declares what it covers, what capability it produces, what it
needs and what it costs. tilegen tiles lists them all; -sexp prints the
registry as S-expressions, the shape tiles will have once they can be
written as data:
(tile postgres-sqlc
(pass select)
(doc "postgres via sqlc: tilegen writes SQL, sqlc writes the Go, the LLM maps rows")
(covers (impl ?iface ?parts...) (backend postgres))
(produces store)
(requires (tool sqlc))
(cost (llm-work 3) (maintenance 2) (dependency 3) (runtime 1)))
(tile catch-all
(pass select)
(doc "covers anything no other tile covers: the LLM as the tile of last resort")
(covers _)
(produces llm/task)
(cost (llm-work 10) (uncertainty 10)))
Costs are live: where several tiles offer one capability, the cheapest legal
one wins under the policy's weights, and tilegen explain shows the
arithmetic. Structural tiles are still covered by maximal munch, since
nothing competes with them. What remains is replacing declared LLM costs
with measured ones, which is what v0.4 is for.
The registry, the cost model and the chain rules are documented in full, with worked examples, in docs/TILES.md.
Writing a tile
A pass tile is a pattern plus a function, with registry metadata:
{Name: "enum", Pattern: Pat("(enum ?name ?items...)"), Then: selectEnum,
Produces: "go/type", Doc: "a string type with constants, Valid() and Parse()",
Cost: Cost{{"llm-work", 0}, {"maintenance", 0}}},
Then returns target forms and calls m.Sub(...) on any leaves it wants
other tiles to cover. Put more specific patterns before general ones.
A storage backend is a registered tile in its own file, like a database/sql
driver. Config, validation, the store tile, GitHub topics, starter files and
-git all ask the registry, so a new backend changes nothing else:
func init() {
RegisterBackend(&Backend{
Name: "memory", Tile: "memory", Doc: "in-memory store: a map guarded by a mutex",
Cost: Cost{{"llm-work", 2}, {"maintenance", 1}, {"dependency", 0}, {"runtime", 1}},
Implement: func(in StoreInput) (StoreParts, error) { ... },
})
}
A new package-level form is a registered tile in its own file too: its rule
joins a pass just before the catch-all, and it validates its own form. The
event bus (events.go) is built this way; nothing in the core names it:
func init() {
RegisterPackageTile(&PackageTile{
Form: "events", Pass: Select,
Rule: Rule{Name: "events", Pattern: Pat("(events ?items...)"), Then: selectEvents,
Produces: "event-bus", Doc: "event structs, a typed Bus interface, and an in-process LocalBus"},
Validate: validateEvents,
})
}
If you keep giving an LLM the same instructions, that convention is a tile waiting to be written.
Background
- Andrew Appel, Modern Compiler Implementation, ch. 9: tree tiling, maximal munch, and "optimal" versus "optimum" tilings.
- Fraser, Hanson and Proebsting, Engineering a Simple, Efficient Code-Generator Generator (iburg, 1992).
- Go's own compiler rewrites SSA with S-expression rules in
src/cmd/compile/internal/ssa/_gen/*.rules, compiled to Go by rulegen.
Status
v0.3.5. Next up is v0.4, Measured: running tilegen fill for real, logging
every attempt, and replacing declared LLM costs with measured ones. See
ROADMAP.md.
License
MIT