Lakebed Docs

Lakebed is an agent-native CLI and runtime for building small full-stack TypeScript apps called capsules.

If you are an agent building with Lakebed, treat the capsule directory as the whole app. Write the server contract, write the Preact client, run the Lakebed CLI, inspect the runtime state, and deploy without leaving code.

Start Here

Create and run a capsule:

npx lakebed new my-app --template todo
cd my-app
npx lakebed dev

To use the current directory instead, run npx lakebed init. Use it in an empty directory or a new repository. init keeps hidden files, README, LICENSE, AGENTS.md, and CLAUDE.md. It adds the Lakebed lines to the end of an existing .gitignore, AGENTS.md, and CLAUDE.md. If the directory has any other files, init stops and writes nothing, because Lakebed treats the whole directory as one app. In that case, run npx lakebed new my-app to put the capsule in a subdirectory. When init creates the git repository, the first commit holds only the files that Lakebed wrote or updated. npx lakebed new <dir> follows the same rules when <dir> already exists.

npx lakebed create is an alias for npx lakebed new. New capsules get a git repository and initial commit unless they are created inside an existing git repository or --no-git is passed.

A Lakebed v0 capsule has this shape:

server/index.ts
client/index.tsx
shared/
.env.lakebed.server

Server Contract

Every capsule exports a default capsule() definition from server/index.ts.

import { action, capsule, endpoint, json, mutation, query, string, table, text, userId } from "lakebed/server";

export default capsule({
  schema: {
    messages: table({
      body: string(),
      authorId: userId()
    }).index("by_author", ["authorId"]),
    webhookMessages: table({ body: string() })
  },

  queries: {
    messages: query(async (ctx) => {
      const { userId } = ctx.auth.requireIdentity();
      return ctx.db.messages
        .withIndex("by_author", (q) => q.eq("authorId", userId))
        .order("desc")
        .collect();
    })
  },

  mutations: {
    sendMessage: mutation(async (ctx, body: string) => {
      const { userId } = ctx.auth.requireIdentity();
      return ctx.db.messages.insert({
        body,
        authorId: userId
      });
    })
  },

  actions: {
    summarizeMessages: action(async (ctx, label: string) => {
      const { userId } = ctx.auth.requireIdentity();
      const messages = await ctx.db.messages
        .withIndex("by_author", (q) => q.eq("authorId", userId))
        .collect();
      return { count: messages.length, label };
    })
  },

  endpoints: {
    webhook: endpoint({ method: "POST", path: "/webhooks/incoming" }, async (ctx, req) => {
      if (req.headers.get("x-webhook-secret") !== ctx.env.WEBHOOK_SECRET) {
        return text("unauthorized", { status: 401 });
      }

      const payload = await req.json<{ body: string }>();
      await ctx.db.webhookMessages.insert({ body: payload.body });

      return json({ ok: true });
    })
  }
});

Server handlers receive:

Make queries and mutations server-authoritative. Get a verified caller with ctx.auth.requireIdentity(), filter user-owned rows by its userId, and re-check ownership before updates or deletes. Declare user references with userId() so they transfer when a guest signs in. The reference type does not grant access to rows.

Use endpoints for webhooks and external services. Endpoint handlers receive the same ctx as queries and mutations plus a request object with headers, query, text(), json(), and bytes(). Return json(), text(), empty(), or redirect().

For database details, see the database guide. To upgrade an older capsule that uses where, orderBy, limit, all, or synchronous database calls, use the Database API v1 migration guide.

Client Contract

The client exports App from client/index.tsx.

import { canAccessApp, createClient, retryAuth, SignInWithGoogle, signOut, useAuth } from "lakebed/client";
import type app from "../server/index";

const client = createClient<typeof app>();

export function App() {
  const auth = useAuth();
  if (auth.isLoading) {
    return <main className="min-h-screen bg-black p-6 text-white">Checking session</main>;
  }
  if (!canAccessApp()) {
    return (
      <main className="min-h-screen bg-black p-6 text-white">
        {auth.error ? <p role="alert">{auth.error}</p> : <p>Sign in to use this app.</p>}
        {auth.error ? <button type="button" onClick={() => void retryAuth()}>Retry</button> : null}
        <SignInWithGoogle />
      </main>
    );
  }

  return (
    <main className="min-h-screen bg-black p-6 text-white">
      {auth.isSignedIn ? (
        <button className="inline-flex items-center gap-2" type="button" onClick={() => signOut()}>
          {auth.picture ? <img alt="" className="h-6 w-6 rounded-full" referrerPolicy="no-referrer" src={auth.picture} /> : null}
          Sign out {auth.displayName}
        </button>
      ) : (
        <>
          <p>Using this browser</p>
          <SignInWithGoogle />
        </>
      )}
      <Messages />
    </main>
  );
}

function Messages() {
  const messages = client.useQuery("messages") ?? [];
  const sendMessage = client.useMutation("sendMessage");
  return (
    <>
      <button type="button" onClick={() => void sendMessage("hello")}>
        Send
      </button>
      <pre>{JSON.stringify(messages, null, 2)}</pre>
    </>
  );
}

Client routes are app-relative and work in dev and hosted deploys:

import { Link, Route, Router, Routes, useParams } from "lakebed/client";

function ItemPage() {
  const { id } = useParams<{ id: string }>();
  return <main>Item {id}</main>;
}

export function App() {
  return (
    <Router>
      <Link to="/items/123">Open item</Link>
      <Routes>
        <Route path="/" element={<main>Home</main>} />
        <Route path="/items/:id" element={<ItemPage />} />
        <Route path="*" element={<main>Not found</main>} />
      </Routes>
    </Router>
  );
}

Use server endpoints for HTTP APIs and webhooks. If a GET endpoint and a client route use the same path, the endpoint handles direct HTTP requests first.

Actions

Use actions for server work that reads the database without changing it. Define each action in server/index.ts with action() and call it from a client component:

const summarizeMessages = client.useAction("summarizeMessages");
const result = await summarizeMessages("inbox");

The action receives ctx.auth, a read-only ctx.db, ctx.env, and ctx.log. Arguments and results must be JSON-compatible. Each action allows up to 16 KiB of arguments and 48 KiB of results, and stops after five seconds. Hosted actions count against both request and mutation limits. Outbound fetch and hosted server env require a claimed deploy.

Auth And Env

Guest access uses protected browser sessions. Use auth.isGuest and auth.isSignedIn to distinguish guests from accounts. ctx.auth.requireIdentity() accepts either. ctx.auth.requireSignedIn() requires an account. Set auth: { requireSignIn: true } on the capsule to block app data operations before sign-in.

Store Lakebed user IDs with userId(), not string(). Those references follow a guest to their account automatically. Existing account rows remain. Use auth.onGuestUpgrade only when your app needs to resolve conflicting records. Shared data must use an intentional shared query, not a shared user ID.

For named local test identities:

npx lakebed auth as alice

For named per-tab tests, add ?lakebed_guest=alice or ?lakebed_guest=bob to the app URL. Named test identities do not work in hosted apps and cannot upgrade to accounts. npx lakebed auth reset removes the CLI override. Use the default protected session to test guest upgrades.

Google sign-in uses first-party Lakebed Auth in dev and hosted apps, with one immutable ctx.auth.userId across deploy hostnames. Each token remains bound to its exact origin, and email is profile and invitation data only. See the identity and authentication contract.

Server-only env belongs in .env.lakebed.server:

OPENAI_API_KEY=sk-...
STRIPE_WEBHOOK_SECRET=whsec_...

Read it only from server handlers:

queries: {
  settings: query((ctx) => ({
    hasOpenAiKey: Boolean(ctx.env.OPENAI_API_KEY)
  }))
}

npx lakebed dev loads server env locally. Hosted server env syncs only after a deploy is claimed, and deploy sync replaces the hosted env with the file contents. Env values are not exposed to client code or embedded in anonymous artifacts.

Inspect The Runtime

While npx lakebed dev is running:

npx lakebed db list --port 3000
npx lakebed db dump --port 3000
npx lakebed db export --port 3000 --out backup.json
npx lakebed logs --port 3000

For hosted or locally deployed apps, pass a deploy id or URL:

npx lakebed inspect <deploy-id-or-url>
npx lakebed db dump <deploy-id-or-url>
npx lakebed db export <deploy-id-or-url> --out backup.json
npx lakebed logs <deploy-id-or-url>

Use these before guessing. db dump is bounded for inspection; db export walks every table in bounded pages and atomically writes a complete backup. Local npx lakebed dev inspection is open on localhost. Hosted deploys keep app manifests, state, table names, logs, and usage private by default. The CLI sends developer auth for a committed lakebed.json binding or reads the saved anonymous claim token from .lakebed/deploy.json. Database export always requires deploy-management authorization, even when ordinary inspection is public. Non-private hosted manifests expose only the app name, deploy id, client bundle hash, runtime version, and favicon metadata when the app has one.

Deploy

From inside a capsule:

npx lakebed deploy

Anonymous deploys work first. Claim the deploy when the app needs hosted server env or outbound server-side fetch, then run npx lakebed deploy again. Anonymous deploys do not rewrite guarded JavaScript mutations into weaker IR.

For a portable owned deploy, run npx lakebed auth login before the first deploy. Lakebed writes a root-level lakebed.json containing only deployId; commit it so fresh checkouts update the same app. Create a deploy-scoped CI credential with npx lakebed token create --name github-actions, or create an owner-wide credential with npx lakebed token create --personal --name local-automation. When using a custom --api, set LAKEBED_TOKEN_API to the same canonical origin before supplying LAKEBED_TOKEN. The canonical origin is the scheme and host, plus a port when it is non-standard, without a path or query. For example, with --api https://api.example.com, set LAKEBED_TOKEN_API=https://api.example.com.

Inspection for hosted deploys is private by default. For demos where public data and logs are intentional, deploy with:

npx lakebed deploy --public-inspect

After a hosted deploy is claimed, reserve a Lakebed-owned app subdomain from the capsule directory:

npx lakebed domains add my-app.lakebed.app

Manage owned deployments with:

npx lakebed deploy list
npx lakebed deploy archive <deploy-id>
npx lakebed deploy restore <deploy-id>
npx lakebed deploy terminate <deploy-id> --yes

Archiving stops the app until you restore it. Termination stops the app and prevents future updates. Both operations retain the deploy record, stored data, logs, usage records, server env, domain mappings, and referenced artifact. Terminated deploys cannot be restored.

Unclaimed deploys expire and are deleted about one week after expiry. Claimed deploys do not expire.

Bring your own domain

Boosted plans can attach a domain you own to a claimed, active deploy. Free plans get *.lakebed.app only. Add the hostname, set the DNS records it prints, then poll status until it goes active:

npx lakebed domains add app.example.com
npx lakebed domains status app.example.com
npx lakebed domains list

domains add prints a table of DNS records to create at your registrar: a CNAME that points the hostname at Lakebed, and a TXT record that proves you own it. Create them exactly as printed. For an apex domain (example.com, no subdomain), use your registrar's ALIAS/ANAME or CNAME-flattening record instead of a static A record.

Then run npx lakebed domains status app.example.com and keep re-running it. The status walks pending_dns to verified to active. It can also land in failed, disabled, or deleting. Exit codes let an agent loop on the command: 0 when active, 2 while pending, 1 otherwise.

A failed domain reports why in its status detail. Wrong or missing DNS records and certificate errors that Lakebed can retry both recover on their own: correct the records and keep polling. If the detail says the registration was removed by the provider, polling will not help. Run npx lakebed domains remove app.example.com and add it again.

Verification uses the TXT record, so the CNAME can already resolve while requests still return 404. That is expected. Keep polling. DNS changes can take a while to propagate. TLS is issued automatically once verification passes, with nothing to configure. Add each hostname separately, because wildcards are not supported. Remove a domain (custom or .lakebed.app) with npx lakebed domains remove <hostname>.

Signed-in users keep the same userId across the generated *.lakebed.app URL and your custom domain. If the app is archived or terminated its custom domains are released; restoring the app does not bring them back, so re-add them.

Current Limits

Resource limits are separate from the capability limits above. limits.md lists free-plan resource limits, their errors, and what to change. Paid plans can have higher limits. See the handler capability table before choosing a handler for external API calls or ingest jobs.