Cost effective LLM content creation.
Give it a topic. It researches it, reshapes the research into typed JSON, and never once blocks waiting on either step:
you: "Anthropic"
-> Bright Data researches it live (real web search, real citations)
-> BD's webhook delivers the essay to this app
-> this app submits it to Gemini's Batch API
-> Gemini's webhook fires when the reshape is done
-> structured JSON, logged
Two different providers, two different async jobs, zero polling loops. Both halves report back on their own schedule via a webhook this app owns — you fire the topic and walk away.
The straightforward version of this — call an LLM to research a topic, call
another LLM to reshape the answer, done — is one function with two blocking
HTTP calls in it. That's not what's interesting here. What's interesting is
that BOTH calls are cheaper in their async form (Bright Data's webhook
delivery vs. its synchronous scrape; Gemini's Batch API vs. generateContent)
and this app is built entirely around never holding a connection open to
either provider. It fires a request, returns immediately, and lets each
provider's own webhook tell it when to do the next thing.
export interface ReshapeStage<T> {
systemPrompt: string; // what to ask
schema: ZodType<T>; // what shape to demand back
model?: string;
}A stage never calls an API directly — it hands back a prompt and validates
whatever text comes back against a schema (src/reshape.ts). Both the
submit side (submitReshapeBatch) and the fetch side
(fetchReshapeBatchResult, called once the Gemini webhook fires) build
their request from the same stage.systemPrompt and route their response
through the same schema.safeParse — a response that's malformed or
off-schema fails the same way regardless of which run produced it.
| Route | Fires when | Does |
|---|---|---|
api/bd-webhook.ts |
Bright Data finishes researching the topic | Pulls the essay out of BD's record, submits it as a Gemini batch job, returns — no polling. |
api/gemini-webhook.ts |
Gemini's Batch API resolves (batch.succeeded / .failed / .expired) |
Verifies the signature, fetches the batch's result, validates it against the stage's schema, logs it. |
Both are deliberately minimal: verify the request is real, do the one thing this step is for, return fast. Neither one polls anything — that's the whole point of registering a webhook instead.
Each result also carries the real costUsd Google billed for that call
(checkBatch in src/gemini-batch.ts computes it from the response's own
usage metadata) — for a demo whose whole pitch is being cost effective, the
actual number is worth keeping, not just the theoretical per-token rate.
src/gemini-webhook-verify.ts implements the
Standard Webhooks
spec Google's Gemini API uses: the signed content is
{webhook-id}.{webhook-timestamp}.{rawBody}, HMAC-SHA256, secret prefixed
whsec_. The header can carry multiple space-delimited v1,<base64> entries
during key rotation — a match on any one is a pass. A stale timestamp (more
than 5 minutes old) is rejected outright, per the spec's own replay
protection.
One correction to Google's own docs, confirmed against the live API: the
batch webhook event enum is batch.succeeded / batch.failed /
batch.expired — not batch.cancelled, which appears in Google's docs
prose but the live API rejects as an invalid subscription value.
scripts/register-gemini-webhook.mjs
subscribes to the three that actually exist.
npm install
npm run build # type-check
npm test- Deploy this to Vercel (or anywhere that'll run
api/*.tsas HTTP functions) — Google needs a real, reachable URL before it'll register a webhook against it. npm run register-webhook -- --uri https://<your-deploy>/api/gemini-webhook— one-time. PrintsGEMINI_WEBHOOK_SECRET; Google shows it exactly once, so store it in your deployment's env immediately.- Set
GEMINI_API_KEY,BRIGHT_DATA_API_KEY,BD_WEBHOOK_SECRET, and theGEMINI_WEBHOOK_SECRETfrom step 2 on the deployment. npm run trigger -- --topic "some company" --url https://<your-deploy>— fires the whole cycle. Watch it land in your deployment's logs.
- No cohort pooling. This submits one Gemini request per topic — simpler
to read, not the most cost-efficient shape at real volume. Packing many
essays into fewer, fuller batch requests is a straightforward extension of
submitReshapeBatch, deliberately left out to keep the demo legible. - No straggler retry. A batch item that comes back malformed or
schema-invalid is logged as
{ ok: false, error }and left there. - Logged, not stored. This proves the cycle works; it doesn't ship a database. A real pipeline would persist the structured result somewhere a human or another system can actually query it.
- This can take a while — plan for minutes, not seconds. Confirmed on a real run: Bright Data's own delivery landed within a few minutes of triggering, and Gemini's batch webhook fired about 5 minutes after that (one request in the batch). Neither provider gives a wall-clock guarantee faster than that, so don't build anything — including how you watch this demo — that assumes a fast turnaround.
Confirmed against a live delivery, not assumed from docs: the Gemini
webhook's payload is {"id","type","created_at","data":{"id":"batches/<id>"}}
— data.id arrives already fully-qualified (batches/xyz...), matching
submitBatch's own name field exactly, no normalization needed.
The reshape-stage pattern (one prompt + one schema, validated once) is the extracted, generalized shape of a reshape stage running in production against real research essays at real volume. The webhook wiring — both BD's and Gemini's — mirrors a real production integration; the credentials and any account-specific details are not.
MIT.