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:
- Queries decide which rows the client can read.
- Mutations validate input before writing.
- Mutations re-check ownership before changing existing rows.
- Client code never writes directly to tables.
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:
- Export
App. - Call queries by the names defined in
server/index.ts. - Call mutations by the names defined in
server/index.ts. - Await mutations when the UI should wait for the server write.
- Use the built-in client router for multiple pages.
- Use Tailwind classes in JSX for styling.
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.