Capsule API

This page shows the API shape an agent should use when authoring a Lakebed app.

File Layout

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

Use server/index.ts for schema, queries, mutations, and external endpoints. Use client/index.tsx for the Preact UI. Put validation helpers, types, and constants in shared/ when both sides need them.

Define The Server

import { boolean, capsule, mutation, query, string, table } from "lakebed/server";
import { cleanTodoText } from "../shared/todo";

export default capsule({
  schema: {
    todos: table({
      text: string(),
      done: boolean().default(false),
      ownerId: string()
    })
  },

  queries: {
    todos: query((ctx) =>
      ctx.db.todos
        .where("ownerId", ctx.auth.userId)
        .orderBy("createdAt", "desc")
        .all()
    )
  },

  mutations: {
    addTodo: mutation((ctx, text: string) => {
      const cleanText = cleanTodoText(text);
      if (!cleanText) {
        return;
      }

      ctx.db.todos.insert({
        text: cleanText,
        done: false,
        ownerId: ctx.auth.userId
      });
    }),

    setTodoDone: mutation((ctx, id: string, done: boolean) => {
      const todo = ctx.db.todos.get(id);
      if (!todo || todo.ownerId !== ctx.auth.userId) {
        return;
      }

      ctx.db.todos.update(id, { done });
    })
  }
});

The important pattern is server authority:

Anonymous deploys preserve this model by running the bundled server JavaScript in a restricted source runtime. IR should be treated as a future optimization only when it can preserve the full handler semantics.

Use Shared Code Carefully

Good shared code:

export type Todo = {
  id: string;
  text: string;
  done: boolean;
  ownerId: string;
  createdAt: string;
  updatedAt: string;
};

export function cleanTodoText(value: string): string {
  return value.trim().slice(0, 160);
}

Keep shared/ pure. Do not import lakebed/server, lakebed/client, Preact, DOM APIs, Node built-ins, env values, or secrets from shared files.

Build The Client

import { SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
import { cleanTodoText, type Todo } from "../shared/todo";

export function App() {
  const auth = useAuth();
  const todos = useQuery<Todo[]>("todos");
  const addTodo = useMutation<[text: string], void>("addTodo");
  const setTodoDone = useMutation<[id: string, done: boolean], void>("setTodoDone");
  const authLabel = auth.displayName;
  const authStatus = auth.isLoading && auth.isGuest ? "checking session" : `signed in as ${authLabel}`;

  async function onSubmit(event: SubmitEvent) {
    event.preventDefault();
    const form = event.currentTarget as HTMLFormElement;
    const data = new FormData(form);
    const text = cleanTodoText(String(data.get("text") ?? ""));
    if (!text) {
      return;
    }

    await addTodo(text);
    form.reset();
  }

  return (
    <main className="min-h-screen bg-black px-6 py-10 text-white">
      <section className="mx-auto max-w-2xl">
        <div className="mb-3 flex items-center justify-between gap-3">
          <div className="flex min-w-0 items-center gap-2">
            {!auth.isLoading && auth.picture ? (
              <img alt="" className="h-7 w-7 rounded-full" referrerPolicy="no-referrer" src={auth.picture} />
            ) : null}
            <p className="min-w-0 truncate font-mono text-sm text-neutral-500">{authStatus}</p>
          </div>
          {!auth.isLoading && auth.isGuest ? (
            <SignInWithGoogle className="border border-neutral-700 px-3 py-1.5 text-sm text-neutral-200" />
          ) : !auth.isLoading ? (
            <button type="button" onClick={() => signOut()}>
              Sign out
            </button>
          ) : null}
        </div>

        <form className="mb-8 flex gap-3" onSubmit={(event) => void onSubmit(event)}>
          <input name="text" className="min-w-0 flex-1 border border-neutral-700 bg-black px-3 py-2" />
          <button type="submit" className="border border-white px-4 py-2">
            Add
          </button>
        </form>

        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>
              <label>
                <input
                  checked={todo.done}
                  type="checkbox"
                  onChange={(event) => void setTodoDone(todo.id, event.currentTarget.checked)}
                />
                {todo.text}
              </label>
            </li>
          ))}
        </ul>
      </section>
    </main>
  );
}

Client rules:

Client routes use Preact components and app-relative paths:

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

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

export function App() {
  return (
    <Router>
      <Link to="/todos/123">Open todo</Link>
      <Routes>
        <Route path="/" element={<main>Home</main>} />
        <Route path="/todos/:id" element={<TodoPage />} />
        <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, direct HTTP requests hit the endpoint first.

Auth

Use auth through Lakebed APIs only.

Server:

ctx.auth.userId;
ctx.auth.displayName;
ctx.auth.picture;
ctx.auth.email;

Client:

const auth = useAuth();

Guest auth is available immediately. To test multiple local users, use separate URLs:

http://localhost:3000/?lakebed_guest=alice
http://localhost:3000/?lakebed_guest=bob

To add Google sign-in, render <SignInWithGoogle /> or call signInWithGoogle() from a custom button. After sign-in, server handlers receive the verified identity on ctx.auth.

Server Env

Add server-only values at the capsule root:

# .env.lakebed.server
OPENAI_API_KEY=sk-...

Read them only from server handlers:

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

External endpoints can use the same env binding for webhook secrets:

import { endpoint, json, text } from "lakebed/server";

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 });
    }
    return json({ ok: true });
  })
}

Do not put secrets in client/ or shared/.

Run And Inspect

npx lakebed dev
npx lakebed db list --port 3000
npx lakebed db dump --port 3000
npx lakebed logs --port 3000

The database is local and in-memory during npx lakebed dev. Restarting dev resets it.

Hosted inspection is private by default. Run hosted inspection commands from the capsule directory so Lakebed can find lakebed.json or .lakebed/deploy.json in the working directory tree and send developer auth from the binding or saved anonymous claim token. Non-private hosted manifests expose only non-sensitive deploy metadata.

Deploy

npx lakebed deploy

If the app uses .env.lakebed.server or outbound server-side fetch, claim the deploy and run npx lakebed deploy again so Lakebed can publish the source-backed server path.

For a portable owned deploy, run npx lakebed auth login before the first deploy. Commit the generated root-level lakebed.json, which contains only the deploy id.

Use npx lakebed deploy --public-inspect only for demos where making hosted data and logs public is intentional.