Architecture · apps/rosie

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.

Source: apps/rosie Branch: main @ 1a558c9 Verified against code, 2026-07-16
1
Worker
Plain fetch handler wrapped by Sentry. No Hono. Effect runs at the request boundary.
3
DO namespaces
RestaurantGroupDO, RosieAgent, ThinkMessengerStateAgent.
0
Cron / queues
All scheduling is per-group DO alarms. Sharded by construction.
3 / 4
Entrypoints the DO calls
Four self service-bindings exist; RestaurantGroupDO's env can see three of them.
01 The whole thing drag to pan · ⌘+scroll to zoom

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
ingress worker durable object store model external

RestaurantGroupDO never holds the OAuth token. It asks the Worker.

A service binding is a binding, not a secret — so the DO can trigger a credentialed fetch without ever seeing the credential.
The invariant is narrower than it looks
Only 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."
02 WhatsApp ingress POST /api/messengers/whatsapp/webhook

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
    
Verified twice, on purpose

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.

Host DO vs facet DO

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.

Three channels, one seam

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.

Outbound

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.

03 The Worker worker/index.ts

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

#StageWhat it does
1gateExternal /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.
2thinkThink's worker entry gets first refusal on /api/agents.
3health/api/health inline, no event.
4shimLocal-dev self-dispatch (see below). No-op in deployed stages.
5auth/api/auth/* → better-auth handler subtree.
6programsExact-path Effect programs: /api/me, /api/dashboard, /api/reviews/summary, /api/db-check.
7routerHttpRouter groups — connections, group, mappings, locations, reviews. The matched handler stamps its own http.route.
8build/api/build-info → commit SHA + build time, no-store.
9devQA endpoints behind a DEV_ROUTE_TOKEN bearer and a non-production stage check.
10webhooks/api/messengers/{whatsapp,telegram,email}/webhook.
11404Any remaining /api/* → 404, so a typo'd API path never falls into the SPA.
12spaEverything 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.

REVIEW_FETCHER DO-called

Fetches one page of Google Business Profile reviews. Called once per backfill/sync tick. The Worker resolves the OAuth token from KV.

REPLY_DRAFTER DO-called

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.

NEW_REVIEW_NOTIFIER DO-called

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.

REVIEW_REPLY_PUBLISHER route-called

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.

Local dev shim
Under 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.
04 Durable Objects worker/do · worker/agent

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.

RestaurantGroupDO

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 in blockConcurrencyWhile at 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
RosieAgent (extends Think)

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 RestaurantGroupDO RPCs plus a Postgres read, cached 30s
  • Effect runtime built once in onStart and reused — correct here precisely because a DO is long-lived
ThinkMessengerStateAgent

Vendored from @cloudflare/think/messengers. Addressed internally by the SDK; not implemented in this repo. It's on the bindings list and otherwise invisible.

Why the agent fails closed
#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.
05 The alarm loop one page of one job per tick

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
Why the sync walk terminates

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.

CLAIM → DRAFT → NOTIFY → DRAFT

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.

Unverified quota assumption
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.
06 Where data lives three stores, one rule

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.

StoreAccessHoldsThe 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".
Invariants the schema can't enforce

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.

Sessions are in Postgres, not KV

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.

Why the edge cache is off
Hyperdrive's query results cache broke read-after-write on the auth path: sign-up's duplicate check poisoned the cache with an empty result, and a sign-in immediately afterwards ate it — returning 401 instead of the 403 email-verify gate for ~70–75s (60s max_age + 15s stale_while_revalidate). Pooling and connection collapse are unaffected; only results caching is disabled.
07 Models & providers every external hop
ServiceUsed forWho calls it
TogetherKimi K2.6The chat agent and the reply drafter, routed through the per-stage AI Gateway at /custom-together/v1RosieAgent and ReplyDrafter
Workers AI@cf/moonshotai/kimi-k2.6Fallback when the Together key is placeholder or emptyNative AI binding
Cerebrasgemma-4-31bThe pre-debounce "on it" WhatsApp bubble. Placeholder key ⇒ ack stays offHost DO, direct Bird send
Google Business ProfileOAuth connect, enumerate accounts/locations, fetch reviews, publish repliesWorker only — via the four entrypoints
Google OAuthbetter-auth social sign-in — a separate app from the GBP connectionWorker /api/auth/*
BirdChannels APIWhatsApp channel and phone-OTP SMS, under two separate credential setsWorker + WhatsApp adapter
ResendTwo roles: transactional auth mail, and the inbound email agent channel (Svix-signed)Worker + email adapter
TelegramBot ingress channel, conditionally registeredGlobal ingress agent
SentryErrors 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 emitWraps the handler
Workers LogsWide 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 workerNative
Two things that will bite
GBP points at a twin — including in production. Real Google splits authorize / token / accounts / locations across four hosts; two base URLs can't express that, so prod deliberately stays on the 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.
08 Deploy topology alchemy.run.ts · no wrangler.jsonc

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>.

Per-PR previews are real infrastructure

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.

The name is load-bearing

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.

BYOK fails at plan time, not at runtime
Gateway BYOK engages only when the flag is on and there's a real key and a real 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.
09 Bindings reference worker/env.ts

Everything the Worker can reach. Two runtime bindings exist that Env never names — worth knowing before you go looking for them.

BindingKindPurpose
ASSETSassetsThe React SPA. Terminal fallback for anything not /api/*.
HYPERDRIVEhyperdrivePlanetScale Postgres. Results caching disabled.
RATE_LIMIT_KVkvRate-limit counters + provider OAuth token bundles.
RestaurantGroupDOdoPer-group review state and the alarm scheduler.
RosieAgentdoThe Think agent — host + facets.
ThinkMessengerStateAgentdoThink SDK-owned messenger state. Vendored.
REVIEW_FETCHERserviceSelf-binding → ReviewFetcher entrypoint.
REPLY_DRAFTERserviceSelf-binding → ReplyDrafter entrypoint.
REVIEW_REPLY_PUBLISHERserviceSelf-binding → ReviewReplyPublisher entrypoint.
NEW_REVIEW_NOTIFIERserviceSelf-binding → NewReviewNotifier. Bound in alchemy, declared only on the DO's env — never on the Worker Env.
AIaiWorkers AI fallback model.
CEREBRAS_API_KEYsecretFront-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)
GroupBindings
AuthBETTER_AUTH_SECRET APP_BASE_URL GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET
GBPGOOGLE_BUSINESS_OAUTH_BASE_URL GOOGLE_BUSINESS_API_BASE_URL GOOGLE_BUSINESS_CLIENT_ID GOOGLE_BUSINESS_CLIENT_SECRET
AI GatewayAI_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
WhatsAppWAPP_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
SMSSMS_BIRD_API_KEY SMS_BIRD_WORKSPACE_ID SMS_BIRD_CHANNEL_ID
ResendRESEND_API_KEY AUTH_EMAIL_FROM RESEND_WEBHOOK_SECRET RESEND_FROM_ADDRESS
TelegramTELEGRAM_BOT_TOKEN TELEGRAM_WEBHOOK_SECRET_TOKEN
OpsDEV_ROUTE_TOKEN DEPLOY_STAGE SENTRY_DSN SENTRY_ENVIRONMENT GIT_SHA BUILD_TIME