Decision · as built

A coding-agent demo on Bun, Hono, Postgres, and sync — anonymous first, upgrade in place

One Bun/TypeScript service fronts RDS Postgres, streams chat completions through a hand-built OpenAI-compatible gateway, and lets Electric sync agent state to the browser. Better Auth manages anonymous-first identities that link to Google or GitHub in place — same row, same history, raised quota. The browser runs a Pi-style agent loop with a gated python_run tool on Pyodide, backed by a persistent virtual filesystem.

LayerChoice (as built)RoleStatus
RuntimeBun + TypeScriptOne server process, runs TS directlyBuilt
HTTPHono + node:http dispatcherRoutes, middleware, SSEBuilt
DatabaseRDS Postgres 16 (logical repl)Source of truthBuilt
ORM / driverDrizzle on bun:sqlSchema-as-code, typed queriesBuilt
AuthBetter Auth + anonymous + accountLinkingGradual registrationBuilt
Admin UIAdminJS over @adminjs/sqlInternal ops, Better-Auth-gatedBuilt
Read syncElectric (internal-only service)Live UI on agent stateBuilt
Browser agentcustom loop + Pyodide workerDemo runtime, gated tool execBuilt
Datasets / FSserver proxy + IDBFSSample data, persists across reloadBuilt
DeployTerraform: ECS Fargate, ALB, RDS, Cloud MapSingle region, single-AZ v1Authored, unapplied
This memo is the as-built record, including deviations from the original plan: Node → Bun, Electric demoted to internal-only, the ALB idle-timeout correction, and several "verified by review, not by a running system" caveats called out in §11.
Runtime

Bun, chosen mid-build — and the one upstream bug it surfaced

The project started on Node 20 + tsx, then switched to Bun: it runs TypeScript directly (no build step), auto-loads .env, and ships a native Postgres client (bun:sql) that Drizzle adapts via drizzle-orm/bun-sql. Dropped tsx, dotenv, and postgres (postgres-js).

What stayed Node-shaped on purpose

@hono/node-server is kept for the top-level node:http dispatcher that splits /admin/* (Express + AdminJS) from everything else (Hono). Bun's node:http compatibility runs it unmodified. Rewriting it as a fetch-to-Express bridge was not worth the bridge code.

The tiptap pin

AdminJS's design-system pins @tiptap/* @ 2.1.13, but @tiptap/starter-kit@2.1.13 declares ^2.1.13 for sub-extensions. Bun resolves that caret to the latest 2.x (2.27.x), whose @tiptap/extension-horizontal-rule imports canInsertNode from @tiptap/core — an export 2.1.13 does not have. npm's older lockfile happened to dedupe down; Bun surfaced the upstream pinning bug. Fix: pin all 31 @tiptap/* packages to 2.1.13 via overrides.

This would have bitten Node too the moment AdminJS actually loaded the design-system; Bun just made it visible at module-load time instead of deep in a render path.
Architecture

Three runtime processes, one source of truth

The browser does two things at once. The agent loop calls /api/inference over plain SSE for streaming completions — hot and synchronous, no sync layer in front. Independently, the UI long-polls Electric shapes (through a server proxy) for live row updates on the quota meter. Both feeds reach the same tab.

Browser A hand-written agent loop drives streamed tool-calling. python_run runs in a Pyodide Web Worker behind a Run / Edit / Skip cell. The Electric client long-polls /api/sync/quota_buckets.
Bun app (Fargate) Owns every write: auth handlers, /api/inference streaming, quota debits, message + tool-call inserts, the dataset proxy, the Electric shape proxy, and the AdminJS surface. Stateless.
Electric sync (Fargate, internal) Consumes Postgres logical replication, serves shape subscriptions over HTTP. Reachable only from the app via Cloud Map — never public.
RDS Postgres Single source of truth. wal_level=logical via the rds.logical_replication parameter; replication slot for Electric, bun:sql pool for the app.
Streaming bytes never flow through Electric. The originating tab sees tokens via SSE in real time; row inserts at stream end propagate to other tabs via shapes a beat later.
Identity

The account row outlives the upgrade

Better Auth's anonymous plugin gives the visitor a real authenticated session on first load. A databaseHooks.user.create.after hook seeds a virtual key + quota bucket at the anon tier. If they later sign in with Google or GitHub, an onLinkAccount callback re-targets every foreign key from the anonymous user to the linked one in a single Postgres transaction and raises the quota tier.

plugins: [
  anonymous({
    onLinkAccount: async ({ anonymousUser, newUser }) => {
      await db.transaction(async (tx) => {
        const anonId = anonymousUser.user.id
        const newId = newUser.user.id
        await tx.update(virtualKeys).set({ accountId: newId }).where(eq(virtualKeys.accountId, anonId))
        await tx.update(usageLog).set({ accountId: newId }).where(eq(usageLog.accountId, anonId))
        await tx.update(agentSessions).set({ accountId: newId }).where(eq(agentSessions.accountId, anonId))
        await tx.update(agentMessages).set({ accountId: newId }).where(eq(agentMessages.accountId, anonId))
        await tx.update(agentToolCalls).set({ accountId: newId }).where(eq(agentToolCalls.accountId, anonId))
        await tx.update(quotaBuckets).set({ accountId: newId }).where(eq(quotaBuckets.accountId, anonId))
        await raiseQuotaLimit(tx, newId, 'free')
      })
    },
  }),
]
Better Auth's link creates a new user.id; FKs migrate but anything else pinning the old id (logs, JWTs, client cache) gets a one-time rewrite. Keeping the anon id stable bypasses BA's flow — punted unless a concrete need surfaces.
Data model

Ten tables; four sync live to the browser

Better Auth owns four tables, all server-only — anything with OAuth tokens, session secrets, or PII never reaches the wire. Six app tables split between sensitive (virtual keys with key_hash) and Electric-friendly (quota, sessions, messages, tool calls, usage log).

TableOwnerElectricReason
user · account · session · verificationBetter AuthNoPII, OAuth tokens, session secrets, auth ephemera.
virtual_keysAppNoContains key_hash; resolved server-side only.
quota_bucketsAppYesPowers the live token meter.
usage_logAppOptionalRecent-activity panel; not subscribed in v1.
agent_sessionsAppYesSidebar list.
agent_messagesAppYesTranscript; insert-on-completion.
agent_tool_callsAppYesState machine for Python cells.

account_id is denormalized onto every Electric'd table. Electric shapes are single-table; keeping the column on the leaf row makes shape WHERE clauses one-liners (account_id = X) and stays in Electric's optimized path. One extra column per row is a fine cost.

Production shapes go through /api/sync/:table on the app, which pins account_id = currentUser.id server-side and forwards the upstream Electric stream. The browser never sees the raw Electric URL.
Gateway

Estimate, debit, stream, reconcile

The inference route is where correctness lives. Pre-debit an estimate before opening the upstream stream, fail-fast if over quota, stream chunks straight to the browser, then in finally compute actual usage and reconcile inside one Postgres transaction that also inserts the assistant message and tool calls. One commit means Electric subscribers see the final state arrive together.

Atomic debit — the load-bearing primitive

const r = await db.update(quotaBuckets)
  .set({ usedTokens: sql`${quotaBuckets.usedTokens} + ${n}` })
  .where(and(
    eq(quotaBuckets.id, bucketId),
    sql`${quotaBuckets.usedTokens} + ${n} <= ${quotaBuckets.limitTokens}`,
  ))
  .returning({ id: quotaBuckets.id })
return r.length > 0  // zero rows == quota exceeded, no SELECT-then-UPDATE race

One statement, atomic per Postgres MVCC. Reconcile applies a signed delta after the stream completes; GREATEST(0, …) guards refund drift. The route validates session_id ownership and returns it as an X-Session-Id header so the client can pin one session across recursive tool turns.

Adapters

OpenAILargely passthrough; gpt-tokenizer for estimation. Chunks forwarded as-is.
AnthropicPull system out of messages; translate to the Messages API; convert content_block_delta / message_delta events to OpenAI-shaped chunks plus a final chunk.usage. chars/4 estimate, reconcile after.
The wire to the browser is always OpenAI-shaped regardless of upstream provider — keeping that boundary clean avoids weeks of bug-chasing.
Browser agent

The loop runs; Pyodide runs only what the user approves

A hand-written loop streams completions, accumulates tool-call deltas by index, and on finish_reason: tool_calls renders a cell and pauses. The agent's proposed code lands in an editable textarea; the executed code — which may differ — is what runs and what is reported back. The tool result feeds the next inference turn, closing the loop.

python_run(code)Pyodide in a Web Worker, lazy-loaded (~10 MB) from jsDelivr on first use. loadPackagesFromImports resolves import pandas / pyarrow before execution. Kernel persists across cells.
Run / Edit / SkipRun ships the textarea contents; Skip resolves status='skipped'. Either way the result is POSTed to /api/tool-calls/:id/complete, which Electric syncs.
AuthSame-origin /api/inference, credentials: 'include', no API key in the browser. The session cookie is the auth; virtual keys stay server-side.
Cost modelTokens debit when the agent calls the tool, not when the user clicks Run. Pyodide compute is client-side and free — cost maps to LLM tokens, not wall-clock.
Agents need to know what actually ran, not what they proposed — so edited code, not the model's original, is what flows back into the transcript.
Datasets & persistence

A server-proxied catalog, persisted to IndexedDB

Kaggle is not usable directly — its files 302 to a login wall and need an authenticated API token. Instead a curated catalog (seaborn-data, vega-datasets, a sample Parquet) is fetched server-side through /api/datasets/:id/download, which also kills the CORS problem and leaves a clean seam for a future Kaggle source behind KAGGLE_KEY. No quota debit — the cost model is tokens, not bandwidth.

The virtual FS survives reload

/data is an Emscripten IDBFS mount. syncfs(true) on worker init restores files persisted from a prior session; syncfs(false) persists after every write (dataset mount, agent-written files, clear). Mounted-dataset metadata is mirrored to localStorage so chips and the agent's file awareness restore instantly on reload without forcing the 10 MB Pyodide download.

// First time the worker actually comes up, reconcile the optimistic
// localStorage list against the real FS — prune what's gone, surface
// what the agent wrote — so the agent is never told about a stale file.
const res = await callWorker({ type: 'listFiles' })
const present = new Set((res?.files ?? []).map((n) => `${DATA_DIR}/${n}`))
for (const [id, m] of [...state.mounted.entries()])
  if (!present.has(m.path)) { state.mounted.delete(id); chipFor(id)?.remove() }
IDBFS is keyed per-origin, not per-account: two anonymous users on one browser share /data. For a single-browser demo this is fine — and it reinforces the "workspace survives the anon→linked upgrade" theme — but it is not isolation for untrusted shared machines.
Admin surface

AdminJS at /admin, gated by the app's own session

No Drizzle adapter for AdminJS exists upstream, so @adminjs/sql introspects the Postgres schema directly over the same DATABASE_URL — avoiding a duplicate ORM. Resources are grouped into Identity, Quota, Activity, and Agent. Secrets (key_hash, OAuth tokens, session tokens, account.password) are isVisible:false; usage_log is read-only; the auth tables deny "new".

A small Express middleware reads the Better Auth session from the forwarded headers via auth.api.getSession(), rejects anonymous users, and checks the email against an ADMIN_EMAILS allowlist (empty in dev = any signed-in non-anonymous user; a hard deny in production). The top-level node:http server dispatches /admin/* to Express+AdminJS and everything else to Hono — single port, single session cookie.

AdminJS bundles its React UI on first request (~30 s, memory-heavy — the Fargate task is sized at 1 vCPU / 2 GB for this). Production should precompile with @adminjs/bundler.
Deployment

ECS Fargate via Terraform — and where it diverges from the memo

15 Terraform files under infra/: VPC across two AZs (ALB minimum), RDS Postgres 16 single-AZ, an ALB, two Fargate services, Cloud Map, ECR, Secrets Manager, IAM, CloudWatch.

DecisionMemo saidAs built
Electric exposureALB routes /v1/shape* to ElectricElectric is internal-only (Cloud Map, SG-locked to the app). The app proxies it at /api/sync, so per the memo's own design the browser never needs the raw URL. ALB has one target group.
ALB idle timeout"600 on the Hono target group"ALB idle timeout is load-balancer-wide, not per-target-group. Set idle_timeout = 600 on the LB.
Network tiersingle AZ v1Public subnets, no NAT (cheapest deployable); RDS publicly_accessible=false + SG-locked. Documented v1 tradeoff.
TLS to Postgresrds.force_ssl=0 for intra-VPC v1; revisit for prod.

The app service runs one task with a 100/200 rolling deploy and circuit-breaker rollback (near-zero downtime at one task). Secrets are injected from one Secrets Manager secret at task start. A separate migrate task definition runs bun run db:migrate via aws ecs run-task. First apply is scoped to ECR only — task definitions can't register with an empty image.

Electric needs replication privileges RDS does not grant the master role automatically: GRANT rds_replication TO gradual; once, if the slot won't create.
Validation

What is verified, and what is review-only

The build environment had no Postgres, no Docker daemon, and no Terraform binary. Honesty about that boundary matters more than a green checkmark.

AreaConfidenceBasis
Type safetyVerifiedtsc --noEmit clean across the server; browser JS bun-build clean.
Bun boot pathVerifiedBoots through AdminJS init, Better Auth init, bun:sql setup; fails only at the expected Postgres ECONNREFUSED.
Dataset catalogVerifiedAll six upstream URLs return 200 with expected sizes.
Inference loop, auth linking, Electric syncReview-onlyNo live Postgres to exercise auth-gated routes end-to-end.
IDBFS round-trip, PyodideReview-onlyNeeds a real browser; standard Emscripten pattern, untested here.
TerraformUnappliedNo terraform validate/plan available; HCL reviewed by hand. First apply is the real test.
Treat everything marked review-only as a design that should compile and run, not as a system that has been observed running.
Sources & open decisions

What's solid, what's open

High confidence Atomic conditional UPDATE on quota is correct under Postgres MVCC. Better Auth anonymous + onLinkAccount matches the gradual-registration spec. The OpenAI-shaped boundary keeps the agent provider-agnostic. Bun runs the whole stack including AdminJS once tiptap is pinned.
Main caveats BA user.id changes on link. Anthropic token estimation is approximate (chars/4, reconciled). Electric needs rds_replication. AdminJS first-request bundle is slow + memory-heavy. Nothing has been run against a live database.

Open decisions

DecisionWhy it matters
Anon quota numbers (tokens/day)Sets demo unit economics; defaults are placeholders.
Launch provider listDrives adapter count and key management.
Anthropic /v1/messages/count_tokensExact pre-debit vs. the current conservative estimate.
Private subnets + NAT, Multi-AZ, force_sslThe v1→prod hardening list.
Per-account FS isolationOnly matters if untrusted users share a browser.