
Inspiration
A solo maker selling candles has no agency and no compliance team. When a platform rejects their ad it tells them the ad was rejected without telling them which rule it broke, so the only way forward is to guess and resubmit. The interesting problem here is not generating an image, because everyone can do that now. It is generating one that is allowed, and being able to show why.
What it does
Upload one product photo. The ad is generated from that photograph rather than from a description of it, so a seller gets their own product restyled for advertising instead of a stock lookalike.

Two agents then negotiate. Creative Studio drafts the ad. Clearance reviews it against a dated snapshot of Meta, TikTok and Pinterest ad policy and returns structured issues, each one tied to a specific policy chunk, and it is not allowed to raise an issue it cannot cite. Creative Studio turns those issues into the next draft. The loop runs under a round cap and a call budget until the risk score reaches zero or the campaign is honestly rejected. On the recorded run, it goes 75, 45, 45, 0 in four rounds.

On approval of the asset, the final risk report and the manifest are committed to Backblaze B2 under compliance Object Lock, and the localization cascade rebuilds the same campaign for Spanish, French, and Japanese markets, reviewing each one again.
Every round is written to B2 as it happens and the live view reads it back out of the bucket, so the screen you watch is the bucket's contents, not server memory.
How we built it
FastAPI on Render, one deployable service that serves the UI, the API and the B2 webhook receiver. Generation goes through Genblaze pipelines with fallback_models chains. Storage is B2, through the S3-compatible API for the app and the native API for bucket configuration.
There is no database. campaigns/{id}/campaign.json is the orchestration state, and a campaign killed mid-round resumes by reading B2 alone. We proved that by killing one: live_loop.py --kill 2 calls os._exit the instant a round lands, and --resume continues from the bucket.
The risk score is computed in code from issue severities. The model is never allowed to emit one.

Providers and models
Every call below runs through a Genblaze Pipeline. Model ids are the ones in
app/llm.py and app/voice.py, not a summary of them.
| Step | Provider | Model | Falls back to |
|---|---|---|---|
| Ad image (primary) | NVIDIA NIM | black-forest-labs/flux.1-dev |
flux.1-schnell, then Gemini |
| Ad image (vendor fallback) | Google Gemini | gemini-3.1-flash-image |
none, last hop |
| Clearance, observe pass (vision) | NVIDIA NIM | meta/llama-3.2-90b-vision-instruct |
llama-3.2-11b-vision-instruct |
| Clearance, judge pass (text) | NVIDIA NIM | meta/llama-3.1-70b-instruct |
llama-3.3-70b-instruct |
| Copy, transcreation, chat | NVIDIA NIM | meta/llama-3.1-70b-instruct |
llama-3.3-70b-instruct |
| Chat (vendor fallback) | Google Gemini | gemini-3.6-flash |
none, last hop |
| Market voiceover | ElevenLabs | eleven_multilingual_v2 |
eleven_turbo_v2_5, then GMI Cloud |
| Voiceover (vendor fallback) | GMI Cloud | minimax-tts-speech-2.6-turbo |
inworld-tts-1.5-mini |
Two layers of fallback, and they are not the same mechanism. Within a vendor,
Genblaze's own fallback_models chain handles it (flux.1-dev to
flux.1-schnell). Across vendors, app/llm.py layers a second hop on top,
because what actually runs out on a free tier is one vendor's quota rather than
one model. A checkbox on the upload form forces the primary to fail so you can
watch the hop rather than take our word for it.
Challenges we ran into
- Convergence was the whole project's risk. Image models regenerate rather than edit, so a revision loop can oscillate forever. It converges only because the fixes are passed as structured data rather than prose.
- A model asked to look and judge in one call scored a clean 0 on an image it had just described as carrying a wordmark. Splitting the review into an observe pass and a judge pass fixed it, at the cost of one extra call per round.
- The most boring input was the broken one. Retrieval returned zero chunks for well-behaved copy, because a clean ad shares no vocabulary with the rules that would forbid it, and zero chunks is our fail-closed signal. An honest seller could never have been approved. Every test input we had contained a violation, so nothing caught it for four sessions.
- Every policy citation pointed at a 404. Meta had reorganised the URLs the snapshot cited. A dead citation link is worse than no link, because it invites someone to check and then fails them.
- Object Lock had to be applied per object rather than as a bucket default. A bucket-wide default retention also freezes drafts/ and rejected/, and a locked version cannot be deleted by a Lifecycle Rule, so the 7-day purge silently stops working.
Accomplishments that we're proud of
The negotiation is auditable end to end. Every round, every citation and every score is in the bucket; the transcript is the round files concatenated verbatim rather than a summary a model wrote, and a manifest can be read back out of B2 days later, rehashed and verified against the bytes it names.
The failure paths are designed rather than incidental. A campaign that cannot be made compliant gets a real closing screen with the unresolved citations. A market whose copy comes back untranslated says so instead of presenting English as a translation. A review that cannot be grounded fails closed at 100 rather than approving.
What we learned
- A green check on your credential script proves nothing about your app. verify_providers.py passed 14 of 14 while the deployed reviewer was completely dead, because it tests credentials and not the code path the app actually uses. Only an end-to-end run on the deployed instance counts.
- Optional dependency extras will quietly remove a feature rather than crash. The nvidia extra installs the connector but not its chat dependency, so every chat call failed while image generation kept working. The app drew ads and reviewed none of them, scoring every round 100 and rejecting the campaign. It ran fine locally only because an unrelated package had pulled that dependency into the dev environment.
- Test that the effect happened, not that the argument was passed. A test asserting the correct argument reached the image call still passed while a hardcoded default silently overrode it.
- Storage reads are not free. B2 charges Class C for head_bucket, head_object and list_objects_v2, and a 4 second polling view is the dominant consumer. We now check the cap before a demo.
- Free text from a model should never be interpolated into another model's prompt without a guard, no matter how structured the object carrying it looks.
What's next for Signal
- Per-market policy corpora, beyond the Spanish nuance chunks we have today, so a market is reviewed against its own rules rather than a translation of someone else's.
- The B2 Event Notification rule the moment the account can create one. The handler is already built, signature-verified and idempotent, and a 30-second timer is standing in for it.
- More platforms. The reviewer is policy-agnostic, and Pinterest proved it: adding it was a snapshot file and one line naming it, with no change to retrieval, scoring or the UI. Google Ads is the same shape of work.
- Snapshot freshness as a first class idea. Policies move, our citations went 404 once already, and a compliance tool should be able to tell you how old the rules it judged you against are.
Built With
- amazon-web-services
- backblaze-b2
- boto3
- computer-vision
- css3
- fastapi
- flux
- genblaze
- generative-ai
- gmi-cloud
- google-gemini
- html5
- javascript
- jinja
- llama
- multi-agent-systems
- nvidia-nim
- openai
- pillow
- python
- rag
- render
- rest-api
- uvicorn


Log in or sign up for Devpost to join the conversation.