From b2e3b01b507e12767d962f327235e908a5781a36 Mon Sep 17 00:00:00 2001 From: clibequilibrium <105135724+clibequilibrium@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:16:05 -0400 Subject: [PATCH 1/2] Ship agents.md and the "let your agent do it" install section Coding agents are becoming the first visitor that matters: Deno ships deno.com/agents.md and a paste-one-line block, and the same door was missing here. static/agents.md is setup instructions written for an agent, with three verified paths - a throwaway demo project the agent mints itself (no account; the id is the credential, the dashboard link is the handoff), a self-hosted deploy via the CLI, and a managed console project. llms.txt points at it. The landing page gets a Deno-style install section between the runtime strip and pricing: the CLI one-liner, then "Or let your agent do it" with a copyable "Read frostbase.dev/agents.md and set up Frostbase in this project". The demo path was verified against production, which surfaced two things the doc now reflects: fresh collections default to readAccess/writeAccess "auth", so the flow mints a project JWT via anonymous sign-in first; and the doc creates collections explicitly (PUT /db/admin/collections/:name) because the lazy auto-create pull is currently answering persistent 503s on production while the explicit push path works - see the ensure-config Sentry tag. --- e2e/landing.ui.spec.ts | 20 +++ src/routes/(marketing)/+page.svelte | 103 ++++++++++++++++ static/agents.md | 185 ++++++++++++++++++++++++++++ static/llms.txt | 1 + 4 files changed, 309 insertions(+) create mode 100644 static/agents.md diff --git a/e2e/landing.ui.spec.ts b/e2e/landing.ui.spec.ts index 12625b1..8c22e94 100644 --- a/e2e/landing.ui.spec.ts +++ b/e2e/landing.ui.spec.ts @@ -32,6 +32,26 @@ test.describe('landing page (frontend)', () => { await expect(page.getByText('AuthAgent · DO SQLite')).toBeVisible(); }); + test('the install section offers the CLI command and the agent prompt', async ({ + page, + request + }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'Or let your agent do it' })).toBeVisible(); + await expect(page.getByTestId('install-command')).toContainText( + 'npx @frostbase-dev/cli init my-backend' + ); + await expect(page.getByTestId('agent-prompt')).toContainText( + 'Read frostbase.dev/agents.md and set up Frostbase in this project' + ); + + // The prompt is only honest if the file it points at is actually served. + const response = await request.get('/agents.md'); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('# Set up Frostbase'); + }); + test('the pricing story and comparison table state the trade-offs', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: /Our price/ })).toBeVisible(); diff --git a/src/routes/(marketing)/+page.svelte b/src/routes/(marketing)/+page.svelte index c024d97..1f5c140 100644 --- a/src/routes/(marketing)/+page.svelte +++ b/src/routes/(marketing)/+page.svelte @@ -19,6 +19,7 @@ X, ArrowRight, Check, + Copy, ChevronDown, Database, GitFork, @@ -58,6 +59,26 @@ 'Analytics Engine' ]; + // The install section's two one-liners: the CLI command and its + // agent-shaped twin, which points at /agents.md (static/agents.md) - the + // setup instructions written for coding agents, throwaway demo path + // included. Copy state is per box so one click never flips the other icon. + const installCommand = 'npx @frostbase-dev/cli init my-backend'; + const agentPrompt = 'Read frostbase.dev/agents.md and set up Frostbase in this project'; + const agentNames = ['Claude Code', 'Codex', 'Gemini CLI', 'OpenCode', 'Cursor']; + let copiedInstall = $state<'cli' | 'agent' | null>(null); + let copyInstallTimer: ReturnType | undefined; + async function copyInstall(kind: 'cli' | 'agent', text: string) { + try { + await navigator.clipboard.writeText(text); + copiedInstall = kind; + clearTimeout(copyInstallTimer); + copyInstallTimer = setTimeout(() => (copiedInstall = null), 1500); + } catch { + // clipboard unavailable - the text stays selectable + } + } + // The comparison matrix. Cells are a rating plus a short receipt - never a // bare checkmark. Competitor cells stay qualitative on purpose: exact // prices live once, with dated sources, on /pricing. @@ -765,6 +786,88 @@ + +
+
+
+ Get started +

A backend in one command.

+

+ Scaffold a Worker with auth installed, add the database, deploy to your own Cloudflare + account. +

+
+ +
+ $ {installCommand} + +
+ +
+

Or let your agent do it

+

Paste this into any coding agent.

+
+ +
+
+ {#each agentNames as name (name)} + {name} + + {/each} + <your agent> +
+
+ > {agentPrompt} + +
+
+

+ + agents.md + includes a zero-account path: the agent builds on a throwaway demo project and hands you the + live dashboard. +

+
+
+
diff --git a/static/agents.md b/static/agents.md new file mode 100644 index 0000000..d4e8c2b --- /dev/null +++ b/static/agents.md @@ -0,0 +1,185 @@ +# Set up Frostbase + +You are a coding agent setting up Frostbase for a user. Frostbase is an +open-source Firebase alternative that runs on Cloudflare: auth (Better Auth), +a realtime document database plus typed SQL tables, object storage, and remote +config. Every project gets its own Durable Objects, so tenants are isolated by +architecture. + +Pick ONE path, then run the Verify step for it. + +- **Path A — throwaway demo.** No account, no deploy, ready in seconds. + Best for prototypes, hackathons, and trying the API. +- **Path B — the user's own Cloudflare account.** A real, self-hosted backend. + Best for production. +- **Path C — a managed console project.** The user already has an account on + frostbase.dev or a self-hosted console. + +## Path A — throwaway demo (no account) + +Mint a project id: `demo-` + 12 random lowercase hex characters, generated +with a CSPRNG — the id is the only credential, so it must be unguessable. + +```bash +node -e "console.log('demo-'+require('crypto').randomBytes(6).toString('hex'))" +``` + +The project exists the moment you touch it — there is no create call. Your +base URLs (replace ``): + +``` +Auth https://frostbase.dev/api/projects//auth +Database https://frostbase.dev/api/projects//db +Storage https://frostbase.dev/agents/storage-agent/ +OpenAPI https://frostbase.dev/api/projects//openapi.json +Console https://frostbase.dev/dashboard/ +``` + +Fetch the OpenAPI document first — it is public and describes every endpoint +for this exact project. Point the user at the Console URL: it is a full +dashboard for their demo project and updates live as your code writes data. + +Demo limits, tell the user these: data is capped, erased after ~30 days, and +reachable by anyone who knows the id. Never put real user data in a demo +project; graduate to Path B or C instead. + +### Verify (Path A) + +Collections default to authenticated access, so mint a project token, create +the collection explicitly, then write — anonymous sign-in counts as signing +in: + +```bash +BASE=https://frostbase.dev/api/projects/ +# 1. Anonymous session; the bearer arrives in the set-auth-token header +SESSION=$(curl -si -X POST $BASE/auth/sign-in/anonymous -H 'content-type: application/json' -d '{}' \ + | grep -i '^set-auth-token:' | cut -d' ' -f2 | tr -d '\r') +# 2. Trade it for a project JWT +JWT=$(curl -s $BASE/auth/token -H "authorization: Bearer $SESSION" | sed 's/.*"token":"\([^"]*\)".*/\1/') +# 3. Create the collection (defaults: readAccess/writeAccess "auth") +curl -s -X PUT $BASE/db/admin/collections/posts -H 'content-type: application/json' -d '{}' +# 4. Write a document +curl -s -X POST $BASE/db/collections/posts/documents \ + -H 'content-type: application/json' -H "authorization: Bearer $JWT" \ + -d '{"data":{"title":"hello from an agent","votes":1}}' +``` + +Step 4 answers `201` with the document, now visible in the Console URL above. +Create every collection explicitly, as in step 3 — writes also auto-create on +first touch, but explicit creation is deterministic and is where access modes +are set. If a write ever answers `503 {"error":"collection is unavailable"}`, +create the collection explicitly and retry the write. + +## Path B — the user's own Cloudflare account + +Requires Node 20+ and a Cloudflare account (the free tier is enough). +`npx wrangler login` opens a browser — have the user run it, or let them set +`CLOUDFLARE_API_TOKEN`. Ask before deploying; it creates resources on their +account. + +New backend: + +```bash +npm install -g @frostbase-dev/cli +frostbase init my-backend # scaffolds a Worker, installs the auth agent +cd my-backend +frostbase add db # optional: documents, SQL tables, live queries +frostbase add storage # optional: object storage on R2 +npx wrangler login # first time only +frostbase deploy # prints the deployed URL +``` + +Existing Worker project: run `frostbase add ` inside it instead of +`init`. It npm-installs the package, merges the agent's config fragment into +`wrangler.jsonc`, wires the entrypoint, and reruns `wrangler types`. It is +idempotent and never overwrites values the user set. `wrangler.toml` projects +are declined — convert to `wrangler.jsonc` first. + +The deployed Worker serves every project id under these bases — pick one id +(for example the app's name) and use it everywhere; the first request +provisions it: + +``` +Auth https://.workers.dev/agents/auth-agent//api/auth +Database https://.workers.dev/agents/db-agent/ +Storage https://.workers.dev/agents/storage-agent/ +``` + +No secrets are required: each project generates its own signing key. Sign-in +works immediately — the deployment trusts its own origin. Only add +`TRUSTED_ORIGINS` (in `wrangler.jsonc` vars) for *extra* origins, like a +separate domain serving the UI. + +### Verify (Path B) + +```bash +curl -s https://.workers.dev/agents/auth-agent//config +``` + +JSON config comes back. Operator routes (`/admin/*`, `/overview`) answer 404 +on this Worker by design — they are reachable from server code through the +Durable Object binding, never over public HTTP. Do not set +`EXPOSE_OPERATOR_API` on a Worker with a public hostname. + +## Path C — a managed console project + +```bash +npm install -g @frostbase-dev/cli +frostbase login https://frostbase.dev # browser approval; or a self-hosted console URL +frostbase init # bare init: pick or create a project, writes frostbase.json +frostbase key create # server-side service key, shown once +``` + +Base URLs are the console's, same shapes as Path A with the real project id. +Service keys (`fsb_…`) are admin-grade and server-only: the API refuses any +request carrying an `Origin` header, so a key leaked into frontend code fails +at the desk, not in production. Put the key in an env var +(`FROSTBASE_SERVICE_KEY`), never in the bundle or the repo. + +## Integrate the app + +Auth — the stock Better Auth client works as-is: + +```ts +import { createAuthClient } from 'better-auth/client'; // or /react, /svelte +const auth = createAuthClient({ baseURL: '' }); +await auth.signUp.email({ name, email, password }); +``` + +Database — documents, typed tables, and live queries: + +```ts +import { createDbClient } from '@frostbase-dev/db/client'; +const db = createDbClient({ + baseUrl: '', + // New collections default to authenticated access, so wire getToken from + // day one. Same-origin or trusted-origin browser apps ride the session + // cookie; elsewhere add the Better Auth session bearer to this fetch. + getToken: async () => (await (await fetch('/token')).json()).token +}); +const posts = db.collection('posts'); +await posts.create({ title: 'hello', votes: 1 }); +posts.subscribe({ orderBy: [{ field: 'votes', direction: 'desc' }], limit: 25 }, + { onSnapshot: (docs) => render(docs) }); +``` + +Storage — `createStorageClient` from `@frostbase-dev/storage/client`, same +`baseUrl`/`getToken` pattern; `upload()` handles multipart above 100 MB. + +Everything is also plain HTTP — no SDK is required. The per-project OpenAPI +document is the complete, machine-readable contract; prefer it over guessing +routes. + +## Rules + +- Ask the user before deploying, logging in, or creating anything on their + Cloudflare account. +- Never hand-edit `worker-configuration.d.ts`; rerun `npx wrangler types`. +- Rerunning `frostbase add` is safe; running it with no argument lists the + installable agents (`auth`, `db`, `storage`). +- Pin an agent version with `FROSTBASE_DB_SPEC=@frostbase-dev/db@x.y.z + frostbase add db` (same pattern for auth and storage). +- Demo projects are throwaway: unguessable-id access, capped, erased in ~30 + days. Say so in your handoff to the user. + +Source: https://github.com/frostbase-dev/frostbase · Site: https://frostbase.dev diff --git a/static/llms.txt b/static/llms.txt index f73b205..6c18cd0 100644 --- a/static/llms.txt +++ b/static/llms.txt @@ -7,6 +7,7 @@ Frostbase is agent-first: every backend primitive ships as a versioned npm packa ## Site - [Home](https://frostbase.dev/): what it is, how it compares to Firebase and Supabase, architecture, and roadmap. +- [agents.md](https://frostbase.dev/agents.md): setup instructions written for coding agents — throwaway demo projects, self-hosted deploys, and managed projects. - [Pricing](https://frostbase.dev/pricing): the same workload priced on Cloudflare, Firebase, and Supabase, with dated sources. - [Live demo](https://frostbase.dev/dashboard): an ephemeral throwaway project, no sign-up. From 57bd3095277a31c176f2d9c1caba390076fb2688 Mon Sep 17 00:00:00 2001 From: clibequilibrium <105135724+clibequilibrium@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:28:47 -0400 Subject: [PATCH 2/2] Db children synchronize on the parent's onStart before RPC The first REST touch of a brand-new project answered a persistent 503 ("collection is unavailable") - on production and reproduced locally. The collection's lazy config pull reached the parent DbAgent through a raw namespace stub, and plain RPC bypasses the SDK's fetch-path initialization, so a parent that had never been woken over HTTP served the pull before onStart had run its migrations: "no such table: collections", caught, 503 - forever, until anything touched the parent over HTTP. The dashboard always provisions through the admin surface (which wakes the parent properly), which is why the console masked it and why every e2e db test missed it too. getAgentByName exists for exactly this - it synchronizes on onStart before returning the stub - so the three parentStub helpers (collection, table, gateway) now use it instead of namespace.get(idFromName()). The new e2e test drives the uncovered path: a genuinely fresh project whose very first contact is the REST write, asserting the auth refusal that proves auto-create ran rather than the 503. Found while verifying agents.md's demo path against production; the version stays 0.7.0 so publishing remains a release decision. --- agents/db/src/collection.ts | 10 ++++++++-- agents/db/src/gateway.ts | 16 ++++++++++------ agents/db/src/table.ts | 8 ++++++-- e2e/demo-project.api.spec.ts | 21 +++++++++++++++++++++ 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/agents/db/src/collection.ts b/agents/db/src/collection.ts index c342087..060c8b4 100644 --- a/agents/db/src/collection.ts +++ b/agents/db/src/collection.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/cloudflare'; +import { getAgentByName, type AgentNamespace } from 'agents'; import { count, eq } from 'drizzle-orm'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; import { z } from 'zod'; @@ -1305,8 +1306,13 @@ export class DbCollection extends LiveShard { } private async parentStub(projectId: string) { - const namespace = this.env.DbAgent as unknown as DurableObjectNamespace; - return namespace.get(namespace.idFromName(projectId)); + const namespace = this.env.DbAgent as unknown as AgentNamespace; + // getAgentByName, never a raw namespace stub: plain RPC bypasses the + // SDK's fetch-path initialization, so a cold parent would serve the + // config pull before onStart's migrations and every first REST touch + // of a fresh project would 503 ("no such table: collections"). + // getAgentByName synchronizes on onStart before returning the stub. + return getAgentByName(namespace, projectId); } protected getVerifier(): ProjectJwtVerifier { diff --git a/agents/db/src/gateway.ts b/agents/db/src/gateway.ts index cbcec78..6ad321b 100644 --- a/agents/db/src/gateway.ts +++ b/agents/db/src/gateway.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { getAgentByName, type AgentNamespace } from 'agents'; import { count, eq, and } from 'drizzle-orm'; import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; @@ -388,9 +389,12 @@ export class DbGateway extends DurableObject { // ------------------------------------------------------------------------- // Parent plumbing (routing, origins, socket-pressure reports) - private parentStub() { - const namespace = this.env.DbAgent as unknown as DurableObjectNamespace; - return namespace.get(namespace.idFromName(this.projectId)); + private async parentStub() { + const namespace = this.env.DbAgent as unknown as AgentNamespace; + // getAgentByName, never a raw namespace stub - see the twin comment in + // collection.ts: a raw RPC can reach a cold parent before onStart's + // migrations and turn every first touch into a 503. + return getAgentByName(namespace, this.projectId); } private routingTtl(): number { @@ -403,7 +407,7 @@ export class DbGateway extends DurableObject { if (cached && cached.expires > now) return cached; let entry: ShardRoutingEntry = { kind: null, auto: false, expires: now + this.routingTtl() }; try { - const routing = await this.parentStub().getShardRouting(name); + const routing = await (await this.parentStub()).getShardRouting(name); if (routing) { entry = { kind: routing.kind, @@ -433,7 +437,7 @@ export class DbGateway extends DurableObject { if (this.origins && this.origins.expires > now) return this.origins.allowed; let allowed: string[] = []; try { - allowed = await this.parentStub().getAllowedOrigins(); + allowed = await (await this.parentStub()).getAllowedOrigins(); } catch { // Fail open to the environment allowlist only - a broken parent // link must not take realtime down for same-origin clients. @@ -457,7 +461,7 @@ export class DbGateway extends DurableObject { return; } try { - await this.parentStub().reportGatewaySockets(this.gatewayId, this.region, sockets); + await (await this.parentStub()).reportGatewaySockets(this.gatewayId, this.region, sockets); this.lastReportedSockets = sockets; } catch { this.lastReportedSockets = null; diff --git a/agents/db/src/table.ts b/agents/db/src/table.ts index 4abf6e3..aca32f8 100644 --- a/agents/db/src/table.ts +++ b/agents/db/src/table.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/cloudflare'; +import { getAgentByName, type AgentNamespace } from 'agents'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; import { z } from 'zod'; import migrations from './migrations'; @@ -1600,8 +1601,11 @@ export class DbTable extends LiveShard { } private async parentStub(projectId: string) { - const namespace = this.env.DbAgent as unknown as DurableObjectNamespace; - return namespace.get(namespace.idFromName(projectId)); + const namespace = this.env.DbAgent as unknown as AgentNamespace; + // getAgentByName, never a raw namespace stub - see the twin comment in + // collection.ts: a raw RPC can reach a cold parent before onStart's + // migrations and turn every first touch into a 503. + return getAgentByName(namespace, projectId); } protected getVerifier(): ProjectJwtVerifier { diff --git a/e2e/demo-project.api.spec.ts b/e2e/demo-project.api.spec.ts index 4922304..d5c81d4 100644 --- a/e2e/demo-project.api.spec.ts +++ b/e2e/demo-project.api.spec.ts @@ -85,6 +85,27 @@ test.describe('demo project', () => { expect(settings.ok(), await settings.text()).toBeTruthy(); }); + test('the first REST touch of a brand-new project auto-creates, never 503s', async ({ + request + }) => { + // Regression: the collection's lazy config pull used a raw namespace + // stub, which bypasses the SDK's fetch-path initialization - so a parent + // DbAgent that had never been woken over HTTP had no schema yet, the + // pull died on "no such table: collections", and EVERY first REST touch + // of a fresh project answered a persistent 503. Every other test here + // provisions through the admin surface first (which wakes the parent + // over HTTP), which is exactly why this path went uncovered. A unique + // id per run keeps this a genuine first touch. + const fresh = `demo-${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`; + const write = await request.post(dbDocumentsPath(fresh, 'posts'), { + data: { data: { title: 'first touch' } } + }); + // Default collection access is 'auth', so the correct answer to an + // anonymous write is an auth refusal - which proves the config pull and + // the auto-create both ran. The bug's signature is a 503. + expect(write.status(), await write.text()).toBe(401); + }); + test('serves the demo database flow without an operator', async ({ request }) => { // The Database snippets must run for the anonymous visitor too: the demo // bypass covers the admin surface (creating the collection), and the