Skip to content

Architecture

A short tour of what happens between POST /runs and a finished report.

High-level

[Clerk: customer auth]
[Customer browser] → [Next.js: app.syntheticusers.io]
↓ API key
[API Worker: api.syntheticusers.io]
├→ [D1: customers, runs, sessions, personas, credentials, webhooks, usage]
├→ [KV: API key lookup (hot path)]
├→ [Stripe: usage events]
└→ [SessionDO per session] ⇄ [Container DO: agent-browser + Chromium]
[R2: transcripts, screenshots, reports]
[DO SQLite: live per-session reactions]
[CF Email Routing: inbox.syntheticusers.io → Email Worker]
[InboxDO per session]

Per-session lifecycle

  1. POST /runs → API Worker validates, writes a Run row in D1, returns { run_id, status: "queued" } in under 500ms.
  2. RunDO spawns one SessionDO per persona in parallel (1-10).
  3. Each SessionDO instantiates an AgentBrowserContainer DO subclass keyed by <session_id>#<containerGen>. Container boots a self-hosted Chromium + the agent-browser Rust daemon.
  4. The Pi orchestrator loop runs inside the SessionDO. Each turn:
    • Snapshot the page; pass ref handles to the LLM.
    • LLM picks an action (click, type, scroll, navigate, react(), finish()).
    • SessionDO writes the message + any reactions into DO SQLite.
  5. If the Container crashes (CDP socket drop, OOM), SessionDO increments containerGen and reconnects. Reactions are preserved; messages from the crashed gen are dropped. The agent gets priorReactions in the prompt and re-explores.
  6. When the turn budget runs out, the agent is required to call finish(), and the report synthesis step runs.
  7. SessionDO writes transcript.json.gz, report.json, report.md, report.html, and screenshots/*.png to R2 under customers/<cust>/runs/<run>/sessions/<sess>/.
  8. The last session to finish triggers AggregateDO; aggregate-report files land at customers/<cust>/runs/<run>/.
  9. UsageEvent rows are written and posted to Stripe Meter Events (idempotent on usage_event.id).
  10. Webhooks fire (run.completed).

Why Cloudflare Containers

Cloudflare Containers give each session its own isolated browser that starts in milliseconds, with no per-tool-call vendor lock-in and a commodity Chromium underneath. The cost of a run is dominated by LLM tokens, not the browser infrastructure.

Why this shape (vs. a stateless worker)

A run is a multi-minute, stateful conversation between an LLM and a browser. Workers plus Durable Objects give us both halves — the conversation (DO SQLite holds the messages and reactions) and the browser (the Container) — in the same edge region, billed by CPU time per request rather than per tool call.

Security model

  • SSRF: Only HTTPS targets. Private IPv4/IPv6 / .internal / .local rejected at the API layer. Container egress also constrained.
  • Auth: API key on every request (see Authentication for rate limits). The dashboard signs in through Clerk.
  • Credentials: Envelope encryption (a master key wraps a per-customer key). The plaintext password is only decrypted inside the session, at the moment the agent needs to log in.
  • PII in transcripts: emails and phone numbers are masked before anything is written to storage.
  • Retention: artifacts are kept for 30 days by default.