Inspiration

Three days before this hackathon, I lost one I had just built Providence, a narrative game where characters remember what you did to them. It needed 24 NPC portraits and a handful of voice lines, so I generated them the way everyone does: a prompt, a look, a shrug, another prompt. By the end I could not have told you which prompt made which portrait. I had regenerated things I already owned, because finding the old one was slower than asking again. Then the submission missed its deadline by 65 seconds. That part was my fault. But going through the wreckage afterwards, what stayed with me was not the 65 seconds. It was that my folder of assets could not answer a single useful question about itself.

What it does

You write a brief. Kiln generates the assets, you keep or drop each one, and when you are happy you seal a version. That version gets a manifest.json with a sha256 for every file, which any build can pull and verify.

Three pieces do the work:

  1. A cache, so the same request never costs twice, even after a redeploy.
  2. A gate, so "generated" and "approved" stop being the same word.
  3. A sealed version, so a build in six months still builds the same game.

The obvious project here is a better generator. I did not build that, because generating the picture is not where the pain is. Every provider does that well enough now. The pain is the twenty minutes after the picture exists, where a human decides something and nothing writes it down.

Genblaze records provenance one run at a time. Kiln keeps the whole library: what you made, what you kept, and what shipped.

Genblaze already answers "how was this made" completely. What it cannot do, because it is not its job, is remember across runs. Run two knows nothing of run one. So my real question after Providence, did I already make this and did I like it, had nowhere to go.

How we built it

Kiln does not wrap Genblaze. It extends it at three of its published seams.

A durable StepCache. The one Genblaze ships writes to disk, so it dies with the container and two workers never share it. You buy the same prompt again after every redeploy. Mine implements the same contract against B2 and borrows Genblaze's own step_cache_key, so the two always agree on what counts as the same step.

Identity here is a function of the inputs and of nothing else: $$ k_{\text{gen}} = \text{SHA256}\big(\text{provider} \parallel \text{model} \parallel \text{prompt} \parallel \text{params} \parallel \text{seed}\big) $$ That single line is the reason you never pay twice, and it is also the constraint everything else in Kiln had to respect.

A provider the SDK does not ship. SyncProvider is public, so a Pollinations adapter came to fifty lines instead of a fork. Its seed comes from the prompt hash, so the same brief redraws the same picture and a cache hit matches a fresh run in the output, not only in the manifest.

A StorageBackend. An in-memory one, so the path with no credentials runs the same sink and transfer code as production instead of skipping it.

B2 is not where files go at the end. B2 is the database: assets, manifests, the step cache, the project index, the sealed versions. The service keeps nothing of its own, which is what lets the cache stay warm when the container is replaced.

Challenges we ran into

On 2 August I needed an image provider.

NVIDIA validated my request and its own gateway returned 504 after 303 seconds with zero bytes, twice, on two different keys. Google answered faster and worse: 429 with limit: 0 on all six of its image models. Not rate limited. Quota zero. GMI Cloud wanted a $50 minimum.

Four hours to discover that free image generation had quietly stopped existing.

Pollinations answers in four seconds and asks for nothing, so I wrote the adapter. Swapping providers cost one environment variable, which is what Genblaze promises. I can say it held because I tested it under pressure rather than reading it in a README.

Accomplishments that we're proud of

Every Keep and Drop already recorded the prompt behind it, and that history only ever decided what shipped. Now it feeds the next brief. Kept prompts become examples, dropped ones become warnings, and a model infers the style instead of copying words. From two kept engravings it worked out "monochrome engraving, high contrast woodcut" and applied it to a watchtower nobody had described that way.

One hard constraint: it must not break the cache. Generation is keyed on the prompt, so a rewrite that varied between runs would miss its own cache forever and the premise of the whole project would quietly stop working.

So the rewrite is cached too, by the same construction one level up:

$$ k_{\text{taste}} = \text{SHA256}\big(\text{brief} \parallel \text{count} \parallel \text{kept} \parallel \text{dropped}\big) $$

Two levels, one rule. Change nothing and you pay nothing. Drop an asset and \(k_{\text{taste}}\) moves, so the rewrite is fresh and the pictures follow. That is why judging an asset changes what comes next, and it is not magic.

It also refuses to pretend. Below three judgements it says "still learning" and steers nothing, because two prompts sharing a word is a coincidence, not a style.

What we learned

Read the SDK, do not guess at it.** Every real decision in Kiln came from introspecting Genblaze rather than reading its README. That the shipped cache was disk-backed, that SyncProvider was public, that a step is cached before its assets move: none of that is advertised, and all of it changed the design.

A feature may fail. A product may not. The rewriter falls back to a plain expansion when the model is slow or absent. The cache degrades to a miss when an entry is unreadable. A provider that dies is a 400 naming the variable to set, not a 500. Nothing optional is allowed to take generation down with it.

Say what you do not know. The taste rewriter refuses to claim a pattern below three judgements, because two prompts sharing a word is a coincidence. It was tempting to let it look clever on the first run. It would have been a lie the first time a user noticed.

"It works" is a claim, not an observation. Every number in this writeup came out of a run I can point at: the 504 after 303 seconds, the three identical failures, the 0.00s second pass. I learned that the hard way, by writing a terminal transcript from what the code should print and then running it and finding the lines came out in a different order.

The second challenge cost me more. The same prompt failed three runs in a row. Each time generation worked, the upload failed, and each time I fixed something unrelated.

Genblaze caches a step before its assets are transferred. So a failed upload leaves a cache entry pointing at a temp file that is gone by the next process. From then on the cache serves a corpse, that prompt is broken forever, and the failure replays with a message about something else entirely.

That was when I understood what I was building. A cache is a promise about the past, and a promise that cannot be checked is worse than no promise. Kiln now discards any entry whose file has vanished, and counts them.

Two smaller findings, both reported upstream: a cache hit is invisible to the caller, because Genblaze hands the step back with no flag and no tracer event, so an app cannot tell its user what it just saved them. And providers must write under temp or a declared output_dir, which is correct and undocumented until a transfer fails citing allowed directories.

What's next for KILN

Judge the pictures, not just the prompts. The taste rewriter reads the text of what you kept, never the image itself. If you drop something for being blurry, the prompt carries no signal about blur. I measured the fix during the hackathon: NVIDIA's vision models do read Kiln's B2 assets, at 21 seconds for llama-3.2-90b-vision and 46 for nemotron-nano. Describing three kept and three dropped assets would cost minutes before every brief, so it needs a description cached at judgement time rather than at brief time. That is the next thing I build, and I left it out rather than ship it half done on submission day.

Recommend a model, not just a prompt. Every decision already records its provider and model, so a keep rate per model is arithmetic, not machine learning. "On this project, sana was kept five times out of six" is a recommendation worth making. It needs several models in play on the same project before it says anything, which no demo has yet.

Let it disagree with you. Right now the rewriter suggests what you kept, you keep it, and it grows more confident in a circle of its own making. A taste that never explores is not taste. One suggestion in five should deliberately step outside the pattern.

Upstream. The three findings are written up: the invisible cache hit, the entry cached before its upload, and the undocumented write guard. The durable StepCache is the one I would most like to contribute back, because every Genblaze user deploying to a container has this problem and does not know it.

Built With

Share this project:

Updates

Submission history