1---
2title: Looping the Generation of IaC and SDKs
3date: 2026-07-02T16:00:00Z
4excerpt: AI agents write resources and live tests, run them against the real API, and every unmatched error becomes a typed patch to our generated SDK. One run of this loop grew Alchemy's Cloudflare provider from 22 resources to 230 and patched 720 SDK operations, most of it in 22 hours.
5---
6
7import LoopDiagram from "../../../components/LoopDiagram.astro";
8
9<LoopDiagram />
10
11[Alchemy](/) is a new Infrastructure-as-Code
12framework built from the ground up in TypeScript and Effect.
13Everything is typed code, from a resource's inputs and outputs
14down to the SDK calls and errors inside its lifecycle logic.
15
16Built from the ground up also means starting from zero
17coverage. Alchemy can't reuse the providers of existing IaC
18ecosystems like Terraform or Pulumi, and we're a team of
19three, so hand-writing lifecycle code for thousands of cloud
20resources isn't an option. We generate it with AI, using the
21loop in the diagram above.
22
23Fleets of AI agents write the resources and their live tests,
24then run them against a real Cloudflare account. Every API
25behavior the SDK's types don't capture becomes a patch to the
26SDK, which regenerates and the tests rerun. One run of this
27loop grew Alchemy's Cloudflare provider from **22 resources to
28230** between June 11 and June 17, producing 288 test files
29with roughly 700 tests and **720 SDK patches**. Most of it
30landed in a single PR
31([#601](https://github.com/alchemy-run/alchemy/pull/601))
32that added 101,178 lines across 542 files and was open for 22
33hours. The PR description, in its entirety:
34
35> Using Fable to generate all missing cloudflare resources
36
37Deterministic codegen has been
38producing SDKs and IaC providers from API specs for years, and
39AI makes it even cheaper. The problem is that code generated
40from a spec is only as truthful as the spec, and real APIs
41drift from their specs. They return undocumented error codes,
42misuse HTTP statuses, and null out fields the schema says are
43required. That's why the interesting number isn't the 230
44resources. It's the 720 patches, each one a place where a live
45test caught the API doing something its spec doesn't admit to.
46
47## A software development flywheel
48
49Alchemy's resources are built on
50[distilled](https://github.com/alchemy-run/distilled), our
51Effect-native SDK generated from Cloudflare's official
52TypeScript SDK. It covers 114 services and 2,092 operations,
53and each operation can carry a patch, a small JSON file that
54corrects the spec where reality disagrees with it.
55
56The run operated on one rule: **agents never handle an unknown
57error in their own code.** An unmatched error is never caught
58in the resource. It becomes an SDK patch and a regeneration.
59Untyped failure handling is where AI-generated code usually
60goes wrong, matching message substrings or guessing status
61codes. The rule takes that option away because the code
62doesn't typecheck until the SDK knows about the error. It also
63makes the work compound. Every red test either fixes a
64resource or improves the SDK for the next one.
65
66## One turn of the flywheel
67
68Infrastructure-as-code is mostly disciplined error handling.
69Deletes tolerate already-gone. Creates absorb losing a race.
70Reads ride out eventual consistency. In Alchemy that
71discipline is typed:
72
73```typescript
74// idempotent delete: reading back an already-gone widget is success
75turnstile.getWidget({ accountId, sitekey }).pipe(
76 Effect.catchTag("WidgetNotFound", () => Effect.succeed(undefined)),
77);
78
79// R2's endpoint lags a fresh bucket create, so retry the not-found until it settles
80r2.listBucketDomainCustoms({ accountId, bucketName }).pipe(
81 Effect.retry({
82 while: (e) => e._tag === "NoSuchBucket",
83 schedule: Schedule.exponential("100 millis").pipe(
84 Schedule.both(Schedule.recurs(5)),
85 ),
86 }),
87);
88```
89
90The agents write this code first, then run it against the real
91API. A Turnstile test deletes a widget and reads it back to
92prove it's gone. The read fails with code `10404`, which the
93spec doesn't declare. The `catchTag` doesn't typecheck because
94`WidgetNotFound` isn't in the error union yet, and the only
95permitted fix is a patch in distilled:
96
97```json
98// submodules/distilled/packages/cloudflare/patches/turnstile/getWidget.json
99{
100 "errors": {
101 "WidgetNotFound": [{ "code": 10404 }, { "code": 10407 }],
102 "Forbidden": [{ "status": 403 }]
103 }
104}
105```
106
107Regenerate the service (`bun scripts/generate.ts --service
108turnstile`) and the union names what the API actually does:
109
110```typescript
111export type GetWidgetError = DefaultErrors | WidgetNotFound | Forbidden;
112```
113
114The `catchTag` compiles and the test goes green. The loop
115continues through reconcile, read, diff, delete, and list
116until the whole resource passes against the live API. The
117resource is the product and the truthful SDK is the byproduct.
118The patch lives in the SDK, not Alchemy, so anyone who uses
119distilled's Turnstile module from now on gets `WidgetNotFound`
120as a typed, catchable error.
121
122## What 1,087 patches look like
123
124By beta.56 the corpus stood at 1,087 patched operations,
125covering 52% of the SDK across 94 of 114 services:
126
127| | count |
128| --- | --- |
129| patches declaring typed errors | 1,070 |
130| patches fixing a response schema | 173 |
131| patches fixing a request schema | 26 |
132| distinct error tags introduced | 425 |
133| matchers on Cloudflare error `code` | 1,367 |
134| matchers on HTTP `status` | 994 |
135| matchers needing `message.includes` | 377 |
136| matchers needing a regex | 0 |
137
138Most tags are the vocabulary lifecycle code needs, like
139`Forbidden`, `WorkerNotFound`, and `NoSuchBucket`. The rest
140catalog how APIs drift from their docs. Snippets returns 400
141instead of 404 for a missing snippet, so the matcher reads the
142message. Queues consumers support `http_pull` but the spec's
143enum doesn't include it. Account settings come back null where
144the schema says they can't. None of this is discoverable by
145reading documentation.
146
147## The factory
148
149Around twelve agents ran concurrently, each owning one
150distilled service. Only the owner of `turnstile` writes to
151`patches/turnstile/`, so agents never race the generator.
152Every test follows one shape: deploy, verify out-of-band by
153querying the API directly through distilled, mutate, verify
154again, destroy, and prove the destroy by watching the resource
155disappear. A test never trusts the deploy's own report of
156success. It confirms every step against what the API actually
157returns.
158
159Two choices kept the loop fast. distilled is a git submodule
160inside alchemy's bun workspace, so an agent patches the SDK
161and the resource that consumes it in one working tree, and a
162wave lands as two PRs, one to alchemy and one to
163distilled. And vitest resolves distilled from its TypeScript
164source, so a regenerated service is visible to the very next
165test run. The loop's cycle time is the test's runtime.
166
167## What's next
168
169We're scaling this loop to every cloud provider, with two
170goals: 100% IaC coverage in Alchemy, and refined SDKs whose
171types align with each API's real behavior rather than just its
172spec. AWS is next, then Railway, GCP, and Azure. The
173Cloudflare run was the proof, and it shipped in
174[2.0.0-beta.56](/blog/2026-06-17-beta-56).
175
176- [2.0.0-beta.56 — the release these resources shipped in](/blog/2026-06-17-beta-56)
177- [PR #601 — the generation wave](https://github.com/alchemy-run/alchemy/pull/601)
178- [distilled — the generated, patchable SDK](https://github.com/alchemy-run/distilled)
179- [Cloudflare provider reference](/providers/cloudflare)