Rosie runs as one Worker,
three Durable Object namespaces,
and zero cron jobs.
Everything is a single Cloudflare Worker sitting in front of two stateful layers: a per-restaurant-group Durable Object
that owns review state and schedules its own work with alarms, and a Think agent DO that holds conversations.
Postgres (PlanetScale, via Hyperdrive) owns identity and credentials. The interesting inversion is that
RestaurantGroupDO calls back into the Worker — it's deliberately auth-less, so the Google OAuth token never enters it.
fetch handler wrapped by Sentry. No Hono. Effect runs at the request boundary.RestaurantGroupDO's env can see three of them.
Read it top to bottom. Guests and providers come in at the top, the Worker fans out in the middle, and everything
stateful hangs below it. The thick edge is the one that surprises people: RestaurantGroupDO wakes on its own
alarm and calls back up into the Worker to do anything that needs the Google credential.
flowchart TD GUEST["Guest / owner phone
WhatsApp"] BROWSER["Owner browser
React SPA"] subgraph PROV["PROVIDERS"] BIRD["Bird Channels API
WhatsApp + SMS"] TG["Telegram Bot API"] RESEND["Resend
mail + inbound"] end subgraph WK["CLOUDFLARE WORKER — rosie"] ROUTER["fetch handler · Sentry-wrapped
Effect at the boundary
one wide event per request"] AUTHN["better-auth
/api/auth/*"] APIR["API route groups
connections · group · reviews"] INGRESS["/api/messengers/*
webhook ingress controller"] EPS["WorkerEntrypoints ×4
ReviewFetcher · ReplyDrafter
NewReviewNotifier
ReviewReplyPublisher"] ASSETS["ASSETS
SPA fallback"] end subgraph DOS["DURABLE OBJECTS"] RGDO["RestaurantGroupDO
idFromName is the groupId
auth-less · SQLite · alarm scheduler"] AGENT["RosieAgent : Think
per-org host
per-guest facet sessions"] end subgraph ST["STORES"] HD["Hyperdrive"] PG[("PlanetScale Postgres
identity · auth · connections")] KV[("RATE_LIMIT_KV
rate limits + OAuth tokens")] end subgraph MDL["MODELS"] AIGW["AI Gateway
custom-together"] TOG["Together
Kimi K2.6"] CBR["Cerebras
front-ack"] end GBP["Google Business Profile
reviews + replies"] GUEST --> BIRD BROWSER --> ROUTER BIRD -->|signed webhook| INGRESS TG --> INGRESS RESEND --> INGRESS ROUTER --> AUTHN ROUTER --> APIR ROUTER --> INGRESS ROUTER --> ASSETS INGRESS -->|phone lookup| HD INGRESS -->|route to org agent| AGENT APIR -->|RPC| RGDO APIR -->|publish reply| EPS AUTHN --> HD AUTHN --> KV HD --> PG AGENT --> AIGW AGENT -->|workspace snapshot| RGDO AGENT -->|connections read| HD AGENT -->|reply bubbles| BIRD AGENT -.->|instant ack| CBR AIGW --> TOG RGDO ==>|alarm fires callback| EPS EPS -->|token from KV| GBP EPS -->|draft reply| AIGW EPS -->|new-review ping| BIRD classDef ingressC fill:#b4530933,stroke:#b45309,stroke-width:2px classDef workerC fill:#0369a133,stroke:#0369a1,stroke-width:2px classDef doC fill:#0f766e33,stroke:#0f766e,stroke-width:2px classDef storeC fill:#04785733,stroke:#047857,stroke-width:2px classDef extC fill:#9f123933,stroke:#9f1239,stroke-width:2px classDef modelC fill:#a1620733,stroke:#a16207,stroke-width:2px classDef clientC fill:#5b718922,stroke:#5b7189,stroke-width:1.5px class GUEST,BROWSER clientC class BIRD,TG,RESEND,GBP extC class ROUTER,AUTHN,APIR,EPS,ASSETS workerC class INGRESS ingressC class RGDO,AGENT doC class HD,PG,KV storeC class AIGW,TOG,CBR modelC
RestaurantGroupDO never holds the OAuth token. It asks the Worker.
RestaurantGroupDO is auth-less. RosieAgent is also a DO, and it does hold secrets —
the Bird WhatsApp key, the Cerebras key, the Together key — because it has to send replies itself. The rule isn't "DOs don't get secrets";
it's "the DO that walks Google's API on a timer doesn't get Google's token."
WhatsApp is the only channel with a per-restaurant ingress controller in the Worker. Telegram and email are forwarded untouched to a single global agent instance. The reason is the phone lookup: an inbound WhatsApp message carries nothing but an E.164 number, so the Worker has to hit Postgres to learn which restaurant group it belongs to before it can pick a DO.
sequenceDiagram
autonumber
participant G as Guest phone
participant B as Bird
participant W as Worker ingress
participant P as Postgres via Hyperdrive
participant H as RosieAgent host DO
participant F as Guest facet DO
participant M as Together via AI Gateway
G->>B: WhatsApp message
B->>W: POST /api/messengers/whatsapp/webhook
W->>W: Verify Bird HMAC signature, else 401
W->>W: Classify event, ignore anything but inbound text
W->>P: Look up verified sender by phone, 2s timeout, 2 retries
P-->>W: orgId and groupStatus
alt Unknown sender
W->>B: Signup link button, text fallback, never routed
else Group still setting up
W-->>B: 200 ack, deliberately no reply
else Matched sender
W->>H: agent.fetch with received-at and match headers
H->>H: Adapter re-verifies the same signature
H->>B: Front-ack bubble via Cerebras, awaited before the 200
H->>H: chat.processMessage, turn is fire and forget
Note over H,F: SDK resolves the per-guest facet downstream
F->>M: Model turn with 30s-cached workspace snapshot
M-->>F: Reply text
F->>B: Delivered as up to 4 split bubbles
B->>G: Delivered
end
The Worker verifies the Bird HMAC and the adapter verifies it again inside the DO. The Worker's copy is load-bearing: a verified sender is required before the phone lookup or any outbound send, so an unsigned request can't make Rosie talk or touch the database.
A missing signing key is a fail-closed placeholder sentinel — it 401s rather than defaulting open.
The host is addressed per-org (getAgentByName(RosieAgent, orgId)). The actual model turn runs on a
per-guest facet sub-agent with its own Think session, so two guests texting the same restaurant never share a transcript.
The facet name is messenger:whatsapp:<stableSender> — the SDK sanitises the sender through
stableNamePart, so E.164's leading + becomes _: messenger:whatsapp:_31611111111.
WhatsApp and email go through the generic chatSdkMessenger with verifyWebhook: false, which makes the
adapter the single owner of verification inside Think's bridge — the Worker still checks the same HMAC first.
Telegram uses the first-class messenger and is conditionally registered, so missing Telegram config can't block WhatsApp startup.
Dedupe key is dedupe:whatsapp:<payload.id>, 5 min — which is why a payload with no id is dropped (ignored with a 200)
rather than processed.
Four Bird surfaces (WhatsApp text, template, link-button, SMS OTP) share one hand-rolled client — plain fetch, no SDK,
AccessKey auth. WhatsApp and SMS carry separate credential sets.
SMS and email have a dev-outbox seam (a DB table). WhatsApp doesn't — its dev seam is pointing WAPP_BIRD_API_BASE_URL
at the fibbird twin.
Not Hono, not a framework. A plain ExportedHandler that must stay plain so Sentry.withSentry can wrap it.
Effect lives at the fetch boundary rather than in middleware: runRequest builds a layer with the Hyperdrive connection into a
request scope, runs it, emits exactly one wide event, and closes the scope in ctx.waitUntil.
Dispatch order, in full
| # | Stage | What it does |
|---|---|---|
| 1 | gate | External /api/agents/* and bare /agents/* are denied with a 404 — Think's own routes are not a public surface.The DO namespace set is derived at runtime from Env, kebab-cased. |
| 2 | think | Think's worker entry gets first refusal on /api/agents. |
| 3 | health | /api/health inline, no event. |
| 4 | shim | Local-dev self-dispatch (see below). No-op in deployed stages. |
| 5 | auth | /api/auth/* → better-auth handler subtree. |
| 6 | programs | Exact-path Effect programs: /api/me, /api/dashboard, /api/reviews/summary, /api/db-check. |
| 7 | router | HttpRouter groups — connections, group, mappings, locations, reviews. The matched handler stamps its own http.route. |
| 8 | build | /api/build-info → commit SHA + build time, no-store. |
| 9 | dev | QA endpoints behind a DEV_ROUTE_TOKEN bearer and a non-production stage check. |
| 10 | webhooks | /api/messengers/{whatsapp,telegram,email}/webhook. |
| 11 | 404 | Any remaining /api/* → 404, so a typo'd API path never falls into the SPA. |
| 12 | spa | Everything else → env.ASSETS.fetch with SPA not-found handling. runWorkerFirst: ["/api/*"]. |
The four entrypoints — the Worker's inward-facing half
Named WorkerEntrypoint classes bound to the Worker itself. They exist so a caller can reach credentialed work without
receiving credentials. Three of the four are called by RestaurantGroupDO's alarm — those are the only bindings its env declares.
The fourth is the Worker calling itself from a user route: same trick, different caller.
Fetches one page of Google Business Profile reviews. Called once per backfill/sync tick. The Worker resolves the OAuth token from KV.
Produces draft reply text — model secrets stay Worker-side. The DO remains the single writer and persists the result.
Timeouts nest: 9s per review and 12s per batch on the Worker side, under a 15s DO-side backstop. The Worker's budgets bind first.
Sends the batched new-review WhatsApp ping. The Worker owns the phone lookup and the Bird call.
Bound in alchemy, but declared only on the DO's env — never on the Worker's Env.
Owns the provider write when a reply is published. The only one the DO never calls — its single call site is the user route
POST /api/reviews/:reviewId/reply/publish.
alchemy dev, alchemy 2.0.0-beta.56 drops entrypoint on self-service-bindings, so the Worker
self-dispatches those calls over plain non-/api/ paths instead. Temporary, drift-anchored, tracked in ROSIE-ivhrnfyu.
Three namespaces, but only two are rosie's. Granularity is deliberately per restaurant group, not per location —
with a locationId-addressable split seam kept cheap so a future LocationDO is a routing change, not a rewrite.
The group is the instance
Addressed idFromName(groupId), where groupId === organization.id === session.activeOrganizationId, from all
three call sites (the route gate, the agent's snapshot, the agent's tools). Because the instance is the boundary,
no table carries a group_id column.
- SQLite via Drizzle (
durable-sqlite), migrations applied inblockConcurrencyWhileat construction - Tables:
restaurant_location,review,backfill_job,draft_version,provider_location_mapping - Alarms: yes — it's the scheduler. WebSockets: none
- RPC is plain, not Effect — fallible ops return an encoded tagged error the caller decodes, never throw
The agent is a DO
Per-org host instance; per-conversation facet sub-agents. Telegram and email
share one fixed global instance, messenger-ingress.
- Conversation memory is Think's own session SQLite inside the DO — no custom tables, no Drizzle
- The transcript is never mirrored to Postgres — that would be a dual write with two sources of truth
- Per-turn workspace snapshot = two
RestaurantGroupDORPCs plus a Postgres read, cached 30s - Effect runtime built once in
onStartand reused — correct here precisely because a DO is long-lived
Vendored from @cloudflare/think/messengers. Addressed internally by the SDK; not implemented in this repo. It's on the bindings list and otherwise invisible.
#resolveOrgId() recovers the group id from the facet's parent path and guards it with isUuid. The global
non-WhatsApp ingress instance has a non-uuid name — so its tools and workspace snapshot fail closed rather than address a bogus group DO.
This replaces cron. Worker cron floors at one-minute granularity and centralizes fan-out; per-group alarms are sharded by construction, with per-group rate-limit isolation and resumable cursors. One alarm drives both the one-shot backfill and the perpetual re-arming sync.
flowchart TD ALARM["alarm() fires"] --> PICK["Oldest job by createdAt where status is
pending or running — due check done in JS,
not SQL, to dodge timestamp quirks"] PICK -->|none due| WAIT["Return early — the finally block
still re-arms for pacing/backoff"] PICK -->|job| MARK["Mark running BEFORE the fetch
a crash leaves it resumable"] MARK --> FETCH["REVIEW_FETCHER.fetch
the Worker holds the OAuth token"] FETCH --> RES{"Result"} RES -->|429| BACK["Exponential backoff, capped at 30s
then up to 250ms jitter · keep the job"] RES -->|transient| TR["Separate counter, terminal on the 5th
429s are excluded from this cap"] RES -->|other error| FAIL["Terminal: failed"] RES -->|page| UP["Upsert reviews idempotently
advance pageToken"] UP --> MORE{"Walk state"} MORE -->|more pages| PACE["nextRunAt = now + 250ms"] MORE -->|backfill done| SEED["Seed the perpetual sync job
first tick at now + 60s"] MORE -->|sync hit watermark| PIPE["CLAIM → DRAFT this batch →
NOTIFY → DRAFT the backlog"] PIPE --> REARM["Re-arm: pending, cursor reset
nextRunAt = now + 60s"] BACK --> SCHED TR --> SCHED FAIL --> SCHED PACE --> SCHED SEED --> SCHED REARM --> SCHED WAIT --> SCHED SCHED["finally: scheduleNextAlarm
a throw can never strand the loop"] SCHED -->|zero non-terminal jobs left| IDLE["No alarm set — DO goes idle
deliberately, not a bug"] classDef startC fill:#0369a133,stroke:#0369a1,stroke-width:2px classDef okC fill:#04785733,stroke:#047857,stroke-width:2px classDef badC fill:#9f123933,stroke:#9f1239,stroke-width:2px classDef warnC fill:#b4530933,stroke:#b45309,stroke-width:2px classDef keyC fill:#0f766e44,stroke:#0f766e,stroke-width:2.5px class ALARM,PICK,MARK,FETCH startC class UP,PACE,SEED,PIPE,REARM okC class FAIL,TR badC class BACK,WAIT,IDLE warnC class SCHED keyC
Google Business Profile's reviews.list has no server-side since filter, but it defaults to
updateTime desc. So the walk stops the moment a page reaches a review at or under the mapping's watermark. A no-change
sync short-circuits to roughly one page and zero review-row writes — the job row is still patched every tick.
The watermark is fixed at walk start and only committed on completion.
The order is deliberate. This batch is drafted before the ping so the suggested reply rides along in it. The older backlog is retried after, so a stale queue can't delay an unrelated alert.
The notify latch clears only on a Worker 2xx — at-least-once for transport failures. Both the drafter and the notifier swallow every error: a failure must never crash the alarm.
SYNC_INTERVAL_MS is 60s, which means roughly 1 QPM per confirmed location in steady state. The GBP v4
reviews.list quota is per-project, approval-gated, historically low QPS — and carries an explicit in-code warning that it has
not been verified.
The rule is one line: Postgres owns identity, auth, and credentials; the DO owns operational review state.
Nothing crosses. There is no restaurant_location table in Postgres, and no group_id column in the DO.
| Store | Access | Holds | The catch |
|---|---|---|---|
| PostgresPlanetScale | HYPERDRIVEdrizzle + node-postgres |
user, session, account, verification, organization, member, invitation · restaurant_group, location_assignment · connection, credential · magic_link_token · mail_outbox, sms_outbox · items15 tables total |
Fresh pg.Client per invocation — never a cached module-level Pool (cross-DO I/O crash, blocks DO hibernation). The documented exception is better-auth, which needs a pool-like object for Kysely and gets a request-scoped Pool with max: 1, closed in waitUntil. Edge results caching is off. |
| DO SQLiteper group | Drizzle durable-sqlitebundled migrations |
restaurant_location, review, backfill_job, draft_version, provider_location_mapping |
~100-parameter bind limit per statement; the house rule is to chunk IN (...) at ~80 ids — not yet enforced, two unchunked inArray sites remain. No native uuid/json/timestamp. Drizzle over Effect SQL because @effect/sql-sqlite-do emits BEGIN/SAVEPOINT, which DO SQLite forbids. |
| KVone namespace | RATE_LIMIT_KV |
Auth rate-limit counters and provider OAuth token bundles keyed provider-token:{connectionId}:{uuid} |
The name lies. It holds live OAuth tokens — the target that credential.secret_ref dereferences — so its blast radius is much larger than "rate limits". |
Postgres cannot FK into the DO, so location_assignment.location_ids is a bare uuid[]. Three invariants move into app code:
locations belong to the assignment's group; owner/group_admin are forced to scope=all_locations; every active member has ≥1 assignment (deferred).
Conversely restaurant_group.id is a FK into organization.id — an orphan group is impossible.
Rate limiting uses better-auth's customStorage, never secondaryStorage. Setting
secondaryStorage silently relocates sessions onto eventually-consistent KV — a separately-ticketed hazard from the
Hyperdrive-cache bug below, but the same shape.
KV has no atomic increment, so rate limiting is best-effort by design — a tight burst admits a few extra requests. Fine for abuse throttling, a footgun if an exact cap is ever needed.
max_age + 15s stale_while_revalidate). Pooling and connection collapse are unaffected;
only results caching is disabled.
| Service | Used for | Who calls it |
|---|---|---|
| TogetherKimi K2.6 | The chat agent and the reply drafter, routed through the per-stage AI Gateway at /custom-together/v1 | RosieAgent and ReplyDrafter |
| Workers AI@cf/moonshotai/kimi-k2.6 | Fallback when the Together key is placeholder or empty | Native AI binding |
| Cerebrasgemma-4-31b | The pre-debounce "on it" WhatsApp bubble. Placeholder key ⇒ ack stays off | Host DO, direct Bird send |
| Google Business Profile | OAuth connect, enumerate accounts/locations, fetch reviews, publish replies | Worker only — via the four entrypoints |
| Google OAuth | better-auth social sign-in — a separate app from the GBP connection | Worker /api/auth/* |
| BirdChannels API | WhatsApp channel and phone-OTP SMS, under two separate credential sets | Worker + WhatsApp adapter |
| Resend | Two roles: transactional auth mail, and the inbound email agent channel (Svix-signed) | Worker + email adapter |
| Telegram | Bot ingress channel, conditionally registered | Global ingress agent |
| Sentry | Errors only, cross-linked to the wide event by trace.id. An empty DSN makes the SDK inert and leaves trace.id null — wide events still emit | Wraps the handler |
| Workers Logs | Wide events — one structured record per unit of work, emitted as JSON by the Effect logger. No third-party analytics vendor, no OTLP, no collector, no tail worker | Native |
fibgle twin rather than half-connecting
to wrong hosts.The AI Gateway's
together custom provider is bootstrapped outside alchemy on purpose. Alchemy state is per-stage,
so a pr-N teardown would delete the account-shared provider out from under prod and every other preview. That exact footgun already
took out a shared provider in nocturne.
Cloudflare.Vite owns build and deploy. Stage comes from Alchemy.Stage, never process.env.ALCHEMY_STAGE.
Prod is rosie.restaurant; every other stage is rosie-<stage>.fiberplane.io, and a per-PR preview is stage pr-<n>.
Each preview gets its own PlanetScale branch off the preview database's main, provisioned by a script, migrated once, and wired in as the Hyperdrive origin. Branches come up empty.
Each one is a real ~$5/mo PS-DEV cluster, not copy-on-write. Hyperdrive configs consume an account-wide cap (10 free / 25 paid) whose exhaustion hard-blocks new previews. Hence teardown on PR close plus a scheduled orphan-GC.
Alchemy's physical name is ${stack}-${id}-${stage}-<base32>, so the Hyperdrive config lands as
rosie-pg-pr-<n>-<base32>. The orphan-GC and cap-guard regex depends on exactly that shape — which is why
an explicit name must never be passed to Cloudflare.Hyperdrive.
GC deletes the Hyperdrive config before the PlanetScale branch.
CF_AIG_TOKEN — default on for
non-prod, off for prod. A prod deploy that opts in without credentials dies during planning rather than silently flipping auth
off and deleting the live Secret. And the runtime is bound the deploy-computed verdict, not the raw flag, so it can never select
BYOK against a gateway the deploy left unauthenticated.
Everything the Worker can reach. Two runtime bindings exist that Env never names — worth knowing before you go looking for them.
| Binding | Kind | Purpose |
|---|---|---|
ASSETS | assets | The React SPA. Terminal fallback for anything not /api/*. |
HYPERDRIVE | hyperdrive | PlanetScale Postgres. Results caching disabled. |
RATE_LIMIT_KV | kv | Rate-limit counters + provider OAuth token bundles. |
RestaurantGroupDO | do | Per-group review state and the alarm scheduler. |
RosieAgent | do | The Think agent — host + facets. |
ThinkMessengerStateAgent | do | Think SDK-owned messenger state. Vendored. |
REVIEW_FETCHER | service | Self-binding → ReviewFetcher entrypoint. |
REPLY_DRAFTER | service | Self-binding → ReplyDrafter entrypoint. |
REVIEW_REPLY_PUBLISHER | service | Self-binding → ReviewReplyPublisher entrypoint. |
NEW_REVIEW_NOTIFIER | service | Self-binding → NewReviewNotifier. Bound in alchemy, declared only on the DO's env — never on the Worker Env. |
AI | ai | Workers AI fallback model. |
CEREBRAS_API_KEY | secret | Front-ack model. Bound in alchemy, absent from the Worker Env interface — declared on the agent's env only. |
The rest of the env — secrets and vars (35 more)
| Group | Bindings |
|---|---|
| Auth | BETTER_AUTH_SECRET APP_BASE_URL GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET |
| GBP | GOOGLE_BUSINESS_OAUTH_BASE_URL GOOGLE_BUSINESS_API_BASE_URL GOOGLE_BUSINESS_CLIENT_ID GOOGLE_BUSINESS_CLIENT_SECRET |
| AI Gateway | AI_GATEWAY_ID AI_GATEWAY_ACCOUNT_ID TOGETHER_AI_API_KEY TOGETHER_VIA_GATEWAY TOGETHER_BYOK CF_AIG_TOKENTOGETHER_BYOK is the deploy-computed verdict, not the raw flag |
WAPP_BIRD_WEBHOOK_SIGNING_KEY WAPP_BIRD_API_KEY WAPP_BIRD_WORKSPACE_ID WAPP_BIRD_CHANNEL_ID WAPP_BIRD_API_BASE_URL WAPP_ONBOARDING_TEMPLATE_ID | |
| SMS | SMS_BIRD_API_KEY SMS_BIRD_WORKSPACE_ID SMS_BIRD_CHANNEL_ID |
| Resend | RESEND_API_KEY AUTH_EMAIL_FROM RESEND_WEBHOOK_SECRET RESEND_FROM_ADDRESS |
| Telegram | TELEGRAM_BOT_TOKEN TELEGRAM_WEBHOOK_SECRET_TOKEN |
| Ops | DEV_ROUTE_TOKEN DEPLOY_STAGE SENTRY_DSN SENTRY_ENVIRONMENT GIT_SHA BUILD_TIME |