Kiln — agent-driven build mode

Experimental. Kiln is a separate binary for shaping a GoFastr app live.
When you graduate, it writes the same gofastr.yml blueprint the generator
already reads — a scaffolder, not the thing you ship. The generated Go code
is what you keep; it's the source of truth, not the blueprint.

Kiln gives an agent a small, journaled application IR. Each accepted edit is
replayed into a fresh framework app, SQLite is migrated, and the browser sees
the result through GoFastr's normal SSR + hydration host. REST CRUD defaults to
/api/<entity> so an HTML screen may safely use /<entity>.

Start it

12 lines
# Turnkey path: starts the server, installs the Kiln skill, and runs OMP/GLM-5.2.kiln agent -p "build a small issue tracker"# Panel-driven mode with the same built-in adapter.kiln serve --agent omp# Other installed adapters or a custom command remain available.kiln serve --agent claude-codekiln serve --agent pikiln serve --agent codexkiln serve --agent autokiln serve --agent "omp -p --model glm-5.2"

The omp adapter runs OMP in an isolated temporary working directory with
--model glm-5.2, the Bash tool, auto-approval, no session persistence, and
Kiln's system prompt. Authentication stays with the installed CLI; Kiln does
not store provider credentials.

The server binds to 127.0.0.1:8765 by default. Open any current screen or the
empty-state host and use the floating panel. Important endpoints are:

EndpointPurpose
GET /kiln/worldCurrent lossless world IR.
GET /kiln/statusRuntime status.
POST /kiln/tool/<name>JSON tool dispatch.
POST/GET /mcpKiln authoring tools over Streamable HTTP MCP.
POST/GET /mcp/appRebuilt app's entity MCP tools.
GET /.kiln/eventsJournal/build SSE stream.

kiln mcp and kiln acp expose the same tools over stdio. Pass
--no-http when a parent process owns the UI.

Agent adapters

Built-ins are omp, claude-code, pi, and codex. auto chooses the first
installed adapter in that order. none keeps chat journaled without spawning
an agent. A free-form command is parsed as an executable plus arguments and
receives the prompt as its final argument.

Kiln injects KILN_URL, installs the current embedded skill, and enforces an
HTTP boundary for built-in adapters: they must mutate the app by calling
Kiln's tools, not by editing the repository.

Tools

Read and configuration:

  • world_get
  • set_app_config — current blueprint app config; HTTP calls use
    {"config":{...}}, a non-empty config.name is required, and an
    omitted/empty API prefix resolves to api
  • set_theme — semantic light theme token overrides
  • set_scaffold — navigation plus owned-Go endpoint, middleware, plugin, and
    helper stubs

Entities and data:

  • add_entity, update_entity, delete_entity
  • add_field, delete_field
  • add_seed

Screens and behavior:

  • add_page, update_page_element, delete_page
  • add_hook, delete_hook
  • add_route, delete_route

Safety and session:

  • propose_plan, approve_plan, reject_plan
  • undo, reset_session, chat

Every transport uses the same typed dispatcher. Destructive deletes require an
approved plan naming the exact operation and target. An approval is
single-use. undo truncates one journal entry and deterministically rebuilds;
reset_session clears the journal and ephemeral schema.

Those rules are enforced during replay, not only at the tool call. The
journal is the authorization record, so a destructive entry carries the
plan_id that authorized it and replay re-checks that the plan exists, was
approved, lists that exact target, and has not already been spent on it.
Consumption is derived from the log too — it used to live in a per-process
map that replay never rebuilt, so a restart re-armed every spent approval.
The multi_tenant refusal runs on the same path.

This matters because the journal is replayed at boot and by kiln freeze,
which turns the replayed world into a blueprint. A hand-authored
.kiln.session.jsonl used to install world state the tool API refuses. It
needs a local filesystem write, so it is an integrity property rather than a
remote one — but a guard only one of two readers applies is not a guard.

Current world contract

The world mirrors the current blueprint where a live representation is safe:

  • app module/database/static/output/API prefix, auth/admin/PWA, light and dark
    semantic theme tokens;
  • current entity fields, relations, indices, access, owner scoping, search,
    cursor fields, properties, hooks, declarative routes, and seeds;
  • screen metadata, layouts, access declarations, node trees, and navigation;
  • owned-Go endpoint/middleware/plugin/helper stubs.

multi_tenant: true is rejected by authoring and freeze because neither Kiln
nor the generator can guess the app-specific tenant resolver. Use
owner_field for per-user CRUD, or wire tenant middleware in owned Go after
graduation. Auth/admin declarations are preserved into the scaffold; Kiln's
live preview is not a production auth certification environment.

UI composition

Kiln pages run through framework/uihost: full SSR on first load, hydration of
the existing DOM, SPA navigation across screens, and component CSS from the
registry. Live world edits refresh the current screen through a forced SPA
navigation, never location.reload().

Prefer design-system kinds:

  • page_header, hero, section, card
  • stack, cluster, grid, stat_row, stat_grid, stat_card
  • link_button, callout, divider

Semantic leaf kinds such as heading, paragraph, text, link, form,
input, button, list, and table elements remain available. class,
style, and on* props are rejected: pages use the shared design system
instead of a second way to style things. Form actions that target CRUD
must use the API prefix, for example /api/notes.

Example:

21 lines
{  "page": {    "path": "/dashboard",    "name": "dashboard",    "title": "Dashboard",    "layout": {"name": "app"},    "tree": {      "kind": "stack",      "props": {"gap": "lg"},      "children": [        {"kind": "page_header", "props": {          "title": "Dashboard", "subtitle": "Current project health"        }},        {"kind": "stat_row", "children": [          {"kind": "stat_card", "props": {"label": "Open", "value": "12"}},          {"kind": "stat_card", "props": {"label": "Closed", "value": "48"}}        ]}      ]    }  }}

add_page assigns stable node _id values and version 1.
update_page_element performs optimistic edits to that tree using those IDs
and an optional if_match version.

Graduate to owned Go

6 lines
kiln freeze --diffkiln freeze --dir buildgofastr validate build/gofastr.ymlgofastr generate --from=build/gofastr.yml --out=appcd appgo test ./...

Freeze writes exactly:

  • build/gofastr.yml — deterministic current blueprint, ready for the
    one-shot generator;
  • build/world.json — lossless authoring snapshot.

Declarative Kiln hook/route actions remain exact in world.json. Where the
current blueprint requires a Go function, freeze emits an owned-Go handler
stub with a description naming the declarative action; implement that behavior
after generation. Entity endpoints graduate the same way: they are part of the
world IR but are not served by the live build-mode app (rendering one needs
run-then-respond semantics kiln/effect does not provide), so they surface as
owned-Go handler stubs after generation. The removed pre-v0.1 entities/*.json
format is not emitted.

Freeze fails loudly, naming the offending key, when world data cannot
round-trip through the blueprint's YAML subset — a seed row whose every value
is a nested object, or a props/row key containing a colon, comment marker, or
brackets. Reshape the data (or drop the row) and re-run; a freeze that
succeeded is guaranteed to re-parse into the same values.

FreezeAndGenerate performs the freeze and invokes
gofastr generate --from=gofastr.yml when gofastr is on PATH.

Security boundary

Kiln's HTTP endpoints are intentionally unauthenticated and local-development
only. The default loopback bind is the primary boundary. Unsafe cross-origin
browser requests are rejected; non-browser clients without an Origin header
and same-origin requests are allowed. Binding --addr 0.0.0.0:8765 is an
explicit decision to expose those endpoints and should only be done behind an
appropriate network/auth boundary.

Verification

From the framework checkout on Windows, ensure an MSYS2 GCC is on PATH so
go-sqlite3 can run, then:

2 lines
go test ./cmd/kiln ./kiln/...go test ./cmd/gofastr -run TestKilnFreezeBlueprintParsesAndValidates

The integration suite covers journal replay, live migrations, CRUD, hooks,
browser form submission, UI hosting, and freeze artifacts. Live provider tests
remain opt-in because they consume external model credentials.

Common mistakes

  • Passing flat app fields. set_app_config requires
    {"config":{"name":"My App",...}}. A flat payload is rejected so it cannot
    appear to succeed while silently discarding configuration.
  • Using bare CRUD paths. Entities mount below app.api_prefix, which
    defaults to /api; point forms and clients at /api/<entity> unless the
    world explicitly changes that prefix.
  • Adding page-local classes or styles. Kiln page nodes reject class,
    style, and on* props. Compose an existing typed component, or add a
    missing component/token to the design system before using it in Kiln.
  • Treating world.json as generated application code. It is the lossless
    authoring snapshot. gofastr.yml is the generator input, and declarative
    behaviors that require Go graduate as explicit owned stubs to implement.
  • Exposing the mutation server. Keep Kiln on loopback. Binding a public
    interface without an external authentication/network boundary exposes every
    world-editing tool.
  • Calling a provider run verified without using it live. The deterministic
    suite validates adapters and protocol behavior; set KILN_LIVE=1 and run a
    named TestLive_* case to prove the selected external driver actually edits,
    renders, freezes, and generates the current world.