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.
| Layer | Choice (as built) | Role | Status |
|---|---|---|---|
| Runtime | Bun + TypeScript | One server process, runs TS directly | Built |
| HTTP | Hono + node:http dispatcher | Routes, middleware, SSE | Built |
| Database | RDS Postgres 16 (logical repl) | Source of truth | Built |
| ORM / driver | Drizzle on bun:sql | Schema-as-code, typed queries | Built |
| Auth | Better Auth + anonymous + accountLinking | Gradual registration | Built |
| Admin UI | AdminJS over @adminjs/sql | Internal ops, Better-Auth-gated | Built |
| Read sync | Electric (internal-only service) | Live UI on agent state | Built |
| Browser agent | custom loop + Pyodide worker | Demo runtime, gated tool exec | Built |
| Datasets / FS | server proxy + IDBFS | Sample data, persists across reload | Built |
| Deploy | Terraform: ECS Fargate, ALB, RDS, Cloud Map | Single region, single-AZ v1 | Authored, unapplied |
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.
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.
python_run runs in a Pyodide Web Worker behind a Run / Edit / Skip cell. The Electric client long-polls /api/sync/quota_buckets.
/api/inference streaming, quota debits, message + tool-call inserts, the dataset proxy, the Electric shape proxy, and the AdminJS surface. Stateless.
wal_level=logical via the rds.logical_replication parameter; replication slot for Electric, bun:sql pool for the app.
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')
})
},
}),
]
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.
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).
| Table | Owner | Electric | Reason |
|---|---|---|---|
user · account · session · verification | Better Auth | No | PII, OAuth tokens, session secrets, auth ephemera. |
virtual_keys | App | No | Contains key_hash; resolved server-side only. |
quota_buckets | App | Yes | Powers the live token meter. |
usage_log | App | Optional | Recent-activity panel; not subscribed in v1. |
agent_sessions | App | Yes | Sidebar list. |
agent_messages | App | Yes | Transcript; insert-on-completion. |
agent_tool_calls | App | Yes | State 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.
/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.
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
gpt-tokenizer for estimation. Chunks forwarded as-is.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 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.status='skipped'. Either way the result is POSTed to /api/tool-calls/:id/complete, which Electric syncs./api/inference, credentials: 'include', no API key in the browser. The session cookie is the auth; virtual keys stay server-side.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() }
/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.
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/bundler.
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.
| Decision | Memo said | As built |
|---|---|---|
| Electric exposure | ALB routes /v1/shape* to Electric | Electric 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 tier | single AZ v1 | Public subnets, no NAT (cheapest deployable); RDS publicly_accessible=false + SG-locked. Documented v1 tradeoff. |
| TLS to Postgres | — | rds.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.
GRANT rds_replication TO gradual; once, if the slot won't create.
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.
| Area | Confidence | Basis |
|---|---|---|
| Type safety | Verified | tsc --noEmit clean across the server; browser JS bun-build clean. |
| Bun boot path | Verified | Boots through AdminJS init, Better Auth init, bun:sql setup; fails only at the expected Postgres ECONNREFUSED. |
| Dataset catalog | Verified | All six upstream URLs return 200 with expected sizes. |
| Inference loop, auth linking, Electric sync | Review-only | No live Postgres to exercise auth-gated routes end-to-end. |
| IDBFS round-trip, Pyodide | Review-only | Needs a real browser; standard Emscripten pattern, untested here. |
| Terraform | Unapplied | No terraform validate/plan available; HCL reviewed by hand. First apply is the real test. |
What's solid, what's open
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.
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
| Decision | Why it matters |
|---|---|
| Anon quota numbers (tokens/day) | Sets demo unit economics; defaults are placeholders. |
| Launch provider list | Drives adapter count and key management. |
Anthropic /v1/messages/count_tokens | Exact pre-debit vs. the current conservative estimate. |
| Private subnets + NAT, Multi-AZ, force_ssl | The v1→prod hardening list. |
| Per-account FS isolation | Only matters if untrusted users share a browser. |
statik/gradual · branch claude/build-pi-agent-demo-3OkcW. References: bun.sh · hono.dev · better-auth.com · orm.drizzle.team · electric-sql.com · adminjs.co · pyodide.org.