# 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:

```sh
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:

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

- `server/index.ts`: schema, queries, mutations, actions, and external endpoints. Import only from `lakebed/server` and pure relative files.
- `client/index.tsx`: the Preact UI entrypoint. Import hooks and auth UI from `lakebed/client`.
- `shared/`: pure TypeScript shared by server and client. Do not import Lakebed runtimes, DOM APIs, Node built-ins, secrets, or env values here.
- `.env.lakebed.server`: optional server-only environment variables exposed to server handlers through `ctx.env`.

## Server Contract

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

```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:

- `ctx.auth`: the current guest, signed-in account, or no-session state. Use `requireIdentity()` or `requireSignedIn()` before private data access.
- `ctx.db`: async table access with declared indexes, `withIndex`, `order`, `collect`, `take`, `first`, `paginate`, `get`, `insert`, `update`, and `delete`.
- `ctx.env`: server-only values from `.env.lakebed.server`.
- `ctx.log`: structured logs captured by the runtime.

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](https://docs.lakebed.dev/database/index.md). To upgrade an older capsule that uses `where`,
`orderBy`, `limit`, `all`, or synchronous database calls, use the
[Database API v1 migration guide](https://docs.lakebed.dev/database-migration/index.md).

## Client Contract

The client exports `App` from `client/index.tsx`.

```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>
    </>
  );
}
```

- `createClient<typeof app>()` infers declared handler names, arguments, and results from the server definition. Import the server definition with `import type` so client code does not load server code.
- `client.useQuery("name")` subscribes to a server query and returns `undefined` until its first result arrives.
- `client.useMutation("name")` calls a server mutation and returns a promise.
- `client.useAction("name")` calls a read-only server action and returns a promise.
- `client.usePaginatedQuery("name", args, options)` subscribes to a query that returns `.paginate(...)` results.
- `useAuth()` reads the current client identity. Use `auth.isLoading` to avoid showing signed-out UI while Lakebed confirms a stored session.
- `canAccessApp()` is true when session setup finished without an error, a guest or account exists, and the app's sign-in policy is met. Gate data components on it. Show `auth.error` with a `retryAuth()` button, and keep `<SignInWithGoogle />` available to replace an expired or revoked token.
- `<SignInWithGoogle />`, `signInWithGoogle()`, and `signOut()` provide the built-in Google auth path.
- Use `<Router>`, `<Routes>`, `<Route>`, `<Link>`, `useParams()`, `useLocation()`, and `useNavigate()` for client-side pages.
- Use complete Tailwind class names in JSX. Lakebed compiles CSS automatically from client files and their imports. V0 does not support CSS files, CSS modules, PostCSS, or Tailwind config.

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

```tsx
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:

```tsx
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:

```sh
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](https://docs.lakebed.dev/auth/index.md).

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

```txt
OPENAI_API_KEY=sk-...
STRIPE_WEBHOOK_SECRET=whsec_...
```

Read it only from server handlers:

```ts
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:

```sh
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:

```sh
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:

```sh
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:

```sh
npx lakebed deploy --public-inspect
```

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

```sh
npx lakebed domains add my-app.lakebed.app
```

Manage owned deployments with:

```sh
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`:

```sh
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

- One server entry: `server/index.ts`.
- One client entry: `client/index.tsx`.
- App code can use relative imports, `lakebed/server`, `lakebed/client`, and Lakebed-provided Preact modules.
- Arbitrary npm imports inside capsule code are not supported yet.
- Node built-ins are not available inside capsule modules.
- Database fields support `string()`, `boolean()`, `number()`, `id(...)`, and `userId()`. Chain `.optional()` or `.default(value)` on any field.
- Local state resets when `npx lakebed dev` restarts.
- User-uploaded files use built-in object storage ([storage.md](https://docs.lakebed.dev/storage/index.md)); `lakebed dev` keeps them in memory and resets on restart.
- Anonymous deploys disable outbound server-side `fetch`.
- Non-empty `.env.lakebed.server` files require a claimed deploy before hosted env can sync.

Resource limits are separate from the capability limits above. [`limits.md`](https://docs.lakebed.dev/limits/index.md) lists free-plan resource limits, their errors, and what to change. Paid plans can have higher limits. See the [handler capability table](https://docs.lakebed.dev/capsule-api/index.md#handler-capabilities) before choosing a handler for external API calls or ingest jobs.

## Read Next

- [`capsule-api.md`](https://docs.lakebed.dev/capsule-api/index.md): detailed app-author API.
- [`limits.md`](https://docs.lakebed.dev/limits/index.md): request, database, action, storage, log, and server env limits with their errors.
- [`auth.md`](https://docs.lakebed.dev/auth/index.md): protected guests, sign-in requirements, user references, and guest upgrades.
- [`storage.md`](https://docs.lakebed.dev/storage/index.md): built-in object storage for user-uploaded files.
- [`reference.md`](https://docs.lakebed.dev/reference/index.md): capsule runtime, API, CLI, and deploy reference.
- [`examples`](https://docs.lakebed.dev/examples/index.md): working capsules that show the intended app shape.
- [`llms.txt`](/llms.txt): index of every page and section. Fetch one page from it instead of the full text.
- [`docs.json`](/docs.json): the same index as JSON with heading anchors.
