tgtilegen docs

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:

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:

  1. The LLM replaces panic("tilegen:hole <id>") bodies.
  2. go build ./... && go vet ./... - the compile-time assertions reject any drift from the interfaces.
  3. 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.

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

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