Skip to content
Indra on Cloud
Go back

The Model Wrote the Code. The Build Gate Was the Product.

Four boxes in a row, generate to compile to run to output, with a fix loop feeding compile and run failures back to generate.

On the platform I work on, the application code for each service is generated, not typed. A customer draws their backend on a canvas, the platform compiles that into a knowledge model, and an agent writes the Go service that implements it.

The generation turned out to be the easy part. A model is very good at producing code that looks correct. It is not good at producing code that actually builds, and it will not tell you the difference. So the interesting part of the system was not the prompt. It was the gate.

Why “plausible” is the real failure mode

Ask a model for a service and it will hand you a main.go, a router, a controller, a go.mod. It will import packages, choose signatures, and wire everything together, confidently, whether or not any of it compiles.

Wrong code looks a lot like right code at a glance. A human reviewer gets tired and lets it through. And a wrong import or a mismatched signature does not fail the way a subtle bug fails, weeks later in production. It fails the moment go build runs, which is also when it is cheapest to fix.

So the generator is not allowed to output until it has run its own build. The output is the last step in the loop, not the first.

The loop is a call loop, not a prompt

This part is worth being precise about, because it is where the actual engineering lives.

The generator is not a single prompt that asks for a service and hopes. It is a Go service that drives a model through a tool-calling loop. The model is given two tools, and the service executes them for real:

toolExecutor := map[string]func(args string) (string, error){
    "latest_version":  lookupOnProxyGoLang,
    "compile_and_run": compileAndRun,
}

for {
    resp := chatCompletion(messages)

    if resp.FinishReason == "tool_calls" {
        messages = append(messages, resp.Message)
        for _, tc := range resp.Message.ToolCalls {
            result, err := toolExecutor[tc.Function.Name](tc.Function.Arguments)
            messages = append(messages, toolMessage(result, tc.ID))
        }
        continue
    }
    return resp
}

latest_version batches every non-instrumentation import and resolves its version against proxy.golang.org, so the generated go.mod pins real versions instead of whatever the model remembers. compile_and_run is an HTTP call to a separate sandbox service. The model proposes, the service verifies, and the result is fed back as a tool message for the next turn. The loop ends when the model stops calling tools, and there is a hard iteration cap so a model that cannot stop is cut off rather than allowed to burn forever.

The sandbox runs the real toolchain

compile_and_run is not a linter. It is a sandbox that runs the actual Go toolchain over the generated files.

It writes the files to a fresh temp directory, finds the service root by locating go.mod, and then:

go mod download
go mod tidy
go build -v -o main main.go
./main

Each step has a bounded timeout. The build errors are parsed back into structure, split on the file:line:column: message format go build already emits, which is what lets the model fix exactly the location the compiler named instead of regenerating the whole service.

The run step is the part most people do not bother with, and it is the part I would not skip. Before running the binary, the sandbox authenticates with the customer’s own cloud account: it fetches the service account key from Vault, writes it to disk, activates it with gcloud, and sets GOOGLE_APPLICATION_CREDENTIALS. The generated service then executes with real credentials against real resources. A service that builds but crashes on startup, or panics because it read its config wrong, is caught before it ever reaches a deploy, because it had to boot once, here, first.

The two exit conditions are the actual engineering

A loop without an exit is a hang, so each gate got its own rule, and they are deliberately different.

Two cards side by side, the compile gate with no retry cap and the run gate with a cap of five.

The compile gate has no cap. A build error is a fact with a location attached. Given the file and the line, the fix converges quickly. Capping it would mean giving up on a problem that is mechanically solvable.

The run gate caps at five. A runtime failure is different: the code built, but it did something wrong at execution time, and the cause is not a single line you can point at. Beyond five attempts the model is likely chasing its own tail, so it records the constraint in the README and stops. A service that builds but does not run is documented as such, which is honest, and better than an infinite loop.

There is a third cap above both of these: the tool-call loop itself has a maximum number of rounds. Two of the three exits are about correctness. The third is about the model that will not stop calling tools.

Before the code, the knowledge had to be compact

The code generator does not read the raw architecture graph. It reads a compact knowledge model that a separate service compiles from it first.

That step exists for token economics, and the trimming is aggressive for a reason. The raw graph carries redundancy: a description field that repeats itself verbatim, nodes that appear more than once. The compiler deduplicates the nodes and strips the repeated fields before anything is sent.

There is a second, subtler job the compiler does, which is scope. Given a service name, it walks the graph edges to discover which Firestore collections that service actually touches, then follows each collection’s parent chain recursively so sub-collections resolve to their full path. Only that slice of the data model is handed to the code generator, not the whole database. This is what keeps a one-service generation from dragging in the schema for the entire tenant.

The other reason for the compiler is truncation. A model told to emit a long structure can run out of its output budget and stop mid-way. The result is still valid JSON, it parses fine, and everything near the end is silently missing. The compiler detects exactly that and repairs the truncated output by balancing the braces before it parses. Without that, a project with dozens of services would lose the last few, and nobody would notice until a deploy failed on a service that was never generated at all.

What I would tell someone building this

Make the agent verify its own output. Generation without verification hands you plausible text and calls it done. The cheapest check, a real build, catches almost everything that matters before a human looks.

Run the thing, do not just compile it. A service that builds but cannot boot is the more expensive failure, and it is the one a compile-only gate never sees.

Give each gate its own exit condition. A loop that retries forever is not autonomous, it is stuck. Syntax errors converge, so let them retry. Runtime failures do not, so cap them and record the outcome. And cap the loop itself, for the model that will not stop.

Put the output last. When the fix loop comes before the output, the thing you ship is the thing that passed. When it comes after, you are shipping a first draft.

Trim the prompt before you trim the model. A lot of token cost is redundancy in what you send, not in what you ask for. Deduplicate, compress, and scope the input to the one service being built, and the model gets more signal for the same money.


Share this post:

Previous Post
Our Worst Terraform Deploy Took 71 Minutes. The Fix Was One Flag.
Next Post
The App Called OpenAI Directly. The Gateway Should Have Been in Between.