{"slug": "a-langgraph-pipeline-that-generates-compiling-flutter-apps-with-a-repair-loop", "title": "A LangGraph pipeline that generates compiling Flutter apps (with a repair loop)", "summary": "A new LangGraph-based pipeline generates Flutter applications from JSON Product Requirements Documents, targeting zero-error compilation through a repair loop with four subagents and a QA gate running the real Flutter toolchain. The service, available as an HTTP MCP server at https://37-27-249-33.sslip.io/mcp, charges $3.00 USDC per build via x402, with free validation and payment terms endpoints. The pipeline is designed for machine-to-machine use, settling payments on-chain before builds start.", "body_md": "Takes a JSON Product Requirements Document and emits a compiled Flutter application. Built as a LangGraph loop with four subagents, a QA gate that runs the real Flutter toolchain, and an x402 payment gate in front of packaging.\n\nThe design goal is not \"generates plausible code\" but **zero-error compilation**:\nif the output fails CI, the loop has failed.\n\nAdd this as an HTTP MCP server:\n\n```\nhttps://37-27-249-33.sslip.io/mcp\n```\n\nFour tools: `validate_prd`\n\nand `prd_schema`\n\nand `payment_terms`\n\nare **free** —\nyour agent can check whether a document is well-formed and see exactly what it\nwould build before anything costs money. `start_build`\n\nbuys a build: call it\nonce to get quoted, sign, call again to build. No account, no API key, no\nsubscription — **$3.00 USDC per build**, paid on the spot via\n[x402](https://x402.gitbook.io/x402/) and settled on-chain before the build\nstarts. That's the whole pricing model: nothing recurring, pay only for the app\nyou actually generate.\n\nPrefer curl? Same service, plain HTTP:\n\n```\n# Does my document build, and what would come out? Free.\ncurl -X POST https://37-27-249-33.sslip.io/validate \\\n     -H 'Content-Type: application/json' -d @examples/todo_app.prd.json\n\n# What does it cost, and how do I sign for it?\ncurl https://37-27-249-33.sslip.io/.well-known/x402\n```\n\n`/validate`\n\nruns the same validator the paid path runs, so a document it accepts\nwill not be rejected after payment. A build that fails still returns its\ndiagnostics, because payment settles first and a silent failure would be theft.\n\nNo credentials and no Flutter SDK needed — the defaults are fully offline:\n\n```\npoetry install\npoetry run python src/supervisor.py examples/todo_app.prd.json --clean\n```\n\nThat validates the PRD, runs the loop, and writes a Flutter project to\n`generated_apps/current_build/`\n\n.\n\nTo grade the output with the real toolchain instead of the offline stub:\n\n```\npoetry run python src/supervisor.py --clean --analyzer dart --flutter-root \"C:\\flutter\" --run-tests\n```\n\nCLAUDE.md calls this an M2M microservice; this is the part a machine buyer calls.\n\n```\nX402_SHARED_SECRET=... FLUTTER_ROOT=\"C:\\flutter\"   poetry run uvicorn src.service.app:app --port 8000\n```\n\n| Endpoint | |\n|---|---|\n`POST /builds` |\nPRD in; `202` + job id, or `402` with an x402 challenge |\n`GET /builds/{id}` |\nstatus, log, diagnostics |\n`GET /builds/{id}/apk` |\nthe artifact |\n`GET /healthz` |\nincludes whether payment is configured |\n\nBuilds take minutes, so the API is asynchronous. ** x402_payment_verified in a\nsubmitted PRD is discarded**: the PRD is buyer-supplied, so trusting that field\nwould let anyone assert their own payment. Only the server's verifier sets it.\n\nPayment is real x402: the buyer signs an EIP-3009 `TransferWithAuthorization`\n\n(EIP-712), and the service recovers the signer, checks recipient, amount, chain\nand validity window, and claims the nonce atomically so one signature buys\nexactly one build.\n\n```\nX402_TOKEN_CONTRACT=0x036CbD...  X402_CHAIN_ID=84532 X402_PAY_TO=0xYourAddress        X402_PRICE_ATOMIC=500000   poetry run uvicorn src.service.app:app --port 8000\n```\n\nConfigure nothing and the service refuses every payment — it fails closed rather\nthan falling back to the development shared secret. `/healthz`\n\nreports which\nmode is active.\n\n**Verification is not settlement.** A valid signature proves the payer\n*authorised* a transfer; it does not prove they hold the balance or that the\ntransfer landed on-chain. Set `X402_FACILITATOR_URL`\n\nand the service submits the\nauthorization for on-chain execution and blocks the `202`\n\nuntil it confirms —\nso a build only starts once the money has actually moved. Without it,\n`/healthz`\n\nreports `settlement: verification-only`\n\nand the service is accepting\nsigned promises.\n\nBefore the nonce is claimed the service asks the facilitator's `/verify`\n\nendpoint whether the payment would settle. Insufficient funds is recoverable —\nthe payer tops up and re-presents the same signature — so burning their\nauthorization for it would be needlessly destructive.\n\nSettlement distinguishes three outcomes, not two. A refusal (\"insufficient\nfunds\") is definite and is not retried. A timeout is *unknown*: the facilitator\nmay have broadcast and failed to answer, so the build is refused while\nrecording that the buyer may have been charged. Retrying a transport failure is\nsafe — an EIP-3009 nonce is single-use on-chain, so a duplicate submission\nreverts rather than charging twice.\n\nVerified end to end on Base Sepolia — a real transaction, a real APK:\n\n```\nexport X402_TOKEN_CONTRACT=0x036CbD53842c5426634e7929541eC2318f3dCF7e  # USDC\nexport X402_CHAIN_ID=84532\nexport X402_NETWORK=base-sepolia\nexport X402_PRICE_ATOMIC=3000000                                      # 3.00 USDC\nexport X402_PAY_TO=0xYourReceivingAddress\nexport X402_FACILITATOR_URL=https://facilitator.payai.network         # public, no auth\nexport FLUTTER_ROOT=\"C:\\flutter\"\npoetry run uvicorn src.service.app:app --port 8000\n```\n\nThe payer needs USDC only — **no ETH**. The facilitator submits the transaction\nand pays gas, which is the point of EIP-3009; if a wallet is being asked for\ntestnet ETH, something is wired wrong. Faucet: [https://faucet.circle.com](https://faucet.circle.com).\n\n`scripts/check_x402_funding.py`\n\nreports whether a payer is funded by asking the\nfacilitator's `/verify`\n\n, rather than introducing a second source of truth that\ncould disagree with the one the gate actually uses.\n\nSet `REDIS_URL`\n\nand both the nonce store and the job store move to Redis;\n`/healthz`\n\nreports `multi_process_safe`\n\n. Nonce consumption uses `SET NX EX`\n\n,\none atomic round trip, so the time-of-check/time-of-use protection holds across\nworkers rather than only within one interpreter. A Redis outage refuses payment\nrather than allowing replays.\n\nThe money moves on-chain before the build starts, so losing a build to a deploy\nmeans having taken payment for nothing. Execution is therefore durable, not just\njob *state*: `POST /builds`\n\nsettles the payment, writes the PRD onto the job\nrecord, and pushes the id onto a queue. Workers pull from it.\n\n```\nBUILD_WORKER_EMBEDDED=0 poetry run uvicorn src.service.app:app --port 8000  # accepts\npoetry run python -m src.service.worker                                     # builds\n```\n\nAn API process builds as well as accepts by default, so a single-container\ndeployment needs neither of those flags; set `BUILD_WORKER_EMBEDDED=0`\n\nonce\nrequests and builds want scaling apart.\n\nA worker holds a lease on the job it is building and renews it while it works.\nKill the worker and the lease lapses, another worker returns the job to the\nqueue, and it is rebuilt from the stored PRD — which is why the PRD is stored\nrather than captured in a closure, as it was when execution lived inside the\naccepting process. `scripts/verify_durable_execution.py`\n\ndemonstrates exactly\nthat, across real processes and a real `SIGKILL`\n\n.\n\nTwo things this deliberately does not promise. A lease **bounds** duplicate work\nrather than preventing it: a worker partitioned from Redis for longer than its\nlease is indistinguishable from a dead one, so its build may be handed on while\nit is still running. `renew`\n\nreturning False is how the stalled worker learns to\ndiscard its result, but the overlap is real, and it costs compute rather than\nmoney — the payment settled once, before the job was queued. And retries are\n**bounded** (`BUILD_MAX_ATTEMPTS`\n\n, default 3): without that, one build that\nreliably kills its worker would be requeued into the next one and take the fleet\ndown a process at a time.\n\n| Variable | Default | What it is for |\n|---|---|---|\n`BUILDS_RATE_LIMIT` |\n`20` |\n`POST /builds` per address per window; `0` disables |\n`BUILDS_RATE_WINDOW_SECONDS` |\n`60` |\nthe window that limit applies over |\n`BUILD_WORKER_EMBEDDED` |\n`1` |\nwhether the API process also builds |\n`BUILD_LEASE_SECONDS` |\n`300` |\nhow long a silent worker keeps its job |\n`BUILD_HEARTBEAT_SECONDS` |\n`30` |\nhow often a working worker says so |\n`BUILD_MAX_ATTEMPTS` |\n`3` |\nbefore a build is given up on for good |\n\n`/healthz`\n\nreports `durable_execution`\n\n, which is true only when the queue *and*\nthe job store are both shared — a Redis queue over an in-memory job store loses\nthe record the build would be resumed from.\n\nThe x402 gate stops anyone getting a *free* build, so this is not about theft. It\nis the cost of **refusing**: `POST /builds`\n\ncarrying a payment header makes the\nservice call the facilitator's `/verify`\n\nand then `/settle`\n\n, two network round\ntrips with a 60-second timeout, from a synchronous endpoint. A few hundred\nconcurrent requests with junk authorizations exhaust the thread pool and the\nservice stops answering anyone — including the buyers who paid. So the limit is\non that endpoint only; polling a running build and downloading an APK are cheap\nand legitimately frequent, and throttling them would punish correct behaviour.\n\n**Identifying the caller is the part that fails quietly.** Behind the TLS proxy\nevery request arrives from Caddy's address on the compose network, so keying on\nthe socket peer puts every buyer in the world into one bucket — a limiter that\nlooks configured while blocking either everyone or nobody. The client is the\n**rightmost** `X-Forwarded-For`\n\nentry, because Caddy appends the peer it actually\nsaw to whatever the caller sent. Taking the leftmost, which is the more common\nconvention, would let a caller supply their own header and mint a fresh identity\nper request; there is a test that fails on exactly that.\n\nOne container that accepts payment and builds APKs, plus the Redis that makes\nboth durable. It wants a VM with a disk: the image carries Flutter, the Android\nSDK and a JDK, and measures **7.71 GB**, which rules out serverless targets and\nmost PaaS free tiers.\n\nA finished build used to cost **2.0 GB** of Gradle output for a **144 MB** APK,\nand nothing reclaimed it — job records expire from Redis after seven days, which\nis the wrong half, since the record is kilobytes and the directory it names is\ngigabytes. The service now keeps the artifact and drops the tree the moment a\nbuild finishes, while the worker still holds the lease. Measured on the\ndeployment: the last unpruned build is 2.0 GB on disk, the first pruned one is\n144 MB, and it still downloads. That is the difference between about 58 sales\nand about 790 on a 150 GB box.\n\n```\ncp .env.deploy.example .env.deploy     # fill in X402_PAY_TO and ANTHROPIC_API_KEY\ndocker compose --env-file .env.deploy up -d --build\npoetry run python scripts/verify_deployment.py http://127.0.0.1:8000\n```\n\n`docs/DEPLOY.md`\n\nis the runbook: how the machine should be sized and why, the\nfirewall trap that Docker's published ports set for ufw, and the order — build,\nprove over an SSH tunnel, buy one real build, and only then put it on a public\naddress behind TLS.\n\nEvery toolchain version in the `Dockerfile`\n\nis pinned to the one the table above\nwas proven against — Flutter 3.44.8, Android SDK 36, build-tools 36.0.0, JDK 17.\nFloating any of them makes the build environment a moving target, which is the\none thing a service promising \"it compiles\" cannot afford.\n`tests/test_deployment_config.py`\n\nfails if they drift, if a `${VAR}`\n\nin compose\nhas nothing to supply it, or if the builds volume disappears.\n\n**The deployment check is the point of that last command.** A container can come\nup, answer HTTP and still be unsellable: taking payments it never settles,\nlosing paid builds on restart, or falling back to the dev shared secret because\na token variable was misspelled. `/healthz`\n\nwas built to make each of those\nvisible in one line, and `verify_deployment.py`\n\nrefuses the deployment when it\nsees them. It was itself checked against four deliberately broken deployments —\nno Redis, no facilitator, no token config, wrong chain, no rate limit — and\ncaught all five\nwith the fix named in the message. `--pay`\n\ngoes further and buys a real build\nwith the testnet key, which is the only check that proves the deployment can do\nthe thing it charges for.\n\nTwo things about the buyer-facing surface are worth knowing before pointing\ntraffic at it. The service defaults to the **Claude** generator, so a deployment\nwithout `ANTHROPIC_API_KEY`\n\naccepts payment and then fails to build;\n`SUPERVISOR_GENERATOR=template`\n\nis the free, deterministic path and a sane first\ndeploy. And `SUPERVISOR_BUILD_MODE`\n\ndecides whether a buyer gets an installable\ndebug APK or an unsigned release one — see below for why the service will not\nsign.\n\n**The image has now been built and sold from.** A 4 vCPU / 8 GB Hetzner box\nrunning Ubuntu 26.04 built it on the first attempt, and a signed EIP-3009\nauthorization settled on Base Sepolia\n([ 0x0637c94b…](https://sepolia.basescan.org/tx/0x0637c94b490531065d3476147eaa7484ed2565ad8168b0639d21cf63b1edb2a5))\nbought a 151 MB APK from it end to end.\n\nIt did not work first time, and the way it failed is the point. Every layer of\nthe image built, the service came up healthy, `/healthz`\n\nreported a correct\nposture, and the deployment check passed — and then the first paid build died:\n\n```\nPermissionError: [Errno 13] Permission denied: '/opt/flutter/bin/flutter.bat'\n```\n\nThe Flutter SDK ships `flutter`\n\n*and* `flutter.bat`\n\non every platform. Four call\nsites had independently resolved the tool by taking the first candidate that\n`exists()`\n\n, Windows spelling first — correct here, an unrunnable batch file on\nthe machine that was actually going to run it. 299 tests, an eval sweep, APK\npackaging and the entire verification table had all passed on Windows, where the\nbug cannot appear. It took a buyer paying for a build to find it, which is\nexactly the failure mode the paid check exists to provoke while the buyer is\nstill us. `src/ports/toolchain.py`\n\nnow answers that question once, and takes\n`windows=`\n\nas a parameter so both branches are reachable from a test on either\nplatform.\n\n`flutter create`\n\nscaffolds a release build type that signs with the **debug**\nkey, under a `// TODO: Add your own signing config`\n\ncomment. Left alone,\n`flutter build apk --release`\n\nsucceeds, produces a plausible `app-release.apk`\n\n,\ninstalls on a device, and cannot be published — Play rejects the debug key. A\nbuyer would find that out at upload, having already paid.\n\nSo `--build-mode release`\n\nstrips that config and emits an **unsigned** APK.\n\n```\npoetry run python src/supervisor.py <prd.json> --execute \\\n  --build-mode release --flutter-root \"C:\\flutter\" --sdk-root \"C:\\Android\"\n```\n\nThe service does not sign, and holds no keys. A release key decides who can ship updates to an app's installed base; holding buyers' keys would mean holding that power over every app ever generated here, and one breach would compromise all of them together. The buyer signs with a key this service never sees:\n\n```\nzipalign -p -f 4 app-release-unsigned.apk app-release.apk\napksigner sign --ks my-release-key.jks --ks-key-alias mykey app-release.apk\napksigner verify --print-certs app-release.apk\n```\n\nTwo details worth knowing. The Gradle edit is **best-effort and not the\nguarantee** — pattern-matching a template that changes between Flutter versions\nis not something to stake a security property on — so the pipeline then inspects\nthe APK that actually came out and *fails* rather than hand over a release build\nit cannot show to be unsigned. That check needs `apksigner`\n\n, so release builds\nrequire `--sdk-root`\n\nor `ANDROID_SDK_ROOT`\n\n; without it the state is `unknown`\n\n,\nand unknown is refused, because guessing in the reassuring direction is how a\ndebug-signed APK reaches a buyer labelled as a release build.\n\n| What | Command |\n|---|---|\n| Build one app | `poetry run python src/supervisor.py <prd.json>` |\n| Eval sweep (offline, ~1s) | `poetry run python evals/run.py` |\n| Eval sweep (real analysis) | `poetry run python evals/run.py --analyzer dart --flutter-root \"C:\\flutter\"` |\n| Eval sweep (+ widget tests) | add `--run-tests` |\n| Unit tests | `poetry run pytest` |\n| Build worker (pulls from the queue) | `poetry run python -m src.service.worker` |\n| Prove a build survives a killed worker | `poetry run python scripts/verify_durable_execution.py` |\n| Prove a release APK is unsigned and signable | `poetry run python scripts/verify_release_signing.py` |\n| Prove a generated app really talks to Firestore | `poetry run python scripts/verify_firestore_roundtrip.py` |\n| Prove a user can tap their way into every screen | `poetry run python scripts/verify_navigation_flow.py` |\n| Check a deployment is configured to sell builds | `poetry run python scripts/verify_deployment.py <url>` |\n| Check the x402 payer is funded | `poetry run python scripts/check_x402_funding.py` |\n\nUseful flags: `--generator {template,claude}`\n\n, `--analyzer {stub,dart}`\n\n,\n`--max-repairs N`\n\n, `--build-dir PATH`\n\n, `--clean`\n\n, `--execute`\n\n,\n`--build-mode {debug,release}`\n\n, `--sdk-root PATH`\n\n.\n\nPoetry may not be on `PATH`\n\n; `python -m poetry ...`\n\nworks either way. The Flutter\nSDK is deliberately not on `PATH`\n\n— pass `--flutter-root`\n\nor set `$FLUTTER_ROOT`\n\n.\n\n```\nplan ──> genui ──> logic ──> qa ──┬──> package ──> END\n          ^          ^            │\n          └──────────┴── repair ──┘        (bounded by --max-repairs)\n```\n\nEach subagent owns a strict slice of the output and may not write outside it — enforced in code, not just documented:\n\n| Path | Owner |\n|---|---|\n`DESIGN.md` , `pubspec.yaml` , `analysis_options.yaml` |\nPlanning |\n`lib/ui/**` |\nGenUI |\n`lib/providers/**` , `lib/main.dart` |\nLogic |\n`test/**` |\nQA (derived from the generated sources) |\n\nQA diagnostics are routed to whichever agent can actually fix them. If a round changes nothing, the router escalates to the other agent rather than spending the whole repair budget re-running the one that already failed.\n\nBoth implement the same `CodeGenerator`\n\nport, so the graph does not know which\nit has.\n\n(default) — deterministic Python templates. No network, no credentials. This is the validated path.`template`\n\n— a test double emitting correct Flutter that shares none of`fixture`\n\n`TemplateGenerator`\n\n's naming conventions. Proves the grading harness is not merely grading its own output. See`evals/fixture_generator.py`\n\n.— real Claude calls with structured outputs. Validated on Opus 5 at 11/11 across the eval sweep.`claude`\n\nHonest status, because \"it compiles\" and \"it works\" are different claims:\n\n| Property | How | Status |\n|---|---|---|\n| PRD contract | Pydantic, referential integrity | ✅ |\n| Compiles / type-checks | real `flutter analyze` |\n✅ 11/11 PRDs |\n| Matches the PRD | `src/ports/conformance.py` |\n✅ |\n| No unwired/hallucinated code | analyzer + conformance | ✅ |\n| Screens actually build at runtime | generated widget smoke tests | ✅ |\n| Repair loop recovers honestly | fault injection | ✅ |\n| x402 gate | unit tests | ✅ |\n| EIP-712/EIP-3009 signature verification | real keys, 28 tests | ✅ |\n| Replay protection | atomic claim, concurrent race test | ✅ |\n| Facilitator wire format | live `facilitator.payai.network` |\n✅ payload accepted, payer recovered |\n| On-chain settlement | live, Base Sepolia | ✅ real tx, ~1.2s |\n| Replay refused on-chain | store bypassed | ✅ `duplicate_settlement` |\n| Multi-process replay safety | 2 uvicorn workers, shared Redis | ✅ replay refused across processes |\n| Durable build execution | worker killed mid-build, across processes | ✅ rebuilt by another worker |\n| Duplicate work bounded, not excluded | lease lapses under a stall | |\n| HTTP service, end to end | live server, PRD → APK download | ✅ |\n| Buyer cannot self-certify payment | forged flag → 402 | ✅ |\n| Claude generator — request shape | fake transport, 18 tests | ✅ |\n| Harness works on non-template code | `--generator fixture` |\n✅ full pipeline + APK path |\n| Claude generator (Opus 5) | full eval sweep | ✅ 11/11 |\n| APK packaging (template) | `flutter build apk --debug` |\n✅ correct applicationId |\n| APK packaging (Opus output) | `flutter build apk --debug` |\n✅ 145 MB, x402-gated |\n| The generated app starts at all | `main()` executed in Chrome |\n✅ was broken in every app |\n| Real Firestore read/write | emulator + Chrome, generated app | ✅ template generator, web only |\n| Generated date fields survive a round trip | same | ✅ was broken; see below |\n| Every generated app ships a round-trip test | generated from the PRD in the QA phase | ✅ not run in the loop — needs an emulator |\n| That test generator is not template-specific | discovers both generators' serialiser pairs | ✅ `fromSnapshot` /`toMap` and `fromDoc` /`toJson` |\n| Claude output against a live Firestore | Opus 5 app, emulator + Chrome | ✅ round trip passes |\n| Discovery works on model code nobody wrote for it | Opus, Haiku, template, fixture | ✅ four serialiser shapes |\n| Declared navigation is actually wired | conformance `unreachable_screen` |\n✅ found 3 dead routes |\n| The destination renders when navigated to | app's own `main()` + `Navigator` , Chrome |\n✅ generated per PRD |\n| A user tapping the affordance arrives | the app's own buttons, Chrome + emulator | ✅ template output, 3 actions |\n| Those tap tests are not decorative | affordance hidden, everything else intact | ✅ exactly the right test went red |\n| The same against a model's own widget choices | — | ❌ not yet run on Claude output |\n| A tap arriving after a form is filled in | — | ❌ multi-step flows; see below |\n| Release build is unsigned, not debug-signed | real `--release` build, `apksigner` |\n✅ verified unsigned |\n| The artifact is signable by the buyer | signed with a throwaway keystore | ✅ verifies against their cert |\n| Play upload | — | ❌ needs a Play account and a listing |\n| A misconfigured deployment is refused | 5 broken services, real HTTP | ✅ caught all 5, fix named |\n| The accepting endpoint is bounded | limiter + endpoint tests | ✅ 21 tests, both backends |\n| The suite runs on Linux, not just Windows | GitHub Actions matrix | |\n| The deployment image builds | Hetzner VM, Ubuntu 26.04, amd64 | ✅ 7.71 GB, first attempt |\n| A buyer pays and receives an APK | live deployment, Base Sepolia | ✅ real tx, 151 MB APK |\n| The toolchain resolves on Linux | the deployed build | ✅ was broken; see below |\n| A finished build stops costing 2 GB | live deployment, before/after | ✅ 2.0 GB → 144 MB |\n| The pruned build still downloads | re-fetched after pruning | ✅ `200` , 151 MB |\n| Reachable over TLS on a public address | Let's Encrypt, TLS-ALPN-01 | ✅ trusted cert, no tunnel |\n| A buyer on the open internet can buy | paid run over public HTTPS | ✅ 151 MB APK delivered |\n\nOne caveat on the two Redis rows, since \"✅\" is doing real work there. There is\nno Redis daemon on the development machine, so both were verified against\n`fakeredis`\n\n— for the unit tests in-process, and for the cross-process durability\nrun over `TcpFakeServer`\n\n, a real TCP socket speaking real RESP. The process\nboundaries, the sockets and the `SIGKILL`\n\nare genuine; the server implementing\n`LMOVE`\n\nand key expiry is not. Running `scripts/verify_durable_execution.py`\n\nagainst a real Redis is a `REDIS_URL`\n\naway and has not been done.\n\nThose rows earned their asterisk the hard way. Everything above them is static —\n`flutter analyze`\n\nproves the code type-checks, conformance proves it matches the\nPRD, the generated widget tests prove each screen builds in isolation with its\nproviders overridden — and none of it runs the app. Actually running it found\ntwo bugs in every app the generators had ever produced.\n\n**The app could not start.** The composition root was `await Firebase.initializeApp();`\n\nwith no options, and the generator emits no\n`google-services.json`\n\neither. On web that fails an assertion inside\n`firebase_core_web`\n\nbefore the first frame; natively there is no config file to\nfall back to. The app compiled, analysed clean, passed its widget tests and\npackaged into a release-ready APK — and died on launch. Identifiers now come\nfrom `--dart-define`\n\n, with `null`\n\npassed when none are supplied so a build\ncarrying a native config file still works.\n\n**Dates threw on the way back in.** Firestore stores a `DateTime`\n\nas a\n`Timestamp`\n\nand hands back a `Timestamp`\n\n, so `data['x'] as DateTime?`\n\n— well-typed\nDart — always threw the first time an app read a document it had written itself.\n\nBoth fixes are guarded by tests that fail against the old output and pass against the new, which is the only reason to trust them.\n\nThat guard is no longer hand-written. The QA phase now emits an\n`integration_test/<model>_roundtrip_test.dart`\n\nper model, and the emitted test\nwas itself checked the same way: it passes against the fixed mapper and fails\nagainst the old one with the exact `Timestamp is not a subtype of DateTime?`\n\n.\n\nIt writes through the app's **own** serialiser and reads back through the app's\n**own** deserialiser, which is the only formulation that is not a trap. A\nhand-written document would have to pick a wire format, and the two generators\ndisagree on purpose — `TemplateGenerator`\n\nstores a date as a `Timestamp`\n\n, the\nfixture generator as an ISO-8601 string, and both are correct because each reads\nback what it wrote. Nothing about the pair is hard-coded either: the method\nnames are read out of the generated code, because `fromSnapshot`\n\n/`toMap`\n\nand\n`fromDoc`\n\n/`toJson`\n\nare the same contract spelled differently, and a check\nwritten against one generator's spelling silently stops testing the other.\n\nRunning it is opt-in. It needs an emulator and a browser, and making the analysis loop depend on Node, a JDK 21 and Chrome would be a large tax on every build for a check most builds cannot run.\n\nOne limit on that ✅: it ran in **Chrome, not on Android** — this machine has no\nAVD or system image — which is enough for a platform-independent Dart mapper bug\nand not enough to claim the app works on a phone.\n\nOpus output has now been through it, and passed: the model it wrote handles\n`Timestamp`\n\ncorrectly in both directions, so the prompt guidance holds up rather\nthan merely existing. Getting there cost two bugs, both in the *discovery* and\nboth the same mistake this module was written to avoid — over-fitting to the one\ngenerator that was available while writing it.\n\n`TemplateGenerator`\n\nmarks every constructor parameter`required`\n\n. Claude makes most of them optional with defaults. Matching only`required this.x`\n\nfound nothing to assert and emitted**no test at all**— failure that looks identical to success.- The deserialiser need not take a\n`DocumentSnapshot`\n\n. Opus writes`fromMap(String id, Map<String, dynamic> map)`\n\n, keeping the model free of Firestore types, which is arguably the better design and matched nothing.\n\nFour serialiser shapes are now covered — `fromSnapshot`\n\n/`toMap`\n\n,\n`fromDoc`\n\n/`toJson`\n\n, `fromFirestore`\n\n/`toFirestore`\n\n, `fromMap`\n\n/`toMap`\n\n— across\ntwo call arities. The lesson is the one this repo keeps relearning: a check\nwritten while looking at a single generator encodes that generator's taste, and\nthe only way to find out is to run it against code somebody else wrote.\n\nThe PRD declares navigation as data — `Action(kind=\"navigate\", target=<screen>)`\n\n— and the schema already refuses targets that name screens which do not exist.\nThe generator listed every one of those screens in the routes table and then\nnever pushed a single route. `/capture`\n\nand `/settings`\n\nexisted and no widget\ncould get to them: the app was a form nobody could open.\n\nEverything passed. Each screen built in isolation, so the smoke tests were green. The routes table was valid Dart, and the analyzer has no opinion about whether a route is ever navigated to. Conformance checked that the screens existed, which they did.\n\n`unreachable_screen`\n\ncloses it, and is deliberately loose about *how* a screen is\nreached — a named route, a `MaterialPageRoute`\n\nbuilding the widget, or a\nrouter's `go()`\n\nall count. What it will not accept is the target appearing only\nin the routes table that declares it, which is exactly the shape the bug had.\n\nTwo things worth recording. The first version of the check was **vacuous**: it\nsearched every UI file for `CaptureScreen(`\n\nand found the destination's own\nconstructor in its own file, so every unreachable screen looked reachable. It\nwas caught by running it against the actual broken output rather than a fixture.\nAnd once it worked it immediately found two more instances nobody had looked\nfor — `auth_flow`\n\nand `many_screens`\n\n— because the fix had only covered list\nscreens, and auth, settings and detail screens had no affordance to hang\nnavigation off at all.\n\n`unreachable_screen`\n\nis a **static** guarantee: something in the app navigates to\nevery declared target. It says nothing about whether the destination works, and\nit cannot — `'/capture': (context) => const CaptureScreen()`\n\nsatisfies it whether\nor not `CaptureScreen`\n\nsurvives being built.\n\nSo the QA phase also generates `integration_test/navigation_test.dart`\n\n, which\nboots the app through its **own main()** — the real composition root, the real\n\n`ProviderScope`\n\n, the real Firebase initialisation — drives its own `Navigator`\n\nto\neach declared target, and checks the destination is on screen. Delete a route\nfrom the generated app and it fails with `Could not find a generator for route RouteSettings(\"/capture\")`\n\n, which is how we know it is testing anything.Destinations are identified by their **PRD title**, the one label that is the\nsame whatever a generator calls its classes, files or routes. Matching\n`<Id>Screen`\n\nwould be matching one generator's convention.\n\nDriving the navigator still leaves the user out of it. `unreachable_screen`\n\nsays\n*something in the source* navigates to each target; the push case says the\ndestination renders once you get there. Neither says a person looking at the\nsource screen has anything to tap.\n\nThe first version of this refused to go further, on the grounds that hunting for a button depends on widget choices and would go flaky. Three decisions make it honest instead:\n\n**What counts as tappable is not a list of buttons.**`InkResponse`\n\nsits under`TextButton`\n\n,`ElevatedButton`\n\n,`IconButton`\n\n,`FloatingActionButton`\n\nand`ListTile`\n\n;`CupertinoButton`\n\nand a hand-rolled`GestureDetector`\n\ncover the rest. Enumerating button classes would have encoded`TemplateGenerator`\n\n's taste — and would have missed the affordance that ships most, since a generated list screen navigates from a FAB whose only child is an icon and whose label is therefore nothing at all.**Every attempt restarts from a freshly pushed source screen**, which turns \"tap things until something happens\" from one stateful sequence into N independent one-tap experiments. It matters here: the generated FAB writes a draft document before it navigates, so without the reset each attempt would face a different screen than the last.**Arrival is the route name first and the title second.** A button labelled with the destination's title is good UI, and if arrival meant \"the title is on screen\" the test would pass before tapping anything — so the title only counts when it was*not*already visible.\n\nNone of that is evidence. `scripts/verify_navigation_flow.py`\n\nis: it generates\nthe eight-screen `many_screens`\n\napp, runs the navigation test in Chrome against\na Firestore emulator, and then runs it again against a mutant with one\naffordance wrapped in `Offstage(offstage: true, ...)`\n\n.\n\nThat mutation is chosen for what it leaves working. The `Navigator.pushNamed`\n\ncall is still in the file, so `unreachable_screen`\n\nstill passes. The route table\nis untouched, so the push case still passes. The widget is still in the tree, so\nnothing about the build changes. It is simply never on screen — the app ships\nwith a screen no user can open, and everything static stays green:\n\n```\nFailure in method: openToday: a tap on inbox reaches today\n  Expected: empty\n    Actual: 'tapped 2 of 2 affordance(s) on /inbox and none of them reached /today'\n```\n\nExactly one of the six generated cases went red, and it was the right one. That is the only reason to believe the tap cases test anything.\n\n**What it still cannot prove** is that a *multi-step* flow arrives. A sign-in\nscreen that navigates only after valid credentials needs a tap and some typing,\nand the PRD says nothing about what to type. Those fail here rather than being\nquietly skipped, which is the right direction — but it means a red tap case is\n\"look at this\", not automatically \"the app is broken\".\n\n**And it has only been run on template output.** Every over-fitting bug in this\nrepo was invisible until code somebody else wrote went through the check — the\nround-trip discovery was broken twice that way. The affordance search is written\nnot to care whose widgets it is looking at, which is exactly the kind of claim\nthat has been wrong here before. `--generator claude`\n\non this script is the next\nthing worth spending on.\n\n**Automation needs its own browser.** `flutter drive -d chrome`\n\ncannot get a\ncontrollable Chrome while an ordinary Chrome session is running — the launch is\ndelegated to the existing instance, the debug port is never opened, and `dwds`\n\nfails with `AppConnectionException`\n\nafter about 25 seconds. It reads like a code\nfault and is not one. Point `CHROME_EXECUTABLE`\n\nat a Chrome-for-Testing binary\nwhose version matches `chromedriver`\n\n:\n\n```\nnpx @puppeteer/browsers install chrome@150.0.7871.124 --path ~/chrome-for-testing\nexport CHROME_EXECUTABLE=~/chrome-for-testing/chrome/win64-150.0.7871.124/chrome-win64/chrome.exe\n```\n\n`flutter drive`\n\nalso wants the WebDriver on port 4444 unless `--driver-port`\n\nsays otherwise, and the failure it gives for a chromedriver on any other port\nnames 4444 rather than the port it was asked for.\n\n339 unit tests; eval sweep 11/11 against real analysis and widget tests; a debug APK built end to end from a payment-verified PRD.\n\n```\nsrc/\n  supervisor.py        CLI entry point\n  prd/schema.py        the PRD contract — strict, validates referential integrity\n  graph/               LangGraph wiring, shared state, the five nodes\n  ports/\n    generator.py       CodeGenerator protocol\n    templates.py       deterministic generator (validated)\n    llm.py             Claude generator (Opus 5, validated)\n    analyzer.py        StubAnalyzer (offline) + FlutterAnalyzer (real)\n    conformance.py     does the app match the PRD?\n    smoke.py           generates widget tests from the output\n    roundtrip.py       generates the Firestore round-trip test from the output\n    navigation.py      generates the runtime navigation test: push and tap\n    runtime.py         runs them\n    ownership.py       which agent can fix which diagnostic\n  payments/x402.py     the payment gate + x402 verifier selection\n  payments/eip3009.py  EIP-712 signature recovery and field checks\n  payments/replay.py   single-use nonces, atomic claim\n  service/\n    app.py             FastAPI app; accepts and pays, then queues\n    jobs.py            the job record (including the PRD) and its store\n    queue.py           reliable queue: atomic reserve, leases, requeue\n    worker.py          pull-based workers; heartbeats and bounded retries\n  build/pipeline.py    APK packaging (x402-gated)\n  build/signing.py     unsigned release builds, and proving they are unsigned\n  build/scaffold.py    generates android/ via `flutter create`\nevals/\n  prds/                10 PRDs chosen to break things\n  run.py               pass-rate harness\nemulator/              Firestore emulator config (a demo-* project; no keys)\n```\n\n**Escaping is load-bearing.** PRD text crosses into two generated languages, and each boundary needs its own escaping:`yaml_scalar`\n\nfor`pubspec.yaml`\n\n,`dart_string`\n\nfor Dart literals. Both exist because both broke in testing.**The Firestore emulator needs a different JDK than the build does.**`firebase-tools`\n\nrefuses anything below JDK 21; the Android/Gradle toolchain here is pinned to 17. They coexist — the emulator is handed a 21+ on its own`PATH`\n\nand nothing else changes — but the failure reads as a Java problem rather than a version-policy one. Set`EMULATOR_JAVA_HOME`\n\nif the script's search does not find a 21+. It also needs`firebase-tools`\n\nand a`chromedriver`\n\nwhose major version matches the installed Chrome.**The stub analyzer cannot parse Dart.** It enforces conventions and catches provider desync, but it passed a project with 59 syntax errors. Treat a green offline run as a smoke signal, not proof; use`--analyzer dart`\n\nfor that.**Cupertino is a full template family**, not a swapped root widget — a`CupertinoApp`\n\nprovides no`MaterialLocalizations`\n\n, so Material widgets throw at runtime inside one.**Smoke tests use stubbed providers.** They prove a screen builds and its data layer resolves; they do not prove navigation works or that writes reach Firestore.", "url": "https://wpnews.pro/news/a-langgraph-pipeline-that-generates-compiling-flutter-apps-with-a-repair-loop", "canonical_source": "https://github.com/carlosge492/app-generation-microservice", "published_at": "2026-08-01 04:21:15+00:00", "updated_at": "2026-08-01 04:52:23.598785+00:00", "lang": "en", "topics": ["generative-ai", "ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["LangGraph", "Flutter", "x402", "EIP-3009", "EIP-712", "USDC"], "alternates": {"html": "https://wpnews.pro/news/a-langgraph-pipeline-that-generates-compiling-flutter-apps-with-a-repair-loop", "markdown": "https://wpnews.pro/news/a-langgraph-pipeline-that-generates-compiling-flutter-apps-with-a-repair-loop.md", "text": "https://wpnews.pro/news/a-langgraph-pipeline-that-generates-compiling-flutter-apps-with-a-repair-loop.txt", "jsonld": "https://wpnews.pro/news/a-langgraph-pipeline-that-generates-compiling-flutter-apps-with-a-repair-loop.jsonld"}}