# Latch documentation (full)

Every page of https://…/docs concatenated in sidebar order. For an index with one line per page, fetch /llms.txt instead.


---

# Build with AI

Source: https://intentface-latch-docs.vercel.app/docs/build-with-ai

Coding agents know the AI SDK well and Latch not at all, so left alone they guess, and the guesses that go wrong are the quiet ones: the user's plan saved in the principal, editor state written into the system prompt, a tool that charges a card twice after a crash. Give your agent the Latch skill, and these docs.

## Install the skill

```bash
npx skills add https://intentface-latch-docs.vercel.app
```

The [skills CLI](https://skills.sh) asks which of your agents to install it for (Claude Code, Cursor, Codex and many more) and puts it in your project. Add `-g` to install it for every project instead, or `-a claude-code -y` to skip the questions. Your agent then reaches for it on its own whenever it works on code that uses `@intentface/latch-*`. To update, run it again.

The skill holds:

- **Recipes** for the common tasks: a chat agent that remembers, your route and the chat UI, tools that only reach the user's data, approvals, schedules, MCP connections, limits per plan, Slack and Telegram, deploying on Vercel, deleting someone's data.
- **What to pass where:** the principal, the context, params and `data-context`, and the mistakes that break quietly after an approval or a crash.
- **The rules that fail silently,** such as a stable owner key and tools that are safe to run twice.
- **Thirty pages of these docs** as markdown, read only when the task needs them, and readable offline.

It's the current version, matching these docs. On an older Latch release, install the skill from that release instead (see below).

## Give your agent the docs

Agents that can read the web can use the docs directly:

| | |
| --- | --- |
| [`/llms.txt`](/llms.txt) | Every page with a one-line description: where to start. |
| [`/llms-full.txt`](/llms-full.txt) | The whole documentation as one file, for an agent with a large context window. |
| Any page + `.md` | That page as markdown, e.g. [`/docs/concepts/inputs.md`](/docs/concepts/inputs.md). |

Or start a conversation with:

```text
Here are the Latch docs: https://intentface-latch-docs.vercel.app/llms.txt
Read the pages you need before writing code, and follow what they say over what you assume from the AI SDK.
```

Every page's **Copy page** button copies it as markdown, to paste into a chat.

## Match an older release

The skill on this site follows the latest release. From GitHub you can install it as it was in any release from 0.20.0 on, by its tag:

```bash
npx skills add https://github.com/Intentface/intentface-latch/tree/v0.20.0/skills/latch
```


---

# Latch

Source: https://intentface-latch-docs.vercel.app/docs

The [Vercel AI SDK](https://ai-sdk.dev) gives you the model call and the tool loop. **Latch is the backend around it**: everything an agent needs once real customers use it. Conversations that are saved, risky actions that wait for a person, answers that survive a crashed server, customers who never see each other's data, usage limits, and the same agent in Slack.

It's a library, not a service. It runs inside your own server, on the Postgres or SQLite you already have, behind your own login.

## What it looks like

An agent that helps customers with their orders, and asks before refunding:

<Tabs items={["Server", "Tools", "Chat route", "Chat UI"]}>
<Tab value="Server">

```ts title="lib/latch.ts"
import { anthropic } from "@ai-sdk/anthropic";
import { createRuntime, defineAgent } from "@intentface/latch-core";
import { createPostgresAdapter } from "@intentface/latch-drizzle";
import { context, find_order, refund } from "./tools";

// Who is asking. Ids only: it's saved with every run and schedule.
// Anything you look up (plan, names, clients) goes in the context.
export type Principal = { orgId: string; userId: string };

export const runtime = createRuntime<Principal>({
  // Every conversation belongs to one person in one organization.
  storage: createPostgresAdapter(db, {
    owner: (p) => [p.orgId, p.userId],
  }),
  // What tools receive on each turn (see the Tools tab).
  context,
  agents: {
    support: defineAgent({
      model: anthropic("claude-sonnet-4-6"),
      instructions: "You help customers with their orders.",
      tools: { find_order, refund },
      // A person clicks Approve before a refund runs.
      toolApproval: { refund: "user-approval" },
    }),
  },
  // A crashed answer is finished by another server.
  durability: { enabled: true },
});
```

</Tab>
<Tab value="Tools">

```ts title="lib/tools.ts"
import type { ToolContext } from "@intentface/latch-core";
import { tool } from "ai";
import { z } from "zod";
import type { Principal } from "./latch";

// Your own data access, bound to one organization. A tool gets this
// object, never an org id it could change, so it can't reach another
// customer's orders.
const ordersFor = (orgId: string) => ({
  get: (orderId: string) => db.orders.findOne({ orgId, orderId }),
  refund: (orderId: string, requestedBy: string) =>
    payments.refund({ orgId, orderId, requestedBy }),
});

// Built at the start of every turn, from the principal your route passed.
export const context = ({ principal }: { principal: Principal }) => ({
  orders: ordersFor(principal.orgId),
});

// What a tool receives: what `context` returned, plus the principal
// itself, which Latch adds for you.
type Ctx = ToolContext<Principal, ReturnType<typeof context>>;

export const find_order = tool({
  description: "Look up an order",
  inputSchema: z.object({ orderId: z.string() }),
  execute: async ({ orderId }, { context }) =>
    (context as Ctx).orders.get(orderId),
});

export const refund = tool({
  description: "Refund an order",
  inputSchema: z.object({ orderId: z.string() }),
  execute: async ({ orderId }, { context }) => {
    const { orders, principal } = context as Ctx;
    return orders.refund(orderId, principal.userId);
  },
});
```

</Tab>
<Tab value="Chat route">

```ts title="app/api/chat/route.ts"
import { runtime } from "@/lib/latch";

export async function POST(req: Request) {
  const user = await requireUser(req); // your login, not ours
  const { id, message } = await req.json();
  return runtime.handleChat({
    agent: "support",
    chatId: id,
    // Who is asking. Latch carries it everywhere from here.
    principal: { orgId: user.orgId, userId: user.id },
    message,
  });
}
```

</Tab>
<Tab value="Chat UI">

```tsx title="app/chat.tsx"
// The AI SDK's own useChat, approvals included. Nothing Latch-specific.
const { messages, sendMessage, addToolApprovalResponse } = useChat({
  id: chatId,
  transport: new DefaultChatTransport({
    api: "/api/chat",
    prepareSendMessagesRequest: ({ id, messages }) => ({ body: { id, message: messages.at(-1) } }),
  }),
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
```

</Tab>
</Tabs>

That's the whole integration. The conversation is saved, the refund waits for a click (today, tomorrow, from another device), and if a server dies mid-answer, another one finishes it.

The `principal` you pass once is what keeps customers apart. Storage scopes every read and write by it, every tool receives it through the context, your limits are keyed from it, connected accounts are stored under it, and it's saved with each run and schedule, so a resumed answer or a 9:00 report still acts as the right person. More in [Principal, context and params](/docs/concepts/inputs).

## What you get

- [**Conversations that are saved**](/docs/hard-parts/history), safe from two tabs, retries and forged messages.
- [**Approvals**](/docs/hard-parts/approvals) that work tomorrow, on another device, from Slack, and only once.
- [**Answers that survive a crash**](/docs/hard-parts/durability), on one server or fifty.
- [**Customers who never see each other's data**](/docs/hard-parts/access), including in runs nobody is logged in for.
- [**Usage limits**](/docs/hard-parts/limits) per organization, user and agent, that simultaneous requests can't slip past.
- [**Files and images**](/docs/hard-parts/attachments) handled without bloating your database or leaking requests.

Plus schedules, connections to other services (MCP, OAuth), subagents, Slack, Telegram and WhatsApp, and cost per answer.

## Build with your coding agent

Install the Latch skill and your agent (Claude Code, Cursor, Codex and more) gets the recipes, the rules that break quietly, and these docs:

```bash
npx skills add https://intentface-latch-docs.vercel.app
```

More, including `llms.txt` for agents that read the web, in [Build with AI](/docs/build-with-ai).

## When not to use it

If you're building a single-user prototype that doesn't need to remember anything, the AI SDK alone is the better tool. Latch earns its place when a second customer, a second server or a second channel shows up.

<Cards>
  <Card href="/docs/getting-started/quickstart" title="Quickstart" description="A chat agent that remembers, in one file and five minutes." />
  <Card href="/docs/concepts" title="How it fits together" description="The parts of Latch, what each one is for, and why it exists." />
  <Card href="/docs/hard-parts" title="Harder than it looks" description="What the simple version of each feature misses." />
  <Card href="/docs/reference/create-runtime" title="Reference" description="Every option and method, generated from the code." />
</Cards>

<Callout type="info" title="Reading this as an agent">
`/llms.txt` is an index of every page with a one-line description; `/llms-full.txt` is the whole documentation as one markdown file. Append `.md` to any page URL for its source. The Latch skill installs with `npx skills add https://intentface-latch-docs.vercel.app`.
</Callout>


---

# Agents

Source: https://intentface-latch-docs.vercel.app/docs/concepts/agents

**An agent is a model, its instructions, and what it's allowed to do.** You declare it with `defineAgent` and give it a name; clients pick it by that name.

## Why it exists

The AI SDK's agent is an object you build per call. In a product, the same agent serves thousands of people, and what it may do often depends on who they are: a different model on the paid plan, a tool only admins get. `defineAgent` describes the agent once, and Latch builds it for each turn, for that person.

## The smallest version

```ts
import { defineAgent } from "@intentface/latch-core";

const support = defineAgent({
  model: anthropic("claude-sonnet-4-6"),
  instructions: "You help customers with their orders.",
  tools: { find_order, refund },
  toolApproval: { refund: "user-approval" },
});
```

When it depends on the caller, pass a function instead:

```ts
const assistant = defineAgent<Principal, Ctx>(({ context }) => ({
  model: context.plan === "pro" ? anthropic("claude-sonnet-4-6") : anthropic("claude-haiku-4-5"),
  instructions: `You work for ${context.orgName}.`,
}));
```

## What an agent can have

- **Params:** what its chats are about, such as a ticket or a document, given once when a chat starts. See [Principal, context and params](/docs/concepts/inputs).
- **Tools,** some of them [behind approval](/docs/hard-parts/approvals).
- **Connections** to other services, by name (`connections: ["linear"]`).
- **Subagents** it can hand work to (`subagents: ["researcher"]`).
- **Built-in tools** you switch on: fetching web pages, asking the user a question, managing their schedules, a workspace with files and a shell.
- **Agents stored in a database** instead of code, so users can create their own, with versions you can test before they go live.

## Go deeper

- Every option: [defineAgent reference](/docs/reference/define-agent)


---

# Channels

Source: https://intentface-latch-docs.vercel.app/docs/concepts/channels

**A channel puts your agent where people already talk.** Users message a bot in Slack, Telegram or WhatsApp; the bot answers as the agent, with the same conversations, tools, approvals and limits as your web app.

## Why it exists

Connecting a bot to a model takes an afternoon. Keeping it right takes much longer: platforms send the same message twice, a bot can end up answering itself, a person has to be linked to their account in your app, approvals need to be buttons that only the right person can press, and someone who left the company shouldn't keep a working bot. `@intentface/latch-channels` handles that once, for every platform.

## The smallest version

One webhook route per bot. You supply who the platform user is in your app, and optionally where the channel keeps its small bits of state; the runtime does the rest.

```ts
import { telegramChannel } from "@intentface/latch-channels/telegram";

const channel = telegramChannel<Principal>({
  agent: "assistant",
  botRef, botToken, webhookSecret,
  runtime,
  authorizeUser, // the platform user → your principal, or a sign-in link
  seen, stashPending, askState, thread, // optional stores: dedupe, held messages, button state, threads
  waitUntil,
});

export const POST = (request: Request) => channel.handleRequest(request);
```

## What carries over

- **The conversation.** A chat in Slack is a chat in storage like any other, with history, limits and crash recovery.
- **Approvals and questions** become buttons, pressable only by the person they're for.
- **Schedules** can deliver into the same thread the user chats in.
- **Answers are posted when they're done,** or streamed where the platform really streams (Slack). Never faked by editing a message over and over.

## Go deeper

- What makes chat apps hard: [Channels in depth](/docs/guides/channels)
- A scheduled brief delivered to a channel: [Guide](/docs/guides/scheduled-brief)


---

# Extensions

Source: https://intentface-latch-docs.vercel.app/docs/concepts/extensions

**An extension hooks into the moments of a run**: before the model starts, before a tool runs, after each step. Each hook returns what it wants to change, and Latch applies it.

## Why it exists

Every product ends up with a few rules of its own: this tool needs the Team plan, show the user what each answer cost, answer `/stats` without calling the model, keep an audit log. Without extensions, each one means forking the runtime or wrapping every tool. With them, each is one small file.

## The smallest version

```ts
import { defineExtension } from "@intentface/latch-core";

export const plans = defineExtension("plans", (ext) => {
  // Refuse a tool. The reason reaches the model, so it can explain instead of retrying.
  ext.on("tool_call", ({ toolName }, run) => {
    if (toolName === "export_data" && planOf(run.principal) === "free")
      return { block: true, reason: "Exporting needs the Team plan." };
  });

  // A slash command, answered without the model.
  ext.registerCommand("stats", {
    description: "Messages in this chat",
    handler: async (_args, { messages }) => `${messages.length} messages so far.`,
  });
});

createRuntime({ …, extensions: [plans] });     // every agent
defineAgent({ …, extensions: [plans] });       // or just one
```

## The rules that keep them safe

- **Each moment allows certain changes.** A `tool_call` hook can block a tool; it can't rewrite history. That's what keeps two extensions from fighting.
- **What's stored stays true.** Changing what the *user sees* never changes what's saved, unless a hook is explicitly about the saved record.
- **A broken extension doesn't break the run.** A hook that throws is reported and skipped, unless you mark the extension as critical.

## Go deeper

- Every moment and what it can change: [Extensions in depth](/docs/guides/write-an-extension)


---

# Overview

Source: https://intentface-latch-docs.vercel.app/docs/concepts

Latch is a handful of parts that each do one job. Here's what happens when a user sends a message, and which part does what.

```text
 Browser / Slack / cron
        │  message
        ▼
 Your route ──── your login decides who is asking ──► Principal
        │
        ▼
 Runtime.handleChat()
   1. loads the conversation ...................... Storage
   2. builds what tools receive, for this person .. Context
   3. runs the agent: model, tools, approvals ..... Agent (+ Tools & connections)
   4. saves every step, holds a lease on the run .. Storage
        │
        ▼
 Streamed answer (AI SDK format) ──► useChat / Slack / Telegram
```

**Your route** stays yours: Latch never handles login. You turn the request into a **principal**, a small object saying who is asking.

**The runtime** is the one object your code calls. It ties everything else together and guarantees the hard parts: one answer at a time per chat, crash recovery, approvals, limits.

**Storage** is where conversations, runs, usage and schedules live: your own database, through an adapter.

**The agent** is the model, its instructions and what it may do. **Context** is what its tools get: this customer's data, nothing else. **Params** say what a chat is about, such as a ticket, given once when it starts. **Tools and connections** are what it can act on: your functions, MCP servers, other services.

Around those sit optional parts: the **server** if you don't want to write routes, **channels** for Slack, Telegram and WhatsApp, and **extensions** to hook into a run.

<Cards>
  <Card href="/docs/concepts/runtime" title="Runtime" description="The one object your code calls." />
  <Card href="/docs/concepts/agents" title="Agents" description="A model, its instructions, and what it may do." />
  <Card href="/docs/concepts/inputs" title="Principal, context and params" description="What to pass where: who is asking, what they can work with, what the chat is about." />
  <Card href="/docs/concepts/storage" title="Storage" description="Where everything lives, in your database." />
  <Card href="/docs/concepts/tools-connections" title="Tools and connections" description="Your functions, MCP servers, and signed-in accounts." />
  <Card href="/docs/concepts/secrets" title="Secrets (the vault)" description="Customers' API keys and tokens, encrypted, never seen by the model." />
  <Card href="/docs/concepts/server" title="Server" description="Ready-made HTTP routes, if you want them." />
  <Card href="/docs/concepts/channels" title="Channels" description="The same agent in Slack, Telegram and WhatsApp." />
  <Card href="/docs/concepts/extensions" title="Extensions" description="Your own hooks into a run." />
</Cards>


---

# Principal, context and params

Source: https://intentface-latch-docs.vercel.app/docs/concepts/inputs

An agent needs to know four things on every turn. Each has its own place in Latch, because each one lives for a different length of time.

| Input | Answers | Example |
| --- | --- | --- |
| `principal` | Who is asking? | `{ orgId: "o_7", userId: "u_42" }` |
| `context` | What can they work with? | a database client limited to their organization, their plan |
| `params` | What is this chat about? | `{ ticketId: "T-9" }` |
| `data-context` | What are they looking at right now? | `{ activeFile: "Q3 budget" }` |

## Which one do I use?

Ask these in order. The first yes is the answer.

1. **Is it who the user is?** Use the **principal**. Ids only.
2. **Can you work it out from who they are?** Use the **context**: their plan, their permissions, the organization's name, clients for their data.
3. **Is it what this conversation is about, decided when it starts?** Use **params**: a ticket, a document, a model fixed for the chat.
4. **Does it change while they chat?** Put it in a **`data-context` part** on their message: the open file, the selection, the page.

Most agents need only the first two. Params are for agents that are *about* something; `data-context` is for agents that see the user's screen.

## Why four places

One answer doesn't always finish in the request that started it. It can stop for an approval that's clicked tomorrow in Slack, or be cut off by a crash and finished by another server. Those moments run with nothing of yours involved, except the `context` function, so whatever the agent needs then has to be saved, or rebuildable from something saved.

<InputsTimeline />

- The **principal** and **params** are saved once and restored, so they're the same throughout.
- The **context** is never saved. It's rebuilt from the principal every time, so it's always current: a downgraded plan applies from the next turn on, even in the middle of an answer.
- The **`data-context` part** is saved with its message. The rest of that answer sees the same values; the next message brings its own.

## The principal: who is asking

```ts
type Principal = { orgId: string; userId: string };

// in your route, on every message
const user = await requireUser(req);
runtime.handleChat({ chatId, principal: { orgId: user.orgId, userId: user.id }, message });
```

- **Passed by your route**, built from your login, on every call.
- **Saved** with every chat, run and schedule. Storage scopes every read and write by it, so another customer's chat simply isn't found.
- **Re-checked** when an answer continues without a request: your `reconstructPrincipal` can say the person has left.
- **Ids only.** Names, plans and roles change; once saved here, they'd be stale in every schedule and resumed run.

## The context: what they can work with

```ts
export const context = async ({ principal }: { principal: Principal }) => ({
  db: dbFor(principal.orgId),            // queries limited to this organization
  plan: await planOf(principal.orgId),   // read fresh on every turn
  can: (action: string) => permissions.check(principal, action),
});
export type Ctx = Awaited<ReturnType<typeof context>>;

createRuntime({ storage, context, agents });
```

- **Built by your function** at the start of every turn, from the principal only. That's all Latch has when an answer continues, so that's all it takes.
- **Never saved**, so it can hold clients and functions, and always reads current values.
- **Reaches the agent function** as `context`, and **every tool** as `options.context`, together with `principal`:

```ts
const find_ticket = tool({
  description: "Look up a ticket",
  inputSchema: z.object({ id: z.string() }),
  execute: async ({ id }, { context }) => {
    const { db } = context as ToolContext<Principal, Ctx>;
    return db.tickets.get(id); // can't reach another organization's tickets
  },
});
```

## Params: what the chat is about

```ts
const Ticket = z.object({ ticketId: z.string() });

const support = defineAgent<Principal, Ctx, z.infer<typeof Ticket>>(
  async ({ context, params }) => {
    const ticket = await context.db.tickets.get(params.ticketId); // a claim: load it through the user's own db
    if (!ticket) throw new Error("Ticket not found");
    return {
      model: anthropic("claude-sonnet-4-6"),
      instructions: `You help with ticket ${ticket.id}: "${ticket.subject}".`,
      tools: { reply_to_ticket, close_ticket },
    };
  },
  { params: Ticket },
);
```

```ts
// the chat's first message says what it's about
runtime.handleChat({ agent: "support", chatId, principal, message, params: { ticketId: "T-9" } });

// every later message: just the message
runtime.handleChat({ chatId, principal, message });
```

- **Declared by the agent**, with a schema (Zod, Valibot, any Standard Schema). Without type arguments the params type comes from the schema; with them, give it as the third, as above.
- **Passed once**, with the chat's first message, or to `runAgent` and `schedule`.
- **Checked** against the schema (400 `invalid_params` if they don't match), then **saved on the chat**.
- **Fixed** for the chat's life. Sending the same params again is fine; different ones are refused (409 `params_fixed`). A different ticket means a new chat.
- **Reach** the agent function as `params` and every tool as `options.context.params`, on every turn.

Good uses: the ticket or document a chat is about, a model chosen when the chat starts (reasoning from one provider can't be sent to another), what a schedule or a background job should work on.

## data-context: what they're looking at

The client attaches it to the message the user is sending:

```ts
sendMessage({
  parts: [
    { type: "text", text: "Summarize this" },
    { type: "data-context", data: { activeFile: "Q3 budget", openTabs: ["Roadmap"] } },
  ],
});
```

What the model sees for that message:

```text
Summarize this
[Context]
activeFile: Q3 budget
openTabs: ["Roadmap"]
```

- **The model** reads it as text in that message, after what the user wrote. Earlier messages keep their own, so the instructions never change between messages and **prompt caching keeps working**.
- **The user** doesn't see it: chat UIs don't render data parts.
- **The agent function and tools** get the latest one as `messageContext`, as data:

```ts
defineAgent(({ messageContext }) => {
  const view = messageContext as { model?: string } | undefined;
  return { model: view?.model === "fast" ? haiku : sonnet, … };
});
```

- **Saved with its message**, so after an approval or a crash the rest of the answer sees the same values.
- **Optional checks and wording:** `createRuntime({ messageContext: { schema, toModel } })`. An agent can word it its own way, or leave it out, with `defineAgent(fn, { messageContext: { toModel } })`.

## All four together

A help desk: each chat is about one ticket, and the side panel tells the agent which reply the agent is looking at.

```ts title="lib/latch.ts"
export const context = async ({ principal }: { principal: Principal }) => ({
  db: dbFor(principal.orgId),
  plan: await planOf(principal.orgId),
});
type Ctx = Awaited<ReturnType<typeof context>>;

const Ticket = z.object({ ticketId: z.string() });

const support = defineAgent<Principal, Ctx, z.infer<typeof Ticket>>(
  async ({ context, params }) => {
    const ticket = await context.db.tickets.get(params.ticketId);
    if (!ticket) throw new Error("Ticket not found");
    return {
      model: context.plan === "pro" ? sonnet : haiku,                 // from the context
      instructions: `You help with ticket ${ticket.id}: "${ticket.subject}".`, // from params
      tools: { reply_to_ticket: replyTool(ticket) },                  // bound to this ticket
    };
  },
  { params: Ticket },
);

export const runtime = createRuntime<Principal>({
  storage,
  context,
  agents: { support },
  messageContext: { toModel: (c: { replyId?: string }) => (c.replyId ? `[Looking at reply ${c.replyId}]` : undefined) },
});
```

```ts title="app/api/chat/route.ts"
const user = await requireUser(req);                         // who: from your login
const { chatId, message, ticketId } = await req.json();
return runtime.handleChat({
  agent: "support",
  chatId,
  principal: { orgId: user.orgId, userId: user.id },
  message,                                                   // carries its own data-context part
  ...(ticketId ? { params: { ticketId } } : {}),             // only the first message sends it
});
```

## Common mistakes

| Mistake | What goes wrong | Instead |
| --- | --- | --- |
| A plan or role in the principal | Frozen into every saved run and schedule: a downgraded user keeps Pro on last month's report | The context |
| What the user is looking at in the instructions | Every navigation changes the system prompt and breaks the prompt cache | A `data-context` part |
| Reading the request inside `context` | There's no request when an answer continues, so it's missing halfway through | Read it in your route; pass `params` or a `data-context` part |
| Trusting an id from params or `data-context` | Another customer's ticket, if someone edits the request | Load it through the user's own client in the agent function |
| A secret in params or `data-context` | Saved in your database, and sent to the model | Fetch it inside the tool, from the vault |
| A new ticket in the same chat | Refused: params are fixed | Start a new chat |

## Go deeper

- Private, shared and team conversations: [Who sees what](/docs/hard-parts/access)
- Options: [createRuntime](/docs/reference/create-runtime) (`context`, `messageContext`), [defineAgent](/docs/reference/define-agent) (`params`), [Context and tools](/docs/reference/context)


---

# The run log

Source: https://intentface-latch-docs.vercel.app/docs/concepts/run-log

Every turn appends to a log: the user message, the assistant message's parts as they stream, a run row with the lease and the usage. Nothing is ever rewritten. Latch is built on the consequence: **state is a fold over that log**, so anything that needs the state of a conversation reads the log, and anything that needs to recover reads it again.

## What falls out of it

- **Resume after a crash.** A run holds a lease and heartbeats it. When the process dies, the lease expires; a reaper marks the run errored and bumps its fencing token; the next tick reclaims it and replays the history. No queue, no workflow engine, no checkpoint format — the log *is* the checkpoint. A stale worker that wakes up later has the wrong fencing token and its writes are refused.
- **Approvals as run state.** A gated tool call is a message part in `approval-requested`. A decision minutes later is another write to the log, and the turn continues from history. Nothing is held open while a human decides.
- **One turn per conversation.** The storage adapter enforces one active run per chat (a partial unique index). Two tabs, a webhook retry and a scheduled brief arriving mid-conversation all meet the same rule: one runs, the other is told to wait. A crashed turn never blocks its chat — the next message supersedes it, fencing the old worker out.
- **Cost and audit.** Every tool call with its input, every token priced per model, per tenant, in the same rows — a query, not a reconstruction.
- **Compaction without loss.** A long conversation gets a summary boundary appended; later turns read from the boundary. Stored history is never rewritten, so nothing is lost and a chat can be compacted again.
- **Memory and evaluation.** Idle conversations are extracted into memory, and an evaluator can grade real turns, both by reading the log after the fact.

## Where the hard parts are

- Two nodes must never own one run: leases with fencing tokens make ownership a single-row transition.
- A resumed turn must not see its own half-written sentence. Nothing partial is ever stored (the checkpoint fires at each step's end, so a step interrupted mid-generation was never written), and what is stored gets its trailing assistant text trimmed, so the model call ends on a user turn rather than being asked to continue its own sentence — a hard rejection on models that refuse a prefill.
- Finishing the loop and recording that it finished are two writes, and a crash can land between them. So recovery reads the tail of history before spending anything: a run whose last step called no tool is already done, and its row is reconciled rather than re-run. Otherwise the answer would be generated a second time and appended to the message that already holds the first.
- A failed stream must be recorded as errored exactly once, by the writer whose fenced update applied.
- A conversation must have exactly one thing happening in it: a run and the message that starts it are written together, so a refusal leaves nothing and a crash cannot leave a run without its message.

## Next

- [Turn on crash recovery](/docs/guides/crash-recovery) — turning it on, `cron()`, `maxAttempts`
- [Reference: storage](/docs/reference/storage) — the guarantees an adapter has to make
- [Guide: gate a tool behind approval](/docs/guides/approvals)


---

# Runtime

Source: https://intentface-latch-docs.vercel.app/docs/concepts/runtime

**The runtime is the one object your code talks to.** You create it once with `createRuntime`, and every route, scheduled job and test calls it.

## Why it exists

An agent turn involves a lot of moving parts: load the conversation, check limits, claim the run so no other server runs it too, call the model, run tools, pause for approvals, save each step, record the cost. If your routes did that themselves, every route would need to get every step right, and the next route would get one wrong. The runtime does it the same way every time.

## The smallest version

```ts
import { createRuntime } from "@intentface/latch-core";

const runtime = createRuntime<Principal>({ storage, context, agents });
```

Three things are required: where to store things (`storage`), what tools receive (`context`), and your `agents`. Everything else switches on a capability (crash recovery, limits, schedules, connections) and does nothing until you add it.

## What you call it for

```ts
runtime.handleChat({ agent, chatId, principal, message });   // a chat message, streamed back
runtime.runAgent({ agent, principal, prompt });              // a job with no chat: webhooks, tests
runtime.applyApproval({ chatId, principal, decisions });     // a person's decision on a paused tool
runtime.loadHistory({ chatId, principal });                  // a conversation, for display
runtime.cron();                                               // every minute: schedules and crash recovery
```

## Go deeper

- Every option: [createRuntime reference](/docs/reference/create-runtime)
- Every method: [Runtime methods](/docs/reference/runtime)
- What it guarantees: [Harder than it looks](/docs/hard-parts)


---

# Secrets (the vault)

Source: https://intentface-latch-docs.vercel.app/docs/concepts/secrets

Tools often need a credential that belongs to a customer: the CRM key they pasted into your settings, their Slack bot's token, the token from their Linear sign-in. **The vault keeps these encrypted in your own database, per customer, and hands them only to your server code.** The model never sees them.

| Use the vault for | Not for |
| --- | --- |
| API keys your customers give you | your own app's keys: those belong in environment variables |
| Bot tokens and webhook secrets per customer | anything the model should read: that goes in the context or a tool result |
| OAuth tokens from users connecting a service (Latch stores these itself) | what a chat is about: that's [params](/docs/concepts/inputs) |

## Set it up

```ts title="lib/vault.ts"
import { createVault } from "@intentface/latch-core";
import { createPostgresSecretStore } from "@intentface/latch-drizzle";

export const vault = createVault<Principal>({
  // Whose secret it is: a person, or the whole team when there's no userId.
  store: createPostgresSecretStore(db, {
    owner: (p) => (p.userId ? [p.orgId, p.userId] : [p.orgId]),
  }),
  masterKey: process.env.LATCH_VAULT_KEY!, // 32 random bytes, base64
});
```

Generate the master key once and keep it in your environment or key manager, never in the database:

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```

<Callout type="warn" title="Keep the master key safe, and keep it">
Every secret is encrypted with it. Lose it, and every secret it encrypted becomes unreadable. To change it, rotate (below) rather than swapping it: a swapped key can't read anything saved before.
</Callout>

On Node, `deriveMasterKey(passphrase, { salt, cost: 2 ** 17 })` from `@intentface/latch-core/node` turns a passphrase into a master key, if that's easier to manage. The salt must stay the same for the deployment. Leave out `cost` only for a deployment that already derives its key without it: a different cost gives a different key.

### Rotating the master key

Rotate when someone who had the key leaves, when it may have leaked, or on a schedule. Give the new key an id, and keep the old one readable while secrets move over:

```ts title="lib/vault.ts"
export const vault = createVault<Principal>({
  store,
  masterKey: { id: "2026-10", key: process.env.LATCH_VAULT_KEY! },
  previousKeys: [{ id: "default", key: process.env.LATCH_VAULT_KEY_OLD! }],
});
```

A plain string key has the id `default`, so that's the old key's id the first time. From then on, new and updated secrets are saved under the new key, and secrets under the old one still read. Then, from a script or an admin route, once the new config is deployed everywhere:

```ts
const { rewrapped, remaining, errors } = await vault.rotate();
```

It moves every secret onto the new key. Only each secret's own small data key is re-encrypted, never the values, so it's quick even with many secrets, and safe to run again or stop part-way. Run it only once every instance is on the new config: an instance still on the old one keeps saving secrets under the old key, including ones `rotate()` has already passed, and `remaining` can't count those. With every instance on the new config, `remaining: 0` means nothing needs the old key any more: remove it from `previousKeys` and from your environment. A secret saved while it ran can count in `remaining`; run it again. A secret that no configured key opens is listed in `errors` and left as it is.

### A master key in a key service

To keep the master key out of your app altogether, in AWS KMS, Google Cloud KMS, HashiCorp Vault or your own HSM, give the vault a key it calls instead of one it holds: an id, and functions that wrap and unwrap a secret's data key through your service. Here, AWS KMS:

```ts title="lib/vault.ts"
import { DecryptCommand, EncryptCommand, KMSClient } from "@aws-sdk/client-kms";
import { createVault, type VaultKeyProvider } from "@intentface/latch-core";

const kms = new KMSClient({});
const KeyId = process.env.VAULT_KMS_KEY_ARN!;

const awsKms: VaultKeyProvider = {
  id: "kms-1",
  async wrap(dataKey, { context }) {
    const out = await kms.send(new EncryptCommand({ KeyId, Plaintext: dataKey, EncryptionContext: context }));
    return out.CiphertextBlob!;
  },
  async unwrap(wrapped, { context }) {
    const out = await kms.send(new DecryptCommand({ KeyId, CiphertextBlob: wrapped, EncryptionContext: context }));
    return out.Plaintext!;
  },
};

export const vault = createVault<Principal>({ store, masterKey: awsKms });
```

`context` names the secret and, with the shipped stores, its owner. Pass it on, as the encryption context or, for services that take bytes (Google Cloud KMS's `additionalAuthenticatedData`), as `aad`, so the service refuses a secret copied into another owner's row. Each `get` and `set` makes one call to the service. To move existing secrets over, put your current key in `previousKeys` and run `vault.rotate()`, as above.

Each secret is also tied to the row it's saved in, its owner and its name: a record copied into another owner's row doesn't decrypt there. (With a store of your own, that takes `ownerKeyOf`; without it, secrets are tied to their name only.) Secrets saved before 0.21 aren't tied yet: `rotate()` ties them as it moves them, and running it once without a new key ties them where they are. Once a deployment saves secrets with 0.21, an older version can't read those, so don't roll back past it.

## Put secrets in

Your own settings page saves them. The vault is plain server code; nothing in Latch exposes it over HTTP.

```ts title="app/api/settings/crm/route.ts"
export async function POST(req: Request) {
  const user = await requireAdmin(req);                       // your check: who may set it
  const { apiKey } = await req.json();
  await vault.set({ orgId: user.orgId }, "crm_api_key", apiKey); // the whole team's key
  return Response.json({ connected: true });                  // never send the value back
}

// elsewhere
await vault.has({ orgId }, "crm_api_key");   // "connected" in your settings UI
await vault.list({ orgId });                 // key names, never values
await vault.delete({ orgId }, "crm_api_key"); // disconnect
```

Setting a key again replaces it. That's how a customer rotates one: the next tool call uses the new value.

## Whose secret is it?

The principal you pass, and the store's `owner`, decide. With the setup above:

| You pass | Owner | Who shares it |
| --- | --- | --- |
| `{ orgId, userId }` | `[orgId, userId]` | one person, like their personal API key |
| `{ orgId }` | `[orgId]` | the whole team, like the company's CRM key |

A tool reads it back with the same shape: `vault.get({ orgId: principal.orgId }, "crm_api_key")` for the team's key, `vault.get(principal, …)` for a personal one. Another customer's key is never found, the same way another customer's chat isn't.

The secret store's `owner` is separate from the chat storage's, so team-wide credentials can sit next to private chats. It also receives the key, for when one kind of secret should always be personal and another always shared:

```ts
createPostgresSecretStore(db, {
  owner: (p, key) => (key?.startsWith("team:") || !p.userId ? [p.orgId] : [p.orgId, p.userId]),
});
```

`vault.list` passes no key, so `owner` decides from the principal alone: here `vault.list(principal)` lists the personal keys and `vault.list({ orgId })` the team's. Make sure your `owner` handles a call without a key.

## Use them in tools

A tool reads the secret when it runs, uses it to call the service, and returns only the result:

```ts
const lookup_customer = tool({
  description: "Look up a customer in the CRM",
  inputSchema: z.object({ email: z.string() }),
  execute: async ({ email }, { context }) => {
    const { principal } = context as ToolContext<Principal, Ctx>;
    const apiKey = await vault.get({ orgId: principal.orgId }, "crm_api_key");
    if (!apiKey) return { error: "Connect your CRM in Settings first." };
    const res = await fetch(`https://crm.example.com/customers?email=${encodeURIComponent(email)}`, {
      headers: { authorization: `Bearer ${apiKey}` },
    });
    const customer = await res.json();
    return { name: customer.name, plan: customer.plan }; // the data, never the key
  },
});
```

Reading it inside the tool, rather than in your route, also means it's there when an answer continues after an approval or a crash: tools run with the restored principal, so the lookup works the same.

## Connections use the same vault

- **Signing in to a service** (`oauth()` in [`createMcpConnections`](/docs/reference/mcp)): each user's tokens are saved in the vault under `mcp_oauth:<connection>:…`, refreshed when they expire, and removed when the user disconnects.
- **A key you supply** (`bearer()`): `createMcpConnections({ vault, secrets: vault.resolver, … })` and `bearer({ getToken: (p) => vault.get(p, "crm_token") })` send it as the server's `Authorization` header.

Either way the token goes from the vault to the request, and never into the conversation.

## What protects them

- **Encrypted per secret.** Each value is encrypted with its own random key (AES-256-GCM), and that key is encrypted with your master key. A copy of the database alone reveals nothing.
- **Server only.** Values reach your code and the requests it makes. The model, the browser, logs and traces see them only if your code puts them there.
- **Scoped by owner.** Reads are limited to the owner the principal maps to.

## Common mistakes

| Mistake | What goes wrong | Instead |
| --- | --- | --- |
| A key in the instructions, params or `data-context` | Saved in the conversation, sent to the model, and possibly repeated in an answer | Read it inside the tool |
| A tool returning the raw response, headers included | The model can see, and repeat, whatever came back | Return only the fields the model needs |
| Reading the key in your route and passing it along | It isn't there when an answer continues, and it travels further than needed | Read it in the tool from the restored principal |
| The master key in the database or the repo | Encryption protects nothing | An environment variable or a key manager |
| Swapping the master key like an API key | Every secret saved under the old key becomes unreadable | [Rotate it](#rotating-the-master-key): the new key with an id, the old one in `previousKeys`, then `vault.rotate()` |

## Go deeper

- Every method: [Vault reference](/docs/reference/vault)
- Signing in to services: [Connecting accounts](/docs/hard-parts/connections), [Connect other services](/docs/guides/connections)


---

# Server

Source: https://intentface-latch-docs.vercel.app/docs/concepts/server

**`@intentface/latch-server` turns the runtime into HTTP routes.** It's optional: everything it does, your own routes can do by calling the runtime.

## Why it exists

A chat product needs more than one route: send a message, approve a tool, load a conversation, list chats, manage schedules, finish signing in to a service, and a cron endpoint. They're the same in every app, so they ship ready-made. Your login still decides who is asking.

## The smallest version

`createLatchApp` builds the runtime with sensible defaults (crash recovery, the cron, a model catalog) and gives you one handler for every route:

```ts
import { createLatchApp } from "@intentface/latch-server";

export const { runtime, handler } = await createLatchApp<Principal>({
  storage,
  agents: { assistant },
  // Your login. Return null and the request gets 401.
  resolveContext: async (request) => {
    const user = await getUser(request);
    return user ? { orgId: user.orgId, userId: user.id } : null;
  },
});
```

The handler takes a `Request` and returns a `Response`, or `undefined` for a path that isn't one of its routes. So it mounts in anything that speaks web requests: Next.js, Hono, TanStack Start, Bun, Deno, and Express or Fastify with a small adapter.

```ts title="app/api/latch/[...path]/route.ts"
const route = async (req: Request) => (await handler(req)) ?? new Response(null, { status: 404 });
export { route as GET, route as POST, route as DELETE };
```

## When to write your own routes instead

When you need control over one step: a custom request body, extra checks before a message reaches the agent, a webhook that starts a run. Mix both freely: the handler for the routine routes, `runtime.handleChat` or `runtime.runAgent` in your own.

## Go deeper

- Mounting in each framework: [Hosting](/docs/guides/mount-the-handler)
- Without the server package: [By hand](/docs/getting-started/by-hand)


---

# Storage

Source: https://intentface-latch-docs.vercel.app/docs/concepts/storage

**Storage is where Latch keeps everything: conversations, runs, usage and schedules.** It lives in your own database, through an adapter.

## Why it exists

Saving messages is the easy part. The hard part is the rules storage has to enforce, because it's the only place every server sees: one answer at a time per chat, exactly one server taking over a crashed run, a stale server's writes refused, each customer seeing only their own rows. An adapter keeps those guarantees in the database itself, so they hold however many servers you run.

## The smallest version

```ts
import { createPostgresAdapter, migratePostgres } from "@intentface/latch-drizzle";

await migratePostgres(db); // creates the latch_* tables
const storage = createPostgresAdapter<Principal>(db, { owner: (p) => [p.orgId, p.userId] });
```

`createSqliteAdapter` does the same for SQLite and Turso. `owner` decides whose conversation is whose: `[orgId, userId]` for private chats, `[orgId]` for chats the whole team shares.

## Your own database

You can write your own adapter for another database or your own schema. `@intentface/latch-core/testing` ships the checks the built-in adapters pass, so you can prove yours keeps the same guarantees:

```ts
import { registerStorageConformance } from "@intentface/latch-core/testing";

registerStorageConformance({ describe, it }, "my adapter", async () => ({
  store: createMyAdapter(db),
  principals: [{ orgId: "a" }, { orgId: "b" }],
}));
```

## Deleting someone's data

When a person asks for their data to be deleted, or a customer leaves, delete it through Latch rather than with SQL behind its back:

```ts
// One conversation, with the subagent threads it spawned.
await runtime.deleteChat({ principal, chatId });

// Everything stored for a person, in every store you pass.
const also = { secrets: secretStore, agents: agentStore, memory: memoryJobStore, channels: myChannelStore };
await runtime.purgeOwner([orgId, userId], { also });

// A customer who has left: say what happens to their usage records.
await runtime.purgeOwner([orgId], { also, usage: "delete" });
```

`purgeOwner` works by owner path: `[orgId]` covers the organization's shared chats and every person in it, `[orgId, userId]` just that person. The storage adapter removes chats, messages, runs and schedules; the stores in `also` remove what they keep (the shipped secret, agent and memory job stores all can, including the drafts a person is writing in the organization's agents). It works in batches, so a large organization can't time out, and anything an answer is still writing is stopped first, so nothing comes back. If any store can't purge, it refuses before deleting anything, rather than leaving the owner half deleted.

**Usage records stay, unless you say otherwise.** The usage ledger (model, tokens and cost per answer, no content) is what you bill from, so deleting doesn't rewrite it:

- `deleteChat` keeps the chat's usage. Deleting a conversation doesn't make it cost less, and it doesn't give anyone more of their limits either: those count in a separate store.
- `purgeOwner([orgId, userId])` re-files the person's usage under `[orgId]` and clears its link to their runs, so the organization's totals stay right while nothing points at the person. That's pseudonymous rather than anonymous (a one-person organization can still be told apart), which is what keeping billing records usually means. Pass `usage: "keep"` to leave it as it is, `"delete"` to remove it, or `refileTo` to move it somewhere else.
- `purgeOwner([orgId])` has nothing above it to re-file to, so it asks you to choose: `usage: "delete"` or `"keep"`.

Two things stay yours: memories themselves live in your memory store, and usage-limit counters are keyed by the names you chose (they may contain the person's id, like `user:org1:u7`; clear those for a complete erasure). The handler also exposes `DELETE chats/:id` for the chat list's delete button.

## Go deeper

- What an adapter must guarantee: [Storage reference](/docs/reference/storage)
- Why those guarantees matter: [Running on many servers](/docs/hard-parts/durability)


---

# Tools and connections

Source: https://intentface-latch-docs.vercel.app/docs/concepts/tools-connections

**Tools are what an agent can act on.** Your own functions, the tools of an MCP server, or another service the user has signed in to.

## Why they need more than the AI SDK's `tool()`

A tool in a product acts for one customer, often with that customer's credentials. Three things have to be right: the tool reaches only that customer's data, the credential never reaches the model, and a user who hasn't signed in to a service is asked to, instead of the agent failing.

## Your own tools

Plain AI SDK tools. They receive the [context](/docs/concepts/inputs), so they act for the right customer:

```ts
const refund = tool({
  description: "Refund an order",
  inputSchema: z.object({ orderId: z.string() }),
  execute: async ({ orderId }, { context }) =>
    (context as ToolContext<Principal, Ctx>).orders.refund(orderId),
});
```

## Connections: tools from other services

Name a service on the agent, and its tools appear for each person who has access:

```ts
import { createMcpConnections, oauth } from "@intentface/latch-mcp";

createRuntime({
  …,
  connections: createMcpConnections({
    vault,
    allowedHosts: ["mcp.linear.app"],
    callbackBaseUrl: "https://app.example.com/api/latch/connections",
    connections: { linear: { url: "https://mcp.linear.app/mcp", auth: oauth() } },
  }),
});

defineAgent({ …, connections: ["linear"] });
```

A user who hasn't connected Linear yet sees the agent ask them to, and the conversation continues after they sign in. Their tokens are stored encrypted in the **vault** and used on their behalf; the model never sees them. Tools the server marks as destructive need approval by default; servers don't always mark them, so you can ask for approval on every tool instead.

## Secrets for your own tools

The same vault holds API keys your tools need, per customer. A tool reads one when it runs and returns only the result:

```ts
const key = await vault.get({ orgId: principal.orgId }, "crm_api_key");
```

Setup, team and personal keys, and what protects them: [Secrets (the vault)](/docs/concepts/secrets).

## Go deeper

- What signing in involves: [Connecting accounts](/docs/hard-parts/connections)
- OpenAPI specs as tools: `@intentface/latch-openapi`


---

# Inside a turn

Source: https://intentface-latch-docs.vercel.app/docs/concepts/turns

A turn is built from what it's given (**principal**, **context**, **params**, the message's **data-context**) and from the stored **messages**, projected differently for the model and the user. What each input is and when to use it: [Principal, context and params](/docs/concepts/inputs). In short:

- **principal**: who is asking. Saved with the chat, run and schedule; the storage adapter derives the owner key that scopes every row from it; re-checked by `reconstructPrincipal` when a turn runs without a request.
- **context**: built by your `context` function from the principal at the start of every turn. Never saved.
- **params**: saved on the chat by its first message; every turn gets them.
- **data-context**: a part on the user's message; the turn answering that message gets it, saved with the message.

<Callout type="warn" title="Pin the tenant at session creation">
If the value your owner key derives from can change mid-session (an "active org" the user can switch), owner-keyed rows fragment silently: yesterday's chats belong to a key you no longer produce. Resolve it once when the session is created and keep it stable for the session's life.
</Callout>

## Messages — one record, three views

`AppMessage` is just an AI SDK `UIMessage` typed with Latch's metadata. It is the source of truth; everything else is a projection.

```ts
type AppMessage = UIMessage<AppMetadata, UIDataTypes, TOOLS>;

interface AppMetadata {
  visibility?: "user" | "internal" | "redacted"; // client-projection axis
  sendToModel?: boolean; // model-projection axis
  model?: string;
  usage?: {
    inputTokens?: number;
    outputTokens?: number;
    cacheReadTokens?: number;
    cacheWriteTokens?: number;
  };
  cost?: number;
  finishReason?: string;
  createdAt?: number;
  voice?: { durationMs: number };
}
```

The **two axes** are the whole mechanism:

| | `visibility` | `sendToModel` |
| --- | --- | --- |
| Governs | what the *user* sees | what the *model* sees |
| Applied by | `toClientMessages` | `toModelMessages` |

So a compaction summary or an injected instruction can reach the model without ever appearing in the conversation, and a redacted message can stay in the record while being invisible to both.

```ts
import { toClientMessages, toModelMessages } from "@intentface/latch-core";

const forUser = toClientMessages(stored); // drops internal + redacted
const forModel = await toModelMessages(stored, { tools }); // drops sendToModel: false
```

<Callout title="Pass the same tools to both">
`toModelMessages` wraps the SDK's `convertToModelMessages`, which consults each tool's `toModelOutput` to decide what a tool result looks like to the model. Pass the same tool set you passed to the agent, or replayed history won't match what the model saw live.
</Callout>

`toModelMessages` also, in order: cuts to the live context window at the compaction boundary, drops `sendToModel: false`, neutralizes audio file parts, and closes any dangling tool calls (a tool call with no result makes conversion throw).

## Compaction

A chat that never ends — a DM, a months-old thread — would grow its prompt forever. `runtime.compactChat` summarises the history and appends a **boundary marker** (`data-compaction`); from then on the model projection starts at that boundary and reads the summary instead of the turns it replaced.

```ts
const result = await runtime.compactChat({
  chatId,
  principal,
  focus: "keep the training plan",
});
// → { summary, compacted } | null
```

Stored history is untouched — compaction shrinks the *projection*, never the record. `null` means there was too little to compact. It throws `PendingApprovalError` if the last turn is paused awaiting a human.

## What a turn actually does

The order matters when you're debugging an option or an extension.

1. **Context**: your `context({ principal })` function builds it; every tool later receives it, plus `principal`, `params`, `messageContext`, `run` and `writer`, as `options.context`.
2. **Agent**: the agent function runs with `{ context, principal, params, messageContext }` and returns an `AgentConfig`; the chat's saved params are checked against the agent's schema first. Dynamic agents resolve here.
3. **Tools** — static `tools`, plus each `ToolSource` opened for this turn (connections/MCP), plus harness, sandbox, render and memory tools per the agent's flags. Sources are closed when the turn ends.
4. **Instructions** — the agent's `instructions`, plus the memory provider's compiled index block, plus the current date line.
5. **Model** — one `ToolLoopAgent` stream. `smoothStream`, `maxSteps`, `enabledTools` and reasoning options all apply here.
6. **Persist** — the user and assistant messages, the run row, and a usage row carrying the priced cost.

A gated tool call ends step 5 early with an `approval-requested` part; the run goes `awaiting_input` and waits for `applyApproval`.


---

# By hand

Source: https://intentface-latch-docs.vercel.app/docs/getting-started/by-hand

`createLatchApp` fills in every default. This page is the same backend assembled from its parts, so you can see what each default is and swap any of them.

## Tier 1 — a persisted runtime

Storage is the one option you can't leave out. `latch-drizzle` ships adapters for SQLite and Postgres, plus the migrations.

```ts title="latch.ts"
import { createRuntime, defineAgent } from "@intentface/latch-core";
import { createSqliteAdapter, migrate, schema } from "@intentface/latch-drizzle";
import { anthropic } from "@ai-sdk/anthropic";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";

interface Principal {
  orgId: string;
  userId?: string;
}

const client = createClient({ url: "file:local.db" });
const db = drizzle(client, { schema });

// `migrate` executes DDL, so it takes the libSQL client — not the drizzle db.
await migrate(client);

// The owner key is your isolation boundary — see /docs/guides/ownership-patterns.
const storage = createSqliteAdapter<Principal>(db, {
  owner: (p) => (p.userId ? [p.orgId, p.userId] : [p.orgId]),
});

// What tools work with, built from who is asking on every turn.
const context = async ({ principal }: { principal: Principal }) => ({ orgId: principal.orgId });

const agents = {
  assistant: defineAgent<Principal>(() => ({
    model: anthropic("claude-sonnet-4-6"),
    instructions: "You are a concise, helpful assistant.",
  })),
};

export const runtime = createRuntime<Principal, { orgId: string }>({ storage, context, agents });
```

Run a turn:

```ts
const response = await runtime.handleChat({
  agent: "assistant",
  principal: { orgId: "acme", userId: "u_1" },
  message: {
    id: crypto.randomUUID(),
    role: "user",
    parts: [{ type: "text", text: "Hello." }],
  },
});
```

`handleChat` returns a `Response` streaming AI SDK UI message parts — hand it straight back from your route, or read it yourself.

Tables are prefixed `latch_*`. Consume the package's migrations rather than hand-declaring the schema in your own migration folder.


## Tier 2 — make it durable

One option and one route. The runtime gets `durability`, and something has to tick:

```ts
import { Cron } from "croner";

export const runtime = createRuntime<Principal, { orgId: string }>({
  storage,
  context,
  agents,
  durability: { enabled: true },
  // Schedules need a cron evaluator; core ships none.
  cron: (expr, afterMs, timezone) =>
    new Cron(expr, { timezone: timezone ?? "UTC" }).nextRun(new Date(afterMs))?.getTime() ?? null,
});

// The whole operational surface. A platform cron hits this route; guard it.
export async function POST(request: Request) {
  assertCronSecret(request);
  return Response.json(await runtime.cron());
}
```

A run that dies mid-turn is now reaped on the next tick and resumed from its own log. On a long-lived process, `createIntervalScheduler` drives the same call in-process — see [Turn on crash recovery](/docs/guides/crash-recovery).

## Tier 0 — stateless

The same code with `createClient({ url: ":memory:" })`: a fresh database per process, nothing persisted past it. Useful for a serverless function that answers one turn, and for tests.

## Mount it

`createLatchHandler({ runtime, resolveContext })` gives you the Web-standard handler; [Hosting](/docs/guides/mount-the-handler) mounts it in Hono, Express, Fastify, NestJS or any framework that speaks `Request` and `Response`.

## Next

- [createRuntime](/docs/reference/create-runtime) and [Runtime methods](/docs/reference/runtime) — every option and operation
- [Hooks](/docs/reference/hooks) — why everything past tier 0 is a function you pass


---

# Getting Started

Source: https://intentface-latch-docs.vercel.app/docs/getting-started

Latch runs at three tiers. Each is additive: moving up adds an option and changes nothing you already wrote.

| Tier | Add | Infrastructure | You get |
| --- | --- | --- | --- |
| **0 — Stateless** | `latch-core` + `latch-drizzle` with an in-memory SQLite (`@libsql/client`) | none | `defineAgent` + context + tools + streaming, in any serverless function |
| **1 — Persistence** | a database file or Postgres | a DB | Conversations, runs, usage and cost persisted; run-to-completion in one invocation |
| **2 — Durability** | `durability: { enabled: true }` + a cron | the DB + a scheduled tick | Runs survive a crash: lease, heartbeat, reaper, resume-from-history |

<Cards>
  <Card href="/docs/getting-started/quickstart" title="Quickstart" description="createLatchApp: tier 2 with every default filled in, in one file. Start here." />
  <Card href="/docs/getting-started/by-hand" title="By hand" description="createRuntime piece by piece — when you want a different framework, no HTTP, or to see what the defaults are." />
  <Card href="/docs/getting-started/install" title="Install" description="Registry access and which packages add what." />
</Cards>


---

# Install

Source: https://intentface-latch-docs.vercel.app/docs/getting-started/install

The `@intentface/latch-*` packages are on the public npm registry. Nothing to configure — install and go.

## What to install

Start with two packages. Everything else is additive, and nothing else is required.

```bash
pnpm add @intentface/latch-core @intentface/latch-drizzle ai @ai-sdk/anthropic
```

`ai` is a **peer dependency** (`>=7.0.0`) — core deliberately doesn't pin your AI SDK version. Its only runtime dependency is `zod`.

Add the rest as you need the capability:

| Package | Adds |
| --- | --- |
| `latch-server` | a mountable HTTP surface — see [Hosting](/docs/guides/mount-the-handler) |
| `latch-mcp` | MCP connections, with an SSRF allowlist and vault-brokered auth |
| `latch-openapi` | connections from an OpenAPI spec |
| `latch-memory` | persistent agent memory across sessions (**experimental**) |
| `latch-channels` | Slack, Telegram and WhatsApp |
| `latch-otel` | OpenTelemetry tracing, one trace per run, to any backend (Langfuse preset included) |
| `latch-agent-builder` | agents defined as data, created from your UI |
| `latch-net` | SSRF blocklist and a rebinding-safe pinned `fetch` |

## Next

- [Quickstart](/docs/getting-started/quickstart) — the whole backend in one call
- [By hand](/docs/getting-started/by-hand) — `createRuntime` piece by piece, tier by tier


---

# Quickstart

Source: https://intentface-latch-docs.vercel.app/docs/getting-started/quickstart

You'll run a small server with one agent, talk to it, and see it remember the conversation.

## 1. Install

```bash
pnpm add @intentface/latch-core @intentface/latch-drizzle @intentface/latch-server \
  ai @ai-sdk/anthropic @libsql/client drizzle-orm hono @hono/node-server
export ANTHROPIC_API_KEY=sk-ant-…
```

## 2. One file

```ts title="server.ts"
import { serve } from "@hono/node-server";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { defineAgent } from "@intentface/latch-core";
import { createSqliteAdapter, migrate, schema } from "@intentface/latch-drizzle";
import { createLatchHonoApp } from "@intentface/latch-server/hono";

type Principal = { userId: string };

const client = createClient({ url: "file:./latch.db" });
await migrate(client);

const { app } = await createLatchHonoApp<Principal>({
  storage: createSqliteAdapter<Principal>(drizzle(client, { schema }), {
    owner: (p) => [p.userId], // each user sees only their own chats
  }),
  // Who is asking. A demo shortcut: in your app, read your real session here.
  resolveContext: async (req) => ({ userId: req.headers.get("x-user") ?? "demo" }),
  agents: (models) => ({
    assistant: defineAgent<Principal>({
      model: models.modelFor("claude-sonnet-4-6"),
      instructions: "You are a friendly, concise assistant.",
    }),
  }),
});

serve({ fetch: app.fetch, port: 8787 });
```

```bash
npx tsx server.ts
```

## 3. Talk to it

```bash
curl -N localhost:8787/agents/assistant/chat-1 -H 'content-type: application/json' \
  -d '{"text":"Hi! My name is Anna."}'

curl -N localhost:8787/agents/assistant/chat-1 -H 'content-type: application/json' \
  -d '{"text":"What is my name?"}'
```

The second answer knows your name. The conversation is saved in `latch.db`, together with what each answer cost, so it survives a restart.

The answer streams in the AI SDK's format, so a React page served from the same app can use `useChat` as is:

```tsx
const { messages, sendMessage } = useChat({
  transport: new DefaultChatTransport({ api: "/agents/assistant/chat-1" }),
});
```

## What you just got

That one file is already more than a demo:

- **Conversations are saved and scoped per user.** Two tabs can't scramble one chat.
- **Crash recovery is on.** Stop the server in the middle of an answer and start it again: the answer finishes on its own.
- **Costs are recorded** for every answer.

## Next

Each of these takes a few lines on top of this file:

- [Ask before risky actions](/docs/hard-parts/approvals): a person approves before a tool runs.
- [Your real users](/docs/hard-parts/access): replace the `x-user` header with your login, and decide who sees which chats.
- [Usage limits](/docs/hard-parts/limits): requests and tokens per user, per day.
- [Running on many servers](/docs/hard-parts/durability): what changes on Vercel or behind a load balancer.
- [Files and images](/docs/hard-parts/attachments): what happens when users attach them.


---

# An agent your customers offer their users

Source: https://intentface-latch-docs.vercel.app/docs/guides/agent-for-your-customers

You sell a helpdesk. Each of your customers puts an agent on their website for their own visitors. That's three layers: you, your customer (the tenant), and their visitors, who never sign up with you.

Every decision below comes down to one question: whose is this?

## Who is asking: tenant and visitor

Visitors don't have accounts in your app. The tenant's site vouches for them: it signs a short token with a key you issued, and your route checks it.

```ts
type Principal = { tenantId: string; visitorId: string };

async function principalFrom(req: Request): Promise<Principal | null> {
  const claims = await verifyVisitorToken(req.headers.get("authorization")); // signed by the tenant's site
  return claims ? { tenantId: claims.tenant, visitorId: claims.sub } : null;
}
```

Build the tenant id from the verified token, never from the request body. A visitor who can choose a tenant id can talk to another tenant's agent.

## Whose conversation: one visitor's

```ts
createPostgresAdapter<Principal>(db, { owner: (p) => [p.tenantId, p.visitorId] });
```

Each visitor sees only their own chats. Don't use `[p.tenantId]` here: every visitor of a tenant would share one owner, and any of them could list the others' conversations.

## What the agent knows: the tenant's setup

The context is built per turn from the tenant id: their name, their help articles, their own API key from the vault. The agent's instructions come from it.

```ts
const context = async ({ principal }: { principal: Principal }) => {
  const tenant = await tenants.get(principal.tenantId);
  return {
    tenant,
    articles: helpCenterFor(tenant.id), // search over this tenant's articles only
    crmKey: await vault.resolver(principal, "crm_api_key"),
  };
};
type Ctx = Awaited<ReturnType<typeof context>>;

const support = defineAgent<Principal, Ctx>(({ context }) => ({
  model: anthropic("claude-sonnet-4-6"),
  instructions: `You answer questions for visitors of ${context.tenant.name}. ${context.tenant.guidelines}`,
  tools: { search_articles, open_ticket },
}));
```

## What the visitor is looking at

The widget knows which page of the tenant's site the visitor is on. It attaches that to each message as a `data-context` part, so the agent can answer "how do I cancel this?" about the right product page, without the page going into the instructions:

```ts
sendMessage({
  parts: [
    { type: "text", text: "How do I cancel this?" },
    { type: "data-context", data: { page: location.pathname, product: currentProductSlug } },
  ],
});
```

It's a hint, not a permission: the agent still looks the product up through the tenant's own data.

## What it may spend: the tenant's plan

```ts
limits: {
  store: createPostgresLimitStore(db),
  limitsFor: async ({ principal }) => {
    const plan = await planOf(principal.tenantId);
    return [
      { key: `tenant:${principal.tenantId}`, windowMs: 30 * DAY_MS, max: { cost: plan.monthlyBudget }, label: "this site's monthly budget" },
      { key: `visitor:${principal.tenantId}:${principal.visitorId}`, windowMs: MINUTE_MS, max: { turns: 5 } },
    ];
  },
},
```

The tenant budget protects you; the visitor rate limit protects the tenant from one noisy visitor.

## The tenant's inbox

The tenant's support staff want to see every visitor's conversation. Latch's own lists are scoped to one owner, so keep a small index of your own (chat id, tenant, visitor, last activity), written when a run finishes, and open each chat with that visitor's principal and the staff member recorded as the actor. See [support access](/docs/guides/ownership-patterns#pattern-6--support-and-admin-access).

## Agents each tenant configures

If tenants edit their agent's instructions or tools in your dashboard, store them as [agents in a database](/docs/guides/agents-in-a-database), owned by the tenant. Every change is a version, and a tenant can roll back.


---

# Agents in a database

Source: https://intentface-latch-docs.vercel.app/docs/guides/agents-in-a-database

Agents that live in a database rather than in code come from `dynamicAgents`: `resolve` builds one by name for the caller, `list` shows the caller's agents in a picker. `@intentface/latch-drizzle` ships a store that does both, with the versions below:

```ts
import { createPostgresAgentBuilderStore } from "@intentface/latch-drizzle";

const store = createPostgresAgentBuilderStore<Principal>(db, {
  // Who owns an agent: the organization, or one person in it.
  owner: (p, scope) => (scope === "org" ? [p.orgId] : [p.orgId, p.userId]),
  modelFor: (id) => gateway(id), // a stored model id → a model
});

createRuntime({
  storage,
  context,
  agents, // code-declared
  dynamicAgents: {
    resolve: (name, principal, ctx) => store.resolve(name, principal, ctx),
    list: (principal) => store.list(principal),
  },
});
```

Your own table works too: implement `resolve(name, principal)` to return an agent config (or `undefined`) and `list(principal)` to return `{ name, title, description }` entries.

`runtime.listAgents(principal)` returns both sets, so a picker shows code-declared and user-created agents together. This is what makes "agents are data" work: one deploy serves any number of agents per tenant, and changing a prompt is a row update rather than a release.

## Versions, drafts and rollback

A prompt in a database is a prompt someone can overwrite. `@intentface/latch-drizzle`'s agent store keeps every published configuration, so editing one never destroys the one that was running.

**Publishing appends a version.** `upsert` writes the live record *and* appends a snapshot of it — unless the content is unchanged, in which case it appends nothing, so history stays a list of real changes rather than of save events. The whole record is snapshotted, not just the instructions: a version is therefore always a configuration that actually ran *together*, so restoring one never produces a combination that never existed — a prompt paired with a tool selection or model it was never written for. What a snapshot cannot promise is the world around it: it records the tools an agent selected, not the ones the host still provides, so a rollback can restore a reference to a connection or harness tool that has since gone away.

```ts
await store.upsert(principal, record, {
  author: userId,
  source: "editor",
  message: "Tightened the refund wording",
});

const history = await store.listVersions(principal, "support", "org");
// → [{ version: 3, author, message, changedFields: ["instructions"], published: true, createdAt }, …]
```

**Rolling back moves a pointer.** `activateVersion` makes an existing version live and appends *no* version, so flipping between two configurations can't inflate history. Because of that, who made what live and when is recorded separately:

```ts
await store.activateVersion(principal, "support", "org", 2);
await store.listActivations(principal, "support", "org");
// → [{ version: 2, actor, source: "activate", createdAt }, …]
```

The version list answers *what did this agent look like*; the activation log answers *what was it running on Tuesday*.

**Drafts are per editor, not per agent.** An owner key is an isolation boundary a whole org shares, so one draft per agent would let two admins silently overwrite each other's half-written prompt. A draft never affects a running agent, and carries the version it started from — compare that against the live `publishedVersion` before publishing and a stale publish becomes detectable rather than a silent revert.

```ts
await store.saveDraft(principal, "support", "org", record, baseVersion);
const draft = await store.getDraft(principal, "support", "org");
const others = await store.listDrafts(principal); // metadata only — a colleague's text isn't yours to read
```

**Candidates are shared changes you can test.** A candidate is a named, unpublished configuration that belongs to the agent, not to one person: everyone who can edit the agent sees it. It never affects the live agent, but it runs by reference, so you can try it before anyone else gets it. Closing one, discarded or published, keeps it, so results filed under it keep their name.

```ts
const c = await store.saveCandidate(principal, "support", "org", {
  title: "Shorter answers",
  record,
  baseVersion: 3, // the publish guard, as with drafts
});

await runtime.runAgent({ principal, agent: `support@${c.id}`, prompt: "Where is my order?" });

await store.closeCandidate(principal, "support", "org", c.id, { publishedAs: 4 });
```

**Runs record the version behind them.** The resolved config carries `configVersion`, the runtime stamps it on the run as `agentVersion` — when the run *opens*, so a run that errors or crashes still says what it ran — and a tracer can tag the trace with it (`@intentface/latch-otel` records `latch.agent.version`, and its Langfuse preset the tag `agent-version:<n>`). A past answer stays attributable to an exact configuration, and its results can be compared across versions.

<Callout title="Migrating an existing database">
`migrate()` (SQLite/libSQL) and `migratePostgres()` (Postgres) each backfill every existing agent as v1 and point its live row at it. Without that, the first publish after upgrading would create v1 from the *new* content and the prompt that had been running would be gone — the exact failure versioning exists to prevent. The backfill is idempotent, so re-running either is safe.
</Callout>


## Names in a picker

`AgentMeta` (`title`, `description`) is display-only and surfaced by `listAgents`. Set it wherever a human picks an agent from a list.


---

# Gate a tool behind approval

Source: https://intentface-latch-docs.vercel.app/docs/guides/approvals

## Background

An agent that can act needs a way to *not* act until a human agrees. In the AI SDK a tool marked `needsApproval` pauses the loop, but the pending approval lives in the process that is waiting — a websocket, a held request, a function that must not time out. Latch stores the pending approval as a part of the conversation instead. Nothing is held open; the decision can arrive tomorrow, from another device, after a deploy, and the run resumes from history.

This recipe gates one tool, decides it both ways, and shows the pause surviving a restart.

## Mark the tool

```ts
import { defineAgent } from "@intentface/latch-core";
import { tool } from "ai";
import { z } from "zod";

const support = defineAgent<Principal>({
  model,
  instructions: "You help with refunds. Confirm the amount before refunding.",
  tools: {
    refund: tool({
      description: "Refund a customer.",
      inputSchema: z.object({ customerId: z.string(), amountCents: z.number().int().positive() }),
      execute: async (input) => payments.refund(input),
    }),
  },
  toolApproval: { refund: "user-approval" },
});
```

Ask it to refund someone. The stream ends with an assistant message whose last part is in `state: "approval-requested"`, carrying `approval.id`. The tool has **not** run; the run's status is `awaiting_input`.

## Render the card and decide

Your client renders that part as an approve/deny card and posts the decision:

```ts
// POST /agents/support/:chatId/approvals   — or runtime.applyApproval(...)
{ "decisions": [{ "approvalId": "<approval.id>", "approved": true }] }
```

The response is a **continuation stream** of the same turn: it reuses the assistant message's id and streams only the new parts, so seed your reader with a clone of that message first ([The chat UI](/docs/guides/chat-ui#the-continuation-protocol)). The tool executes now and the agent finishes its answer.

## Deny it

Send `approved: false`. The tool never runs; the model is told and says what it will do instead. A denial is an answer, not a cancel — the conversation keeps going.

## Restart between the pause and the decision

Stop the server after the first request. Start it again. Post the decision. It works, because the pending approval is a row, not a waiting process. This is the whole reason for the design.

## Let the user walk away from it

If they type a new message instead of deciding, that message *is* the decision: the gate closes as declined and the new message proceeds. Stop rendering the card as live once they send something.

## Full example

```ts
import { createRuntime, defineAgent } from "@intentface/latch-core";
import { createSqliteAdapter, migrate, schema } from "@intentface/latch-drizzle";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { tool } from "ai";
import { z } from "zod";

interface Principal { orgId: string; userId?: string }

const client = createClient({ url: "file:./latch.db" });
await migrate(client);
const storage = createSqliteAdapter<Principal>(drizzle(client, { schema }), {
  owner: (p) => (p.userId ? [p.orgId, p.userId] : [p.orgId]),
});

const runtime = createRuntime<Principal, { orgId: string }>({
  storage,
  context: ({ principal }) => ({ orgId: principal.orgId }),
  agents: {
    support: defineAgent({
      model,
      instructions: "You help with refunds. Confirm the amount before refunding.",
      tools: {
        refund: tool({
          description: "Refund a customer.",
          inputSchema: z.object({ customerId: z.string(), amountCents: z.number().int().positive() }),
          execute: async (input) => payments.refund(input),
        }),
      },
      toolApproval: { refund: "user-approval" },
    }),
  },
});

// Turn 1: the model proposes `refund`; the stream ends awaiting a decision.
const first = await runtime.handleChat({ agent: "support", chatId: "c1", principal, message: userMessage });
// … render the approval-requested part …

// Turn 2 (any time later, any process): decide. The tool runs, the turn continues.
const continuation = await runtime.applyApproval({
  chatId: "c1",
  principal,
  decisions: [{ approvalId, approved: true }],
});
```

## Pitfalls already handled

- **The card is stale** (answered in another tab, or the turn moved on) — a tapped approval is checked against fresh history and a card for a vanished approval is retired, never pushed through.
- **Two gated calls in one step** — the first decision returns an empty `200`; the other card stays live for its own decision.
- **Who may click** — in a shared Slack thread only the person whose turn raised the gate, because the tool runs with their credentials; buttons are signed and bound to that actor.
- **A gate three subagents deep** — bubbles up to the human at the top as a part on the parent's message, and the decision routes back down.
- **The subagent stops at a second gate after you approve the first** — the decision's response streams the new card into the same message, alongside the subagent's progress while it resumes.
- **The parent tries to delegate again while it waits** — its turn stops at the paused delegation, so one decision means one card.
- **Nobody is there** — a scheduled run reaching a gate parks: `status: "awaiting_input"`, `trigger: "schedule"`. `runAgent` defaults to `blockGated: true`, stubbing gated tools so an unattended test has no side effects.
- **Which tools need a gate** — MCP tools marked destructive or non-read-only arrive gated from their own annotations.
- **OAuth connects** — a `connect_<name>` call is an approval whose resolution is a redirect, with the principal pinned when the flow starts.
- **Two clicks on one card** — the second meets the one-active-run-per-chat rule and is told the chat is busy.

## Run it

```bash
pnpm --filter @intentface/latch-example smoke:approval            # gate → await → approve runs / deny blocks
pnpm --filter @intentface/latch-example smoke:subagent-approval   # a sub gate bubbles to the parent
pnpm --filter @intentface/latch-example smoke:oauth               # connect_<name> through the OAuth flow
```

Mock model, no API key; sources in `apps/example/src`.

## Tools the browser answers

A tool with no `execute` (an `askUser`-style questionnaire) pauses the same way and resumes with `applyToolResult`:

```ts
await runtime.applyToolResult({ chatId, principal, toolCallId, output });
```

If other client tool calls in the same step still need results, this returns an empty `200` and waits for them.

## Approvals inside subagents

A gated tool *inside* a subagent suspends the parent turn and surfaces to the same human — it does not auto-approve out of sight. It arrives as a `data-subagent-approval` part on the parent's message, the parent's tool loop stops there as it would at a gate of its own, and the decision goes through the same `applyApproval`.

That decision resumes the subagent first, and its response streams what the resume does, continuing the paused message: the delegation's live progress (the `data-subagent-progress` part `spawn_agent` already streamed, updated in place), then either a **fresh `data-subagent-approval`** when the subagent stops at another gate, or the delegation's result followed by the parent's continuation. Render the response like any turn's stream and a second gate shows up as a second card, with no reload. A failure during the resume arrives as an `error` chunk in that stream.

## Runs with nobody there

`runAgent` runs a turn with no client consuming the stream, so there is no one to ask. It defaults to **`blockGated: true`**: approval-gated tools are stubbed rather than executed, and the agent is told they were unavailable. That's the safe default — an unattended turn can't quietly perform the very actions you gated.

```ts
// Gated tools are stubbed — the agent works around them.
await runtime.runAgent({ principal, agent: "reporter", prompt });

// Opt in to executing them unattended. You are asserting no human is needed.
await runtime.runAgent({ principal, agent: "reporter", prompt, blockGated: false });
```

Set `blockGated: false` deliberately, for agents whose gated tools are safe to run without a human — a scheduled report that must actually send. It is the one place where "autonomous" and "approved" are the same decision.

<Callout type="warn" title="Reversibility is architectural">
An agent can only offer undo if the harness kept a checkpoint. "Approve the irreversible, make the rest reversible" is good design advice that depends on infrastructure you have to have built first — the log is what makes it possible here.
</Callout>

## Next

- [The chat UI](/docs/guides/chat-ui) — the continuation protocol your UI has to follow
- [Run agents on a schedule](/docs/guides/schedules#when-a-fire-stops-for-a-human) — a scheduled run that stops for a person
- [defineAgent](/docs/reference/define-agent) — `toolApproval` as a function, with reasons


---

# Chat apps in depth

Source: https://intentface-latch-docs.vercel.app/docs/guides/channels

A channel puts an agent where people already talk. Users message a bot; the bot answers as the agent, with approvals, questionnaires and commands rendered natively. Identity, credentials and dedupe stay in your host, per Latch's isolation model; the package owns the protocol.

**What it makes possible.** One agent reachable from the web app, Slack, Telegram and WhatsApp; account linking that survives a forwarded link; scheduled briefs delivered into the same conversation the user chats in; a fourth platform in a day, not a rewrite.

## The kernel

Telegram and WhatsApp are two `Surface`s over one **turn kernel** (`createTurnKernel` in `@intentface/latch-channels`). The kernel owns everything that used to be copied per platform: dedupe, the identity gate with stash and post-link replay, thread epochs (`/new`), the slash commands, questionnaires with fenced state, media folding (files inline, voice transcribed), posting the answer **once at finish** (split past the platform cap, with a typing hint meanwhile), approvals and button taps back into the run. A `Surface` is pure protocol — send text, a link, an approval, a question; edit or retire a card; download a file.

```ts
import { telegramChannel } from "@intentface/latch-channels/telegram";

const channel = telegramChannel<Principal>({
  agent, botRef, botToken, webhookSecret,
  runtime: { handleChat, applyApproval, applyToolResult, compactChat, loadHistory },
  authorizeUser, seen, stashPending, askState, thread, waitUntil,
});
return channel.handleRequest(request); // one webhook route per bot
```

Slack keeps its own adapter — its surface is threads, native streaming and task cards — and shares the primitives that fit: the auth result, dedupe claims, signed actor-bound approval buttons.

## What you supply

The persistence: which agent a bot serves, the identity links, held messages, delivery dedup. One set of tables can serve every kind of channel. One turn per chat is **not** yours to guard: core's storage adapter refuses a second live run and the channels render that as "still working". On a custom adapter without that invariant, wire the optional `claimTurn` / `releaseTurn` lease.

## Where this gets hard

- **Platforms redeliver.** Telegram and Meta resend until they see a 2xx; Slack sends one message as two events. Every delivery, message and click is claimed once in a TTL'd table — an in-process set does not survive serverless. Everything happens *after* the 200, because Slack's three-second ACK deadline is what caused the retries.
- **The bot answers itself.** Some bot posts carry no bot id. The bot's own user id is the loop guard, not a heuristic on message shape.
- **Linking lands in the wrong browser.** The signed link identifies the platform user; the session identifies the Latch user; they can differ. A GET only renders a confirmation showing both, the POST is CSRF-bound to that pair, a link claimed by another account is refused, and tokens are domain-separated and length-prefixed so an id with a colon cannot forge another channel's token.
- **A removed employee still has a linked account.** Membership is re-checked on every inbound turn and every scheduled delivery.
- **Two users named their agent "support".** Bindings pin the agent's scope, so a personal agent never shadows an org one.
- **Streaming that isn't.** Never edit a message to mimic streaming. Slack streams natively or posts once at finish; Telegram posts at finish with a typing hint and falls back to plain text when the model's markdown breaks the entity parser.
- **Double taps.** Questionnaire state carries a revision fence, so a repeated tap cannot advance twice; a final tap refused because the chat is busy keeps its answers for the retry.

## Next

- [Guide: a scheduled brief delivered to a channel](/docs/guides/scheduled-brief)
- [Reference: packages — latch-channels](/docs/reference/packages#latch-channels)


---

# The chat UI

Source: https://intentface-latch-docs.vercel.app/docs/guides/chat-ui

Latch ships no browser package (yet). The handler speaks the AI SDK's UIMessage
SSE protocol, so any AI SDK-compatible chat client works for the happy path —
but four things are **your client's job**, and every one of them looks like a
Latch bug when it's missing. This page exists so you don't discover them in
production.

## Rendering: assistant output is markdown

Models emit markdown. If your message component renders assistant text as a
plain string, every table arrives as literal pipes and every heading as `##` —
and nothing errors. User input and assistant output need **different**
treatment: render the user's text verbatim, and run the assistant's through a
markdown renderer.

If you build on `@intentface/chat`, note that `Message.Text` takes a plain
string by design (chip segmentation operates on it) — assistant turns need a
sibling markdown renderer, as its reference example shows.

## Approvals: a paused turn is message state

A gated tool call ends the stream with the assistant message carrying a part in
`state: "approval-requested"`. Nothing else will happen until someone decides.

The simplest client uses the AI SDK's own approval flow. `useChat` marks the
card answered and sends the message back once every card in the step has an
answer; Latch reads only the decisions from it (never the inputs or anything
else in the client's copy) and streams the continuation into the same message:

```tsx
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";

const { messages, sendMessage, addToolApprovalResponse } = useChat({
  id: chatId,
  transport: new DefaultChatTransport({
    api: `/agents/${agent}/${chatId}`,
    // Latch keeps the history: send only the newest message.
    prepareSendMessagesRequest: ({ messages }) => ({ body: { message: messages.at(-1) } }),
  }),
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});

// On each card:
<button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: true })}>Approve</button>
```

Decisions that come from elsewhere (a Slack button, another device, a card
rendered outside `useChat`) go to `POST /agents/:agent/:id/approvals` instead. When the decision resumes the turn, the
response is a **continuation stream** of the same turn. When the same step has
*other* gated calls still undecided, the turn cannot resume yet — the response
is an **empty 200**, and the remaining cards stay live for their own
decisions. Check the response's `Content-Type` for `text/event-stream` before
feeding it to your stream reader; an empty body parsed as SSE fails
confusingly.

Denying is a real answer, not a cancel: the turn resumes and the agent says
what it will do instead. A user typing a new message instead of deciding also
resolves the gate (declined as superseded) — so stop rendering the card as
live once the user sends something.

## The continuation protocol

Whenever a paused turn resumes — an approval decision, a client-handled tool
result, or the OAuth flow below — the continuation stream has two properties
that are easy to miss and produce confusing UIs when missed:

1. **It reuses the last assistant message's id and streams only the new
   parts.** Seed your stream reader with a clone of that message before
   feeding it the continuation, or the answer renders as a second, half-empty
   message.
2. **Mirror the decision locally first.** The decision is saved before the
   continuation starts, but the stream won't show the card's new state until
   the tool's result arrives — mirror it in local state at click time, or the
   card sits on screen for the whole run. Reconcile against server history
   (`GET /chats/:id`) when the stream ends.

## OAuth: the callback redirects — resuming is yours

When an agent needs a connection the user hasn't authorized, it calls the
synthetic `connect_<name>` tool, which parks the turn like any approval. The
user authorizes at `GET /connections/:name/authorize`; the IdP redirects back
to the callback, which finishes the token exchange and **redirects to
`returnTo` immediately** — it does not resume the turn (a browser must not
hang on a blank page for a whole run).

On arrival back at your chat page:

1. Strip the `?connected=<name>` param.
2. Find the pending `connect_<name>` approval part in the chat's history.
3. Submit it approved to `POST /agents/:agent/:id/approvals`.
4. Feed the returned continuation into your stream reader — seeded as above.

A `?error=` param instead (`flow_expired`, `missing_code`, an exchange
failure) means the flow didn't finish: leave the gate rendered so the user can
retry Connect, or deny it to let the agent route around the connection.


---

# Connect other services

Source: https://intentface-latch-docs.vercel.app/docs/guides/connections

A connection is a named external service whose tools an agent may use. Declare `connections: ["linear"]` on an agent and every turn gets that service's tools, namespaced, for the tenant the turn runs as — plus a gated `connect_linear` step for a user who has not authorised it yet. The point is what you don't write: no per-tool wiring, no token handling in agent code, no per-tenant plumbing.

**What it makes possible.** An agent with Linear, Gmail and Airtable tools by listing three names; user-completed OAuth once, then tools that just work; mutating third-party tools that arrive approval-gated from their own annotations; a per-tenant SSRF allowlist; one org's grant that can never serve another's tool.

## Connections — MCP and OpenAPI tools

A connection registry turns an agent's `connections: ["linear", "airtable"]` into a per-turn tool source: each authorized connection's tools namespaced `<name>_<tool>`, plus a gated `connect_<name>` tool for any that still need OAuth.

```ts
createRuntime({
  // …
  connections: {
    async open(principal, names) {
      /* → OpenToolSet */
    },
  },
});
```

`@intentface/latch-mcp` implements this over MCP servers and adds two guards core doesn't have: a **per-tenant SSRF allowlist** (an unlisted host throws before a socket opens) and **vault-brokered auth** (bearer tokens resolved per tenant, never in agent config). `@intentface/latch-openapi` does the same for OpenAPI specs.

Helpers for composing sources by hand:

```ts
import { mergeToolSources, mergeConnections } from "@intentface/latch-core";
```

<Callout title="Approval policy travels with the tools">
An MCP tool's own annotations (`destructiveHint`, `readOnlyHint`) drive whether it needs approval, so a mutating third-party tool arrives gated without you enumerating it. For servers that ship no annotations, `latch-mcp` offers a tool-name verb heuristic.
</Callout>

## Vault: secrets the model never sees

Connections keep each user's tokens in the vault, and your own `bearer` connections read keys from it:

```ts
import { createVault } from "@intentface/latch-core";

const vault = createVault<Principal>({ store, masterKey });

createMcpConnections({ vault, secrets: vault.resolver /* … */ });
```

Setting it up, whose secret is whose, putting keys in from your settings page, and using them in your own tools: [Secrets (the vault)](/docs/concepts/secrets).

## OAuth, end to end

1. A connection the user hasn't authorised exposes none of its tools yet — only a gated `connect_<name>` tool. When the agent calls it, the turn parks like an approval ([Approvals](/docs/hard-parts/approvals)).
2. The client sends the user to `GET /connections/:name/authorize`. The **principal is pinned into the flow state here** — never re-resolved from the callback request, so the grant cannot land on whoever completes the redirect.
3. The provider redirects to the callback; it finishes the exchange, stores the token in the vault under that principal, and redirects to `returnTo`.
4. The client approves the parked `connect_<name>` and the turn continues with the tools available. [The chat UI](/docs/guides/chat-ui#oauth-the-callback-redirects--resuming-is-yours) has the exact steps.

## Next

- [Guide: gate a tool behind approval](/docs/guides/approvals) — the same pause the connect step uses
- [Reference: packages — latch-mcp, latch-openapi](/docs/reference/packages#latch-mcp)
- [Who owns a conversation](/docs/guides/ownership-patterns#how-a-principal-reaches-a-tool) — how a principal reaches a tool and its credential


---

# Turn on crash recovery

Source: https://intentface-latch-docs.vercel.app/docs/guides/crash-recovery

A turn that outlives its invocation needs to survive the process dying. Latch does this with a lease, a heartbeat and a reaper — no workflow engine, no queue service, no worker that must stay alive.

```ts
createRuntime({
  storage,
  context,
  agents,
  durability: { enabled: true },
});
```

What happens:

1. **Claim** — the run is claimed with a lease (`owner`, `fencingToken`, expiry).
2. **Heartbeat** — the holder extends the lease while it works.
3. **Die** — the process is killed mid-turn. Nothing is lost: every step so far is already in the log.
4. **Reap** — a later `sweep()` (or `cron()`) finds runs whose lease has expired, marks them `errored`, clears the owner and bumps the fencing token (invalidating any zombie holder).
5. **Resume** — `resume(runId)` reclaims the run under a fresh lease, then reads the tail of persisted history to decide what it needs. A turn whose last step called no tool had already finished, so its row is reconciled (`completed`, or `awaiting_input` when a gate is pending) with no model call. Anything else re-runs from history and completes server-side.

Point any cron at `cron()` — it fires due schedules, then reaps and resumes in one call:

```ts title="The whole operational wiring"
await runtime.cron({ maxAttempts: 5 });
// → { due: {…}, reaped: RunRecord[], resumed: RunOutcome[], errors: string[] }
```

`maxAttempts` (default 5) is the poison-run cap: a run that keeps dying stops being retried instead of looping forever. `sweep()` is the reap-and-resume half on its own, and `resume(runId)` reclaims and re-runs one run — for when you want the pieces separately.

On a long-lived Node process, use the in-process scheduler instead of an external cron:

```ts
import { createIntervalScheduler } from "@intentface/latch-core";

const scheduler = createIntervalScheduler({
  intervalMs: 30_000,
  onTick: () => runtime.cron(),
  onError: (error) => log.warn({ error }, "sweep failed"),
});
scheduler.start();
```

Ticks never overlap — a slow tick skips the next fire rather than stacking — and the timer is `unref`'d so it won't hold the process open on its own.

<Callout title="Why this is safe on every node at once">
With a storage adapter that keeps the [storage contract](/docs/reference/storage) (atomic reaping and claiming, fenced writes), as the built-in ones do, firing `sweep()` on all nodes simultaneously is fine: only one node wins each run, and fencing tokens stop a revived stale worker from overwriting newer state. An adapter that ignores the fence accepts a stale worker's writes, over the new worker's, until the stale worker notices: it stops once its heartbeats have failed for a full lease.
</Callout>


---

# Deploy on Vercel

Source: https://intentface-latch-docs.vercel.app/docs/guides/deploy-on-vercel

Serverless changes three things: a function can be stopped at any moment, nothing runs between requests, and every request may land on a fresh instance. Latch is built for that. This guide sets it up.

## 1. A database with transactions

Use Postgres (Neon, Supabase, or any other) through a driver that supports transactions: `pg`, `postgres`, or Neon's `neon-serverless` pool. Neon's HTTP driver (`neon-http`) can't run transactions, and the adapter needs them.

Run the migrations once per deploy, from a script, not on every request:

```ts title="scripts/migrate.ts"
import { migratePostgres } from "@intentface/latch-drizzle";
await migratePostgres(db); // creates and updates the latch_* tables
```

## 2. The runtime

One module, imported by every route. Creating the runtime is cheap and needs no connection, so a fresh instance pays nothing for it.

```ts title="lib/latch.ts"
import { createRuntime } from "@intentface/latch-core";
import { createPostgresAdapter, createPostgresLimitStore } from "@intentface/latch-drizzle";
import { createCronerEvaluator } from "@intentface/latch-server";

export const runtime = createRuntime<Principal>({
  storage: createPostgresAdapter(db, { owner: (p) => [p.orgId, p.userId] }),
  context,
  agents,
  durability: { enabled: true }, // answers survive a stopped function
  cron: createCronerEvaluator(), // recurring schedules
  limits: { store: createPostgresLimitStore(db), limitsFor },
  reconstructPrincipal: async (identity) => {
    const p = identity as Principal;
    return (await isActiveMember(p)) ? p : null;
  },
});
```

## 3. The chat route

```ts title="app/api/chat/route.ts"
export const maxDuration = 300; // seconds: long enough for your longest answer

export async function POST(req: Request) {
  const user = await requireUser(req);
  const { id, message } = await req.json();
  return runtime.handleChat({ agent: "assistant", chatId: id, principal: { orgId: user.orgId, userId: user.id }, message });
}
```

An answer that runs past `maxDuration` isn't lost: the function stops, its lease runs out, and the next cron tick finishes the answer on another instance.

## 4. The cron

Vercel Cron calls a URL with `GET` and your `CRON_SECRET` as a bearer token. One small route turns that into `runtime.cron()`, which fires due schedules and recovers interrupted answers:

```ts title="app/api/cron/route.ts"
export const maxDuration = 300;

export async function GET(req: Request) {
  const secret = process.env.CRON_SECRET;
  // Unset, the comparison would accept "Bearer undefined".
  if (!secret || req.headers.get("authorization") !== `Bearer ${secret}`) {
    return new Response("Forbidden", { status: 403 });
  }
  const result = await runtime.cron();
  if (result.errors.length) console.error("[latch cron]", result.errors);
  return Response.json({ fired: result.due.fired, resumed: result.resumed.length });
}
```

```json title="vercel.json"
{ "crons": [{ "path": "/api/cron", "schedule": "* * * * *" }] }
```

Set `CRON_SECRET` in the project's environment variables; Vercel sends it automatically.

If you mount [the handler](/docs/guides/mount-the-handler) instead of writing your own routes, you don't need this one: its `cron/run` route accepts Vercel's request. Pass `cronSecret: process.env.CRON_SECRET` (`createLatchApp` reads it on its own) and point `vercel.json` at `/api/latch/cron/run`.

<Callout title="How often your plan runs crons">
Every-minute crons need a plan that allows them. With a less frequent cron, schedules fire late and crashed answers take longer to recover, but nothing is lost or run twice.
</Callout>

## Checklist

- `DATABASE_URL` points at a driver with transactions.
- Migrations run on deploy.
- `maxDuration` is set on the chat and cron routes.
- `CRON_SECRET` is set, and `vercel.json` has the cron.
- Model provider keys are in the environment.


---

# Guides

Source: https://intentface-latch-docs.vercel.app/docs/guides

Each guide does one job. The longer ones follow the same shape: the problem, one step at a time, a full example, and a command that runs it on a mock model, no API key needed.

## Build

<Cards>
  <Card href="/docs/guides/approvals" title="Gate a tool behind approval" description="Pause at a risky tool, decide from any device, continue." />
  <Card href="/docs/guides/schedules" title="Run agents on a schedule" description="Recurring and one-off runs, and what happens when one stops for a person." />
  <Card href="/docs/guides/scheduled-brief" title="Deliver a scheduled brief to a channel" description="A timed run that lands in the user's own Slack, Telegram or WhatsApp thread." />
  <Card href="/docs/guides/ownership-patterns" title="Who owns a conversation" description="Private, shared, both, a database per customer, support access." />
  <Card href="/docs/guides/agent-for-your-customers" title="An agent your customers offer their users" description="Three layers of people: you, your customers, and their visitors." />
  <Card href="/docs/guides/ownership-patterns#pattern-3--personal-and-shared-in-one-app" title="A team agent with shared chats" description="Private assistants and team-wide conversations, side by side." />
  <Card href="/docs/guides/plans-and-limits" title="Plans and limits" description="Caps on requests, tokens and cost, per team, member, agent or plan." />
  <Card href="/docs/guides/connections" title="Connect other services" description="MCP and OpenAPI tools, signing in with OAuth, and secrets in the vault." />
  <Card href="/docs/guides/agents-in-a-database" title="Agents in a database" description="Agents your users create, with versions and rollback." />
  <Card href="/docs/guides/write-an-extension" title="Write an extension" description="Every moment of a run you can hook into, and what each can change." />
  <Card href="/docs/guides/channels" title="Chat apps in depth" description="How Slack, Telegram and WhatsApp share one core." />
</Cards>

## Ship

<Cards>
  <Card href="/docs/guides/mount-the-handler" title="Mount in your framework" description="Next.js, Hono, Express, Fastify, Bun, Deno and more." />
  <Card href="/docs/guides/chat-ui" title="The chat UI" description="Streaming, approval cards, and finishing a sign-in from the browser." />
  <Card href="/docs/guides/crash-recovery" title="Turn on crash recovery" description="What to configure so answers survive a dying server." />
  <Card href="/docs/guides/deploy-on-vercel" title="Deploy on Vercel" description="Postgres, crash recovery, schedules and limits on serverless functions." />
  <Card href="/docs/guides/storage-adapter" title="Write your own storage adapter" description="Your schema or database, with the same guarantees, proven by tests." />
</Cards>


---

# Mount in your framework

Source: https://intentface-latch-docs.vercel.app/docs/guides/mount-the-handler

<Callout title="Shortest path">
`createLatchApp({ storage, agents, resolveContext })` wires the runtime with every default — durability, cron, model catalog, harness tools — and returns `{ runtime, handler, models, stop }`, the `handler` being the one described below, already mounted over that runtime. `createLatchHonoApp` returns the same plus `app`. The rest of this page is for when you want to assemble the pieces yourself.
</Callout>

`@intentface/latch-server` gives you the whole runtime as **one handler**:

```ts
type LatchHandler = (request: Request) => Promise<Response | undefined>
```

`undefined` means "not one of my routes" — hand the request back to your host. That single signature is why the same code mounts everywhere.

```ts title="latch-handler.ts"
import { createLatchHandler } from "@intentface/latch-server";
import { runtime } from "./latch";

export const latch = createLatchHandler({
  runtime,
  basePath: "/api/latch",
  // Your existing auth, returning your Principal. Null → 401.
  resolveContext: async (request) => {
    const session = await auth.getSession(request);
    return session ? { orgId: session.orgId, userId: session.userId } : null;
  },
});
```

<Callout title="The routes are a convenience, not the contract">
The real contract is the [Runtime methods](/docs/reference/runtime). This handler is a
pre-wired HTTP surface over them — you can ignore it and call `handleChat`,
`sweep` or `schedule` from your own routes, a queue consumer, or a CLI.
</Callout>

## Web-standard hosts

Anything that already speaks `Request`/`Response` needs no adapter.

<Tabs items={['Hono', 'TanStack Start', 'Next.js', 'Bun', 'Deno']}>

<Tab value="Hono">
`latch-server` ships a Hono adapter on a subpath, so `hono` stays an optional peer:

```ts
import { Hono } from "hono";
import { createLatchHono } from "@intentface/latch-server/hono";

const app = new Hono();

// Your middleware still applies — auth, CORS, logging.
app.route("/api/latch", createLatchHono({ runtime, resolveContext }));

export default app;
```

Or mount the bare handler yourself if you want to control the fallthrough:

```ts
app.all("/api/latch/*", async (c) => (await latch(c.req.raw)) ?? c.notFound());
```
</Tab>

<Tab value="TanStack Start">
```ts title="src/routes/api/latch.$.ts"
import { createServerFileRoute } from "@tanstack/react-start/server";
import { latch } from "~/lib/latch-handler";

const handle = async ({ request }: { request: Request }) =>
  (await latch(request)) ?? new Response("Not found", { status: 404 });

export const ServerRoute = createServerFileRoute("/api/latch/$").methods({
  GET: handle,
  POST: handle,
  DELETE: handle,
});
```
</Tab>

<Tab value="Next.js">
```ts title="app/api/latch/[...path]/route.ts"
import { latch } from "@/lib/latch-handler";

const handle = async (request: Request) =>
  (await latch(request)) ?? new Response("Not found", { status: 404 });

export const GET = handle;
export const POST = handle;
export const DELETE = handle;
```

Streaming turns need a Node runtime, not Edge, if your storage adapter uses a Node driver.
</Tab>

<Tab value="Bun">
```ts
Bun.serve({
  port: 3000,
  fetch: async (request) => (await latch(request)) ?? new Response("Not found", { status: 404 }),
});
```
</Tab>

<Tab value="Deno">
```ts
Deno.serve(async (request) =>
  (await latch(request)) ?? new Response("Not found", { status: 404 }),
);
```
</Tab>

</Tabs>

## Node frameworks

Express, Fastify and NestJS predate `Request`/`Response`, so they go through `toNodeHandler` from `@intentface/latch-server/node`, which turns Node's `req`/`res` into the Web objects and back. Request bodies and answers stream through it; it resolves `false` for a path that isn't a Latch route, so your app can carry on.

```ts title="latch-handler.ts"
import { createLatchHandler } from "@intentface/latch-server";
import { toNodeHandler } from "@intentface/latch-server/node";

export const handle = toNodeHandler(createLatchHandler({ runtime, resolveContext, basePath: "/api/latch" }));
```

<Callout type="warn" title="Do not body-parse the Latch route">
`express.json()`, Fastify's default parser and NestJS's global pipes all consume
the request stream. If one runs first, `toNodeHandler` sends Latch what it left on
`req.body`, but uploads no longer stream, and a parser that keeps nothing leaves
every turn blank. Mount the parser *after* the Latch route, or exclude
the path.
</Callout>

<Callout type="warn" title="Do not buffer the response">
Turns stream. Anything that collects the body before writing it — a compression
middleware without flush, `res.json(await response.text())` — turns a live
stream into one late blob, and the UI shows nothing until the turn ends.
</Callout>

<Tabs items={['Express', 'Fastify', 'NestJS']}>

<Tab value="Express">
```ts
import express from "express";
import { handle } from "./latch-handler";

const app = express();
// Before any body parser. Express 5 requires a NAMED wildcard — a bare
// "/api/latch/*" throws at registration. On Express 4 use "/api/latch/*".
app.all("/api/latch/*splat", async (req, res, next) => {
  if (!(await handle(req, res))) next();
});

app.use(express.json()); // the rest of your app
app.listen(3000);
```

Express 5 moved to `path-to-regexp` v8, which dropped anonymous `*`. Mounting
with `app.use("/api/latch", …)` also works and is version-agnostic — but note
`req.url` is then stripped of the mount prefix, so pass the same prefix as
`basePath` or the handler won't recognise its own routes.
</Tab>

<Tab value="Fastify">
Fastify parses JSON before your route runs, and a catch-all content-type
parser can't override its built-in one. So give the Latch routes a plugin of
their own without Fastify's parsers: the body then streams. Pass the raw Node
objects, and `request.body` too, which covers a body Fastify did parse.

```ts
import Fastify from "fastify";
import { handle } from "./latch-handler";

const app = Fastify();

app.register(async (latch) => {
  // Only inside this plugin: let every body through unread, JSON included.
  latch.removeAllContentTypeParsers();
  latch.addContentTypeParser("*", (_req, payload, done) => done(null, payload));

  latch.all("/api/latch/*", async (request, reply) => {
    reply.hijack(); // we own the raw socket from here
    if (!(await handle(request.raw, reply.raw, request.body))) {
      reply.raw.statusCode = 404;
      reply.raw.end();
    }
  });
});

await app.listen({ port: 3000 });
```
</Tab>

<Tab value="NestJS">
The same handler applies — reach for the underlying request and skip Nest's
pipes on this route. This is NestJS on Express; on Fastify, `@Req()` and
`@Res()` are Fastify's objects, so pass `req.raw` and `res.raw` to `handle`:

```ts title="latch.controller.ts"
import { All, Controller, Next, Req, Res } from "@nestjs/common";
import type { IncomingMessage, ServerResponse } from "node:http";
import { handle } from "./latch-handler";

@Controller("api/latch")
export class LatchController {
  // Named wildcard: Nest 11 runs Express 5, where a bare "*" is invalid.
  @All("*splat")
  async proxy(
    @Req() req: IncomingMessage,
    @Res() res: ServerResponse,
    @Next() next: () => void,
  ): Promise<void> {
    if (!(await handle(req, res))) next();
  }
}
```

Using `@Res()` opts this route out of Nest's response handling, which is what
you want — Nest must not serialize a stream. Also exclude the path from
`bodyParser` in `main.ts` (`NestFactory.create(AppModule, { bodyParser: false })`
and apply parsers per-module, or `app.use(json({ ... }))` scoped to other paths).
</Tab>

</Tabs>

## What the handler exposes

All paths are relative to `basePath`.

| Method | Path | Does |
| --- | --- | --- |
| `GET` | `agents` | agents available to the caller, for a picker |
| `POST` | `agents/:agent/:chatId` | run a turn — returns the stream |
| `POST` | `agents/:agent/:chatId/approvals` | resolve a human-in-the-loop gate |
| `POST` | `agents/:agent/:chatId/tool-result` | answer a client-handled tool |
| `GET` | `chats` · `chats/:id` | the caller's chats, and one chat's history |
| `DELETE` | `chats/:id` | delete a chat and the subagent threads it spawned |
| `GET` | `runs` | run records, for an admin or debug view |
| `GET` `POST` `DELETE` | `schedules` · `schedules/:id` | manage scheduled agents |
| `GET` | `connections` | connection status for the caller |
| `GET` | `connections/:name/authorize` · `callback` | the OAuth dance |
| `POST` | `connections/:name/disconnect` · `test` | revoke, or check liveness |
| `GET` `POST` | `cron/run` | `runtime.cron()`, or your `cronRun`: fire all tenants' due schedules, reap + resume stalled runs |

The turn route accepts `{ message }`, `{ text }`, or a `useChat`-style `{ messages }` array and takes the last one — so an AI SDK client works without a translation layer.

### Refusals a client should expect

| Status | `code` | Meaning | What the client does |
| --- | --- | --- | --- |
| `409` | `pending_approval` | A decision is outstanding on this chat. | Render the approval; resolve it first. |
| `409` | `chat_busy` | A turn is already running in this chat (two tabs, a retry racing a reply). Nothing was persisted. | Wait `Retry-After` (also `retryAfterMs` in the body) and resend the same message. |
| `409` | `chat_conflict` | The client-chosen chat id exists and isn't yours. | Pick another id. |
| `429` | `limit_exceeded` | A turn, token or cost budget is spent. | Wait `Retry-After`; tell the user which budget. |

## The cron route

`cron/run` is the only route that isn't user-authenticated: it fires **every** tenant's due schedules and finishes interrupted answers, so there's no principal to check. Guard it with a shared secret, sent as `Authorization: Bearer <secret>` or in the `x-latch-cron-secret` header, with `GET` or `POST`:

```ts
createLatchHandler({
  runtime,
  resolveContext,
  cronSecret: process.env.LATCH_CRON_SECRET,
});
```

Omit `cronSecret` and the route doesn't exist at all — it fails closed.

<Callout title="Vercel Cron">
Vercel Cron calls a URL with `GET` and `Authorization: Bearer $CRON_SECRET`, which this route accepts. Set `cronSecret: process.env.CRON_SECRET` (`createLatchApp` reads `CRON_SECRET` on its own) and point a cron in `vercel.json` at it: `{ "crons": [{ "path": "/api/latch/cron/run", "schedule": "* * * * *" }] }`.
</Callout>

Schedules also need a cron evaluator, since core ships no cron library:

```ts
import { createCronerEvaluator } from "@intentface/latch-server";

createRuntime({ /* … */ cron: createCronerEvaluator() });
```

On a long-lived Node process you can skip the HTTP route entirely and use the in-process scheduler instead — see [Turn on crash recovery](/docs/guides/crash-recovery).

## Multi-tenancy

`runtime` accepts a resolver as well as a value, which is how you choose between the two isolation models:

```ts
// One database, isolated by owner key on every row.
createLatchHandler({ runtime, resolveContext });

// A database per tenant — hard isolation.
createLatchHandler({
  runtime: (principal) => runtimeFor(principal.orgId),
  resolveContext,
  // No principal exists on cron/run, so supply the system-wide tick yourself.
  cronRun: async () => {
    for (const tenant of await listTenants()) {
      await (await runtimeFor(tenant)).cron();
    }
  },
});
```

Which one you want, and how the owner key follows from your `Principal`, is [Principals](/docs/guides/ownership-patterns).


---

# Who owns a conversation

Source: https://intentface-latch-docs.vercel.app/docs/guides/ownership-patterns

`Principal` is **your type**. Latch never inspects it, and there is no built-in notion of a user, an org, or a role. What Latch does with it is derive one string — the **owner key** — and scope every row by it.

That single indirection is the whole tenancy model:

```ts
interface Principal {
  orgId: string;
  userId?: string;
}

const storage = createSqliteAdapter<Principal>(db, {
  owner: (p) => (p.userId ? [p.orgId, p.userId] : [p.orgId]),
});
```

`owner` returns the key's **segments**, not a string — the adapter escapes each one and joins them (so a `/` or `%` inside an id can't forge a boundary). Change that one function and you change who can see what. Everything below is a variation on it.

<Callout title="Two responsibilities, clearly split">
Latch enforces **isolation** — a read scoped to an owner key cannot return
another owner's rows. Latch does not do **authorization** — whether this user
may use that agent, or spend that budget, is your `resolveContext`'s job. Isolation
is structural; authorization is policy.
</Callout>

## Pattern 1 — Each end user owns their own sessions

The SaaS default, and the one most apps want. Include the user in the key:

```ts
owner: (p) => {
  // Don't assert with `p.userId!` — a principal that reached here without a
  // userId would key as [orgId, undefined] and quietly land in a bucket
  // nobody can read back. Make the requirement explicit instead.
  if (!p.userId) throw new Error("per-user isolation requires a userId");
  return [p.orgId, p.userId];
},
```

If your `Principal` type makes `userId` required, the check is free and the
throw is unreachable — which is the better shape when every entry point has a
human behind it.

Now every chat, run, usage row and schedule belongs to one person. `listChats` for user A can never return user B's conversation, even inside the same customer account — not because a `WHERE` clause was remembered, but because the adapter has no key that would reach it.

```ts
const latch = createLatchHandler({
  runtime,
  resolveContext: async (request) => {
    const session = await auth.getSession(request);
    if (!session) return null; // → 401
    return { orgId: session.orgId, userId: session.userId };
  },
});
```

Two users hitting the same route get different data from the same code path. There is no per-request filter to forget.

## Pattern 2 — The whole team shares one inbox

Drop the user from the key and the org becomes the unit:

```ts
owner: (p) => [p.orgId]
```

Every member sees the same chats and can pick up any thread. This is what you want for a shared support desk, an ops channel, or an on-call assistant — the conversation belongs to the company, not to whoever typed first.

The principal still carries `userId`; it's just not part of the key. Keep it, because tools and audit logs still need to know who is acting.

## Pattern 3 — Personal and shared, in one app

Most real products want both: private assistants *and* team agents. The storage `owner` option takes one function, so put the scope in the **principal** and let the key follow it:

```ts
type Scope = "user" | "org";

interface Principal {
  orgId: string;
  userId?: string;
  /** Which bucket this turn reads and writes. Resolved with the session. */
  scope: Scope;
}

const storage = createSqliteAdapter<Principal>(db, {
  owner: (p) => {
    if (p.scope === "org") return [p.orgId];
    // Same rule as Pattern 1, and `?? ""` breaks it just as `!` would: every
    // principal missing a userId would collide into one shared [orgId, ""]
    // bucket — a cross-user leak dressed as a default.
    if (!p.userId) throw new Error("user scope requires a userId");
    return [p.orgId, p.userId];
  },
});
```

A personal agent runs with `scope: "user"` and resolves under the caller's key; an org agent runs with `scope: "org"` and resolves under the org's. The same human produces both, so they hold private conversations and shared ones side by side.

The scope has to be **part of the principal**, not an extra argument, because it must survive serialization: a resumed or cron-fired turn rebuilds the principal from the stored `identity` blob, and a scope passed alongside it would be lost — the turn would then read from the wrong bucket.

<Callout type="warn" title="Your own tables need the same rule — and won't get it for free">
Latch's tables are safe here: you return segments and the adapter escapes and
joins them, and refuses a missing or empty segment rather than filing it under
`"undefined"`. For your *own* owner-scoped tables, build the key with the same
function, `encodeOwnerKey(segments)` from `@intentface/latch-core`, in exactly one
exported helper.

Two copies that escape differently — one calling `encodeURIComponent`, one a bare
template string — produce **different keys for the same principal** as soon as an
id contains a `/` or `%`. The rows don't leak; they vanish. No error is raised,
and the data still exists under a key nothing now generates.
</Callout>

## Pattern 4 — A database per tenant

When a column isn't enough — a compliance commitment, a customer with their own region, a very large tenant — isolate at the database instead. `latch-server` takes a runtime *resolver*:

```ts
createLatchHandler({
  runtime: (principal) => runtimeFor(principal.orgId),
  resolveContext,
  cronRun: async () => {
    // Not a serial for-loop: `runDue` awaits each scheduled turn to
    // completion, so one tenant's hour-long agent turn would stall every
    // tenant after it — including the `sweep` that recovers their crashed
    // runs. Fan out, and don't let one tenant's failure skip the rest —
    // but do LOG the failures, or a tenant whose schedules silently stopped
    // firing is undiagnosable.
    const tenants = await listTenants();
    const results = await Promise.allSettled(
      tenants.map(async (tenant) => {
        const rt = await runtimeFor(tenant);
        await rt.cron();
      }),
    );
    results.forEach((r, i) => {
      if (r.status === "rejected") console.error(`cron tick failed for tenant ${tenants[i]}:`, r.reason);
    });
  },
});
```

The resolver runs *after* `resolveContext`, so the principal picks the database. Note the `cronRun`: the system-wide tick has no principal, so with per-tenant databases you must supply it yourself. With a single runtime it defaults to `runtime.cron()`.

Both models can coexist — column-scoped for self-serve customers, a dedicated database for enterprise ones — because the choice lives in a function rather than in the schema.

## Pattern 5 — Nobody is there

A scheduled agent and a webhook-triggered agent still need a principal, and inventing one at fire time is how tenancy breaks. Latch stores the principal on the run as an opaque `identity` blob and hands it back:

```ts
createRuntime({
  // The run stored your Principal verbatim. Give it back as-is.
  reconstructPrincipal: (identity) => identity as Principal,
});
```

The rule: **the identity is captured when the schedule is created, not when it fires.** A report scheduled by user A three months ago still runs as user A, reads what user A can read, and lands in user A's rows — even though no one is logged in.

Which is also the risk: if user A has since been removed from the org, the passthrough above keeps running their schedule, as them, forever. Re-validate on the way back out and return `null` to refuse:

```ts
createRuntime({
  reconstructPrincipal: async (identity) => {
    const p = identity as Principal;
    // The same question your login answers — not a weaker one.
    return (await isActiveMember(p.userId, p.orgId)) ? p : null;
  },
});
```

A refusal is permanent, and the runtime treats it that way: a refused cron fire is not run and its **schedule is disabled** (counted in `runDue`'s `rejected`), and a refused resume ends the run `errored` without anything running as that identity. Throw only for a transient failure — a lookup that couldn't be made — which counts as an error for that occurrence: it is still consumed, so a recurring schedule stays live for its next one and a one-shot is spent, while a thrown resume is retried once its lease lapses. Only `null` refuses; `undefined` does not. The hook runs inside the schedule's claim window, so keep it fast.

<Callout type="warn" title="Never derive identity from the request">
Identity comes from your login, in the route, and travels as the principal. The
`context` function gets only the principal, on purpose: a resumed or scheduled
turn has no request, so anything read from one would be missing exactly when
nobody is watching. What a chat is about goes in `params`; what the user is
looking at goes in a `data-context` part. Both are saved, and neither is
identity. See [Principal, context and params](/docs/concepts/inputs).
</Callout>

## Pattern 6 — Support and admin access

The mistake is to run the turn as the admin and "look at" the customer's data. That inverts the model: writes land under the admin's owner key, so the notes, runs and usage of a support session end up in a bucket the customer can't see and your billing can't attribute.

Resolve the **subject's** principal deliberately instead:

```ts
resolveContext: async (request) => {
  const session = await auth.getSession(request);
  if (!session) return null;

  // The customer being helped: "orgId/userId", or "orgId" for team chats.
  const actingAs = request.headers.get("x-acting-as");
  if (actingAs) {
    if (!(await isSupportStaff(session.userId))) return null;
    await auditLog.record({ actor: session.userId, subject: actingAs });
    const [orgId, userId] = actingAs.split("/");
    // The customer's principal, so storage opens their chats. `actorId` isn't
    // part of the owner key: it records who acted without changing whose
    // chats these are.
    return { orgId, userId, actorId: session.userId };
  }

  return { orgId: session.orgId, userId: session.userId };
},
```

Two properties worth keeping: the authorization check happens before the principal is built, and the staff member rides along as `actorId` (add it to your `Principal` type, outside the owner key), so tools and traces record who really did it.

## How a principal reaches a tool

Isolating rows is half the job. The other half is making sure tenant A's tool call carries tenant A's credentials — and that the model never sees either.

Every tool the runtime attaches receives one context object as its second argument (`options.context`): what your `context` function returned, plus `principal`, the chat's `params`, the message's `messageContext`, the current `run` (`{ id, chatId, agent }`) and the `writer`. So the context function makes the per-turn world, and a tool resolves what it needs from the principal it was handed, the credential included:

```ts
import { createVault, type ToolContext } from "@intentface/latch-core";

const vault = createVault<Principal>({ store, masterKey });

const context = async ({ principal }: { principal: Principal }) => ({
  orgId: principal.orgId,
  plan: await lookupPlan(principal.orgId),
});
```

Then a tool reads the context, never an argument — and asks the vault for the secret, scoped by the principal:

```ts
type ToolCtx = ToolContext<Principal, Awaited<ReturnType<typeof context>>>;

const lookupCustomer = tool({
  description: "Look up a customer in the CRM.",
  inputSchema: z.object({ email: z.string() }),
  execute: async ({ email }, { context }) => {
    const { principal, plan } = context as ToolCtx;
    const apiKey = await vault.resolver(principal, "crm_api_key");
    const baseUrl = plan === "enterprise" ? EU_ENDPOINT : US_ENDPOINT;
    const res = await fetch(`${baseUrl}/customers?email=${encodeURIComponent(email)}`, {
      headers: { authorization: `Bearer ${apiKey}` },
    });
    return res.json();
  },
});
```

What this buys you, in order of importance:

- **The model never holds a credential.** It chooses *which* tool to call; the key is attached below it, at egress. No prompt injection can exfiltrate a value the model was never given.
- **The same tool is safe for every tenant.** One `lookupCustomer` definition serves all customers, because the credential is resolved from the principal at call time rather than baked into the agent.
- **Rotation is a vault write.** Replace the secret and every agent and connection built on it picks up the new value; delete it and they all revoke.
- **Tools can be tenant-aware without being tenant-specific.** Endpoint, plan tier, feature flags all come through `build`, so behaviour can differ per customer while the code stays single.

The same resolver backs connections: hand it to `createMcpConnections({ secrets: vault.resolver })` and `latch-mcp` brokers MCP server auth through the vault, so an agent's config names a connection and never carries its token.

## Two failure modes worth internalising

### Pin the tenant when the session is created

If the value your owner key derives from can change mid-session — an "active org" the user can switch in a menu — then owner-keyed rows fragment silently. Yesterday's chats belong to a key you no longer produce. They aren't deleted and no error is raised; they simply stop appearing.

Resolve it once at session creation and keep it stable for the session's life. Switching orgs should mean a new session, not a mutated one.

### Make sure the principal is the person you think it is

A worked example, from a real integration. A chat-platform bot verified the sender's identity, checked their org membership, and then ran the turn as **the account that installed the app** rather than as the sender. Consequences, none of which raise an error:

- Every chat, run and usage row was keyed to the installer, not the user.
- Two colleagues using the same bot shared one owner key, so one person's context could surface for the other.
- Per-user memory keys on `principal.userId`. On an org-scoped install there was no `userId`, so per-user memory was **silently skipped entirely** — not misrouted, absent.
- The same agent reached from the web app *did* have the right principal, so it remembered things the bot could not, for no visible reason.

The lesson generalizes past chat bots to any inbound integration — webhooks, email, API keys, embedded widgets. **Authorizing a request and identifying the actor are different steps.** If you check identity and then discard it, you get a system that is secure and wrong at the same time.

<Callout title="A quick audit">
For each entry point you have — web app, mobile, webhook, bot, cron, admin —
write down what `principal.userId` is at the moment `handleChat` is called. If
any two entry points for the same human disagree, memory, history and billing
will disagree too.
</Callout>

## See also

- [Turn inputs](/docs/concepts/turns) — the principal, context and message projections in detail
- [Storage](/docs/reference/storage) — the guarantees the adapter must make for owner scoping to hold
- [Secrets (the vault)](/docs/concepts/secrets): setting it up, and team or personal keys


---

# Plans and limits

Source: https://intentface-latch-docs.vercel.app/docs/guides/plans-and-limits

The free plan gets 20 requests a day per person. The team plan gets a monthly token budget for the whole team. The expensive research agent gets its own cost cap. Here's how to set that up. For why it's harder than reading a counter, see [Usage limits](/docs/hard-parts/limits).

## 1. Say which limits a run counts against

`limitsFor` runs before every run and returns the limits it counts against. Each has a key (any string: a team, a person, an agent, an API key), a window, and caps on `turns`, `tokens` and `cost`.

```ts
import { DAY_MS, MINUTE_MS } from "@intentface/latch-core";
import { createPostgresLimitStore } from "@intentface/latch-drizzle";

createRuntime({
  …,
  limits: {
    store: createPostgresLimitStore(db), // one counter row per key and window
    limitsFor: async ({ principal, agent }) => {
      const plan = await planOf(principal.orgId); // your table: keep it fast, it runs every time
      return [
        { key: `org:${principal.orgId}`, windowMs: 30 * DAY_MS, max: { tokens: plan.monthlyTokens }, label: "your team's monthly budget" },
        { key: `user:${principal.orgId}:${principal.userId}`, windowMs: DAY_MS, max: { turns: plan.dailyRequests }, label: "your daily requests" },
        { key: `user:${principal.orgId}:${principal.userId}`, windowMs: MINUTE_MS, max: { turns: 10 } },
        ...(agent === "research" ? [{ key: `agent:${principal.orgId}:research`, windowMs: DAY_MS, max: { cost: 20 } }] : []),
      ];
    },
  },
});
```

Return an empty list and the run isn't limited at all.

## 2. Treat background runs differently

`trigger` says how the run started: `"chat"`, `"schedule"`, `"programmatic"` (a `runAgent` call), `"decision"` (continuing after an approval) or `"resume"` (after a crash). A scheduled report probably shouldn't use up a person's daily requests:

```ts
limitsFor: ({ principal, trigger }) =>
  trigger === "schedule"
    ? [{ key: `schedules:${principal.orgId}`, windowMs: DAY_MS, max: { cost: 5 } }]
    : chatLimits(principal),
```

Subagents count against the limits of the run that started them, with the same trigger.

## 3. Tell the user which limit they hit

A refused run throws `LimitExceededError` before the chat or the message is saved. (The refused attempt's place in the window is given back; if that fails, it counts once against the limit.) Its message names the limit by its `label` and says when it resets: "Limit reached: your daily requests. Try again in 3 hours." `createLatchHandler` turns it into `429` with `Retry-After`; in your own route, do the same:

```ts
import { isLimitExceededError } from "@intentface/latch-core";

try {
  return await runtime.handleChat({ … });
} catch (e) {
  if (isLimitExceededError(e)) {
    return Response.json({ error: e.message }, {
      status: 429,
      headers: { "retry-after": String(Math.ceil(e.retryAfterMs / 1000)) },
    });
  }
  throw e;
}
```

## Good to know

- **Windows are fixed and aligned to UTC.** A day runs from 00:00 to 00:00 UTC, not the user's midnight. A 30-day window is 30 days from a fixed point, not a calendar month.
- **Request limits are exact, token and cost limits can overshoot** by what answers already running spend. See [what holds exactly](/docs/hard-parts/limits).
- **A long answer stops at the budget,** keeping what it wrote.
- **Old counters are cleaned up** by `runtime.cron()`.


---

# Deliver a scheduled brief to a channel

Source: https://intentface-latch-docs.vercel.app/docs/guides/scheduled-brief

## Background

A background job that runs an agent on a schedule is easy. A *brief* is harder: it has to land where the person will read it, they have to be able to reply to it, and it has to keep running correctly across nodes, time zones, approvals the agent hits with nobody watching, and owners who leave the company. Latch schedules are rows claimed exactly once by whatever runs `runtime.cron()`, fired as the principal who created them, and delivered by a host hook into the conversation of your choice.

## Create the schedule

```ts
await runtime.schedule({
  principal,
  agent: "reporter",
  cron: "0 8 * * 1-5",          // null = one-shot, fired on the next tick
  timezone: "Europe/Helsinki",  // "8am" means the user's 8am
  prompt: "Summarise yesterday's signups and anything unusual.",
  delivery: { kind: "telegram", botId: "b1" }, // opaque to core — your host reads it
});
```

## Make time pass

On a long-lived process `createLatchApp` runs an in-process cron. On serverless, point a platform cron at one route:

```ts
export async function POST(request: Request) {
  assertCronSecret(request); // yours — this route is system-wide
  return Response.json(await runtime.cron()); // due schedules, then reap + resume crashed runs
}
```

Core needs a cron evaluator and ships none; `createLatchApp` supplies croner, or pass `cron: (expr, afterMs, timezone) => nextRunMs` to `createRuntime`.

## Deliver into a conversation

By default a fire runs in a fresh chat. Supply `runSchedule` to own the fire and run it where the user is:

```ts
createRuntime({
  // …
  runSchedule: async ({ schedule, principal }) => {
    const target = parseDelivery(schedule.delivery); // yours; core never reads it
    if (!target) return false;                        // fall through to the default
    const { chatId, parked } = await deliver({ schedule, principal, target });
    return { handled: true, chatId, parked };
  },
});
```

For a Telegram or WhatsApp target, replay the prompt into the user's current conversation with that bot through the channel kernel; for a Slack target, post an anchor message and run the turn threaded under it. Return `true` only when you ran *and* delivered — anything else falls back to a fresh chat so a stale target never loses the fire.

## Let the agent schedule itself

With `defaultTools: true`, an agent asked in a Telegram DM can call `create_schedule` and the brief is delivered back into that DM. The target is derived from the conversation the request came from, never named by the model; `list_delivery_channels` offers the rest.

## Full example

```ts
import { Cron } from "croner";
import { createRuntime } from "@intentface/latch-core";

const runtime = createRuntime<Principal, Ctx>({
  storage,
  context,
  agents: { reporter },
  durability: { enabled: true },
  cron: (expr, afterMs, timezone) =>
    new Cron(expr, { timezone: timezone ?? "UTC" }).nextRun(new Date(afterMs))?.getTime() ?? null,
  runSchedule: async ({ schedule, principal }) => {
    const target = parseDelivery(schedule.delivery);
    if (!target) return false;
    return deliver({ schedule, principal, target }); // → { handled: true, chatId, parked }
  },
});

await runtime.schedule({
  principal,
  agent: "reporter",
  cron: "0 8 * * 1-5",
  timezone: "Europe/Helsinki",
  prompt: "Summarise yesterday's signups.",
  delivery: { kind: "telegram", botId: "b1" },
});

// The cron route — every node may call it; each schedule fires once.
export const POST = async (request: Request) => {
  assertCronSecret(request);
  return Response.json(await runtime.cron());
};
```

## Pitfalls already handled

- **Time zones** — schedules carry an IANA zone; UTC only when none is set.
- **Many nodes, one fire** — claiming a due schedule is a single-row transition.
- **The fire meets an approval** — it *parks* (`parked` in `runDue`'s result); `onParked: "skip"` (the default) stops a recurring schedule minting a new parked chat every tick.
- **The owner left the org** — a schedule's principal is a snapshot; every fire re-checks membership and a departed member's schedule deletes itself.
- **Where the DM is** — derived from the identity row at fire time, never stored on the schedule.
- **The user is mid-conversation** — one turn per conversation; the fire is refused, the user is told it is still working, the occurrence is consumed.
- **A run that keeps dying** — `maxAttempts` caps resume attempts.
- **"Run now" that drifts from cron** — `runScheduleNow` fires through the same path.

## Run it

```bash
pnpm --filter @intentface/latch-example smoke:scheduler   # an interval sweep reaps + resumes a stuck run
```

## Next

- [Run agents on a schedule](/docs/guides/schedules) — `onParked`, `parked`, `runSchedule`'s return shape
- [Channels](/docs/guides/channels) — the kernel a DM delivery replays through
- [The cron route](/docs/guides/mount-the-handler#the-cron-route) — guarding the system-wide entry point


---

# Run agents on a schedule

Source: https://intentface-latch-docs.vercel.app/docs/guides/schedules

Schedules are **rows**, not timers. Core stores the cron expression and the captured identity; what makes time pass is your infrastructure.

```ts
await runtime.schedule({
  principal,
  agent: "reporter",
  cron: "0 9 * * 1", // null = one-shot, fired on the next runDue()
  timezone: "Europe/Helsinki",
  prompt: "Summarise last week's signups.",
  delivery: { kind: "slack", channel: "C123" }, // opaque to core
  onParked: "skip", // the default; see "When a fire stops for a human" below
});
```

Core needs a cron evaluator — it deliberately ships no cron library:

```ts
import { Cron } from "croner";

createRuntime({
  // …
  cron: (expr, afterMs, timezone) =>
    new Cron(expr, { timezone }).nextRun(new Date(afterMs))?.getTime() ?? null,
});
```

Then fire due schedules from the same route as `sweep`:

```ts
await runtime.runDue();
// → { fired, errors, skipped, parked }  — all tenants, each claimed once
```

Each fire reconstructs the principal from the stored identity blob, runs the turn, and advances the schedule.

## When a fire stops for a human

An unattended turn can meet an approval gate, and then it **parks**: the tokens are spent, no answer is produced, and nobody is there to decide. Latch treats that as a legitimate outcome (a scheduled agent asking before it mutates something is the point of the gate) and makes it loud rather than impossible:

- **`parked`** counts fires that ran and stopped waiting on a human. They are counted in `fired` too — the turn did run — and logged on the tick it happens, which is what a one-shot schedule needs: it has no later occurrence to reveal it. Core can account for this itself on the default fresh-chat path; a host that takes over delivery via `runSchedule` owns the chat its fire ran in, so it opts into the same accounting by returning `{ handled: true, chatId, parked }` instead of a bare `true`.
- **`onParked`** decides what the *next* occurrence does while the previous fire is still waiting (against the chat the last fire reported — the default path's own, or the `chatId` a `runSchedule` host returned): `"skip"` (the default) consumes the occurrence without running, so a recurring schedule can't mint a new parked chat and a new bill every tick; `"fire"` always runs, for a digest whose every occurrence matters even while one waits. `skipped` counts those.
- **Which conversations need a person** is a plain list read: `ChatRecord.lastRunStatus` is `"awaiting_input"` for a chat whose latest run is waiting, and self-heals when the conversation moves on.
- **To push a notification** rather than wait for someone to look, use `telemetry.onRunFinished` and check `run.trigger` — see [Telemetry hooks](/docs/reference/hooks#telemetry-hooks--usage-analytics-and-someone-needs-to-be-told). `status: "awaiting_input"` with `trigger: "schedule"` is precisely "a fire nobody can answer".

## Delivering somewhere other than a fresh chat

By default a fired schedule runs in a new chat. If the report should land in the conversation the person actually replies in — a Slack thread, a Telegram DM — supply `runSchedule`:

```ts
createRuntime({
  // …
  runSchedule: async ({ schedule, principal }) => {
    const target = parseDelivery(schedule.delivery); // yours; core never reads this
    if (!target) return false; // fall through to the default
    const { chatId, parked } = await deliverIntoChannel({ schedule, principal, target });
    // `true` is enough. The object form opts this fire into the same accounting
    // the default path gets: `chatId` is what the next occurrence's `onParked`
    // check reads, and `parked` joins runDue's `parked` count.
    return { handled: true, chatId, parked };
  },
});
```

Return `true` (or `{ handled: true, … }`) only when you ran *and* delivered it. Return `false` (or `{ handled: false }`) to decline, and core runs it the default way, so a stale target (bot disabled, user unlinked) still gets the work done in a chat rather than losing the fire. Core keeps owning the claim, the lease and the advance either way.

A hook that throws is different: the occurrence counts as an error in `runDue`'s `errors`, and nothing else runs. The hook may already have run the agent (and failed on delivery, say), and running it again in a chat would be a second turn at a second turn's cost. So decline with `false` *before* doing any work, and once you've delivered, return `{ handled: true, … }` even if a later step fails. A recurring schedule fires again at its next occurrence; a one-shot that throws is spent.

`runScheduleNow` makes a schedule due immediately and lets it fire through the ordinary `runDue` path — so "run now" can never drift from what the cron actually does.


---

# Write your own storage adapter

Source: https://intentface-latch-docs.vercel.app/docs/guides/storage-adapter

The built-in adapters cover Postgres and SQLite. Write your own when you need Latch's data in a schema you already have, in another database, or behind a layer you control: an audit log, encryption, a cache.

## First: do you need to wrap, or replace?

Most needs are a **wrapper** around a built-in adapter: same tables, one method changed.

```ts
const base = createPostgresAdapter<Principal>(db, { owner });

const storage: StorageAdapter<Principal> = {
  ...base,
  appendMessages: async (chatId, messages, ...rest) => {
    await audit.record(chatId, messages);
    return base.appendMessages(chatId, messages, ...rest); // pass every argument on
  },
};
```

<Callout type="warn" title="Pass every argument on">
A wrapper that drops an argument quietly turns a guarantee off. Leave out the fence, and a server that lost its run can keep writing into the conversation after another server took over; leave out the owner key, and the adapter can no longer check whose chat it's writing to. Forward the rest of the arguments as they are (`...rest` above). The adapter returns `false` when it refuses a write: return that too.
</Callout>

Write one from scratch only for a different database or a different schema.

## Start from the checks

The guarantees an adapter must keep only show up under load: two servers claiming one run, a server waking up after its run was taken over. The conformance checks make them show up in a test, so write the test first and make it pass one check at a time.

```ts title="my-adapter.test.ts"
import { describe, it } from "vitest";
import { registerStorageConformance } from "@intentface/latch-core/testing";

registerStorageConformance({ describe, it }, "my adapter", async () => {
  const db = await openTestDatabase();
  return {
    store: createMyAdapter<Principal>(db, { owner: (p) => [p.orgId] }),
    principals: [{ orgId: "a" }, { orgId: "b" }], // two different owners
    close: () => db.close(),
  };
});
```

It runs under Vitest, Jest or `node:test`. The built-in adapters pass the same checks.

## The guarantees, in the order to build them

1. **Every read is scoped to its owner.** A chat from another owner is not found, not forbidden. Build the owner key with `encodeOwnerKey(owner(principal))` from `@intentface/latch-core`: it escapes each segment and refuses a missing one, so a principal without a `userId` can't land in a bucket shared with every other one.
2. **Messages keep their order** and are replaced by id, never duplicated.
3. **One running answer per chat.** Starting a second fails with the time left on the first.
4. **Claims are atomic.** Of many servers claiming the same run, exactly one wins.
5. **Leases come from the database's clock,** not each server's.
6. **Stale servers are fenced out.** Writes carry the run's fencing number, and a write with an old number is refused.

The [storage reference](/docs/reference/storage) has every method and each guarantee in detail, with what breaks when it's missing.

## One schema or database per tenant

If each tenant's data lives in its own schema or database, calls by id are the hard part: `updateRun("run_8f3k")` doesn't say which tenant's schema the run is in. The runtime solves that by handing back the owner key on every such call, so:

1. **Make the tenant the first segment of your owner key**, e.g. `encodeOwnerKey([tenantId, userId])`.
2. **Return the key on every record**: `ChatRecord.owner` and `ScheduleRecord.owner` as usual, and `RunRecord.ownerKey` on every run, reaped and reclaimed ones included.
3. **Route each call by it**: `decodeOwnerKey(scope.ownerKey)[0]` is the tenant. `Runtime.getRun` is the one call that passes `{ principal }` instead; derive the key from it as your other methods do.
4. **Keep a global table for the two sweeps**, `reapExpiredRuns` and `claimDueSchedules`: active leases and due schedules across tenants, each with its owner key.
5. **Run the checks with `checksOwnerKey: true`.**

```ts
registerStorageConformance({ describe, it }, "per-tenant adapter", async () => ({
  store: createTenantAdapter<Principal>(cluster, { owner: (p) => [p.tenantId, p.userId] }),
  principals: [{ tenantId: "t1", userId: "u1" }, { tenantId: "t2", userId: "u2" }],
  checksOwnerKey: true,
}));
```


---

# Write an extension

Source: https://intentface-latch-docs.vercel.app/docs/guides/write-an-extension

Every option on `createRuntime` adds one capability the runtime already knows about. An **extension** is how you add one it doesn't. It attaches handlers to the **moments** of a turn — before the model runs, before a tool executes, after a step ends — and each handler returns an **effect**. The runtime composes any number of extensions with one merge rule per moment, and with none attached a turn is exactly the same as without them.

**What it makes possible.** A per-plan tool veto that the model is told about; a running cost line the customer sees and the model never does; a `/stats` command answered without a model call; a compaction that shrinks what the model reads while the customer keeps every message; a fail-closed audit log — each as one file, none as a change to core.

## Your first extension

```ts
import { defineExtension } from "@intentface/latch-core";

export const guardrails = defineExtension("guardrails", (ext) => {
  // Refuse a tool for this tenant. The reason reaches the model as the
  // tool's result, so it can explain rather than retry blindly.
  ext.on("tool_call", ({ toolName }, run) => {
    if (toolName === "delete_record" && plan(run.principal) === "free")
      return { block: true, reason: "Deleting records needs the Team plan." };
  });

  // A line the customer sees after every step. The model never does.
  ext.notify((step) => `${step.usage.inputTokens ?? 0} tokens in · ${step.durationMs} ms`);

  // A slash command, answered with no model call.
  ext.registerCommand("stats", {
    description: "Turns this month",
    handler: async (_args, { messages }) => `${messages.length} messages so far.`,
  });
});
```

Attach it to every turn, or to one agent's:

```ts
createRuntime({ storage, context, agents, extensions: [guardrails] });

defineAgent({ model, instructions, extensions: [guardrails] });
```

Runtime-wide extensions run first, then the agent's own. A handler that returns `undefined` has no effect. A handler that throws is reported and skipped, so one broken extension never breaks a turn — unless its extension is marked `critical`, in which case the throw fails the run (see the third rule below).

## Moments and effects

The effect type for each moment is the permission — you cannot do everything everywhere, which is what keeps two extensions from fighting.

| Moment | Returns | What it touches |
| --- | --- | --- |
| `session_start` | `{ inject }` / `{ replace }` | the transcript, on a conversation's first turn |
| `before_agent_start` | `{ inject }` / `{ replace }` | the transcript, every turn, before the model |
| `input` | `{ transform }` / `{ handled, reply }` | the user's text — rewrite it, or answer without the model |
| `register_tools` | a new toolset | what the model may call; additions are always active |
| `tool_call` | `{ block, reason }` | a veto; the reason becomes the tool's result |
| `tool_result` | `{ output, isError }` | the stored result **and** what the model reads |
| `instructions` | `{ append }` / `{ replace }` | the system prompt this turn |
| `context` | `{ messages }` | the model's view of history this turn |
| `step_start` | `{ stop, model, activeTools }` | whether, and how, the next step runs |
| `step_end` | `{ append }` | parts appended to the reply — stored, and streamed live |
| `render` | `{ hide }` / `{ replace }` | what the customer sees; never storage |
| `error`, `agent_end` | — | observe |

Three moments touch the stored record: `before_agent_start` (inject or replace before the run), `step_end` (append-only, after each step) and `tool_result` (the override *is* the stored value). Everything else is a lens on one audience's view and vanishes after the step.

## The three rules

**Storage keeps the truth.** `tool_result` changes what is stored, so a secret masked there is gone for the model and the customer alike. `render` changes only what the customer sees — live for parts an extension added, and every part on load — for the rarer case where the model should keep the truth and the person should not see it. Only the start moments can rewrite what is already stored, and only deliberately, through `replace`: `step_end` is append-only, and `render` never touches storage.

**Order matters at `step_end`.** Handlers run in extension order, and the next model call waits for all of them. List an extension that persists before the ones that merely observe, and a persisting extension has always written before the model is asked again.

**Failure is open by default, closed by declaration.** A throwing handler is reported and skipped. That is wrong for anything the run's correctness depends on: a persistence handler whose database is down has not "produced no effect", it has lost the step. Mark those `critical`, and a throw fails the run instead:

```ts
defineExtension("audit", (ext) => { /* … */ }, { critical: true });
```

## Identity

Every handler receives the run's principal as a trailing argument — `run.principal` in `tool_call`, `ctx.principal` in `step_start`, `info.principal` in `step_end`. The one exception is `render`, which also runs when history is loaded with no run: its `ctx.principal` may be missing, so check it before use. Declare its shape once and every handler is typed, with no generic on any signature:

```ts
declare module "@intentface/latch-core" {
  interface Principal { orgId: string; userId: string }
}
```

Identity comes from the runtime, never from a closure: a per-tenant toolset, a per-tenant veto and a per-tenant redaction all read the same principal, so two extensions cannot disagree about who a run is for.

## Two intents, assembled for you

Most extensions want one of two things, and `defineExtension` spells both out so you don't assemble the moments by hand.

**Give the model information** — `ext.context(provider, options)`. The provider returns text; the extension places it. `scope: "run"` (default) resolves once per turn and is cached per run, `"step"` resolves before every model call, `"session"` resolves once per conversation and is stored as a hidden part so later turns reuse it. `position: "before-user"` (default) prepends to the newest user message; `"system"` appends to the instructions.

```ts
ext.context(async ({ run }) => `The customer is on the ${await planOf(run.principal)} plan.`);
```

**Tell the customer something** — `ext.notify(provider)`. Runs at `step_end`; the text becomes a `data-notice-<extension>` part, stored on the reply and streamed as it happens. The model never sees it.

## Testing an extension

An extension is a plain value, so its handlers can be called directly. For the whole turn, run it through `createRuntime` with a mock model from `ai/test` and an in-memory `StorageAdapter`, then read the three surfaces: the chunks the client received, what storage holds, and `model.doStreamCalls` for what the model was asked. Core's own `turn.test.ts` under `src/extensions/` is the template.

## Not available yet

`instructions` and `context` apply to the turn's prompt, not per step. `render` does not touch the model's own streamed text live. An extension cannot yet contribute an HTTP route, a table or a scheduled job — the shape MCP with OAuth needs. Runtime-wide extensions render on `loadHistory`; an agent's own do not, since no agent is resolved there.

## Next

- [Hooks: extensions](/docs/reference/hooks#extensions--moments-and-effects) — the effect types in full
- [Turn inputs](/docs/concepts/turns) — the three views of a message that `tool_result` and `render` operate on
- [createRuntime](/docs/reference/create-runtime) — where `extensions` sits among the other options


---

# Who sees what

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/access

Every agent demo has one user. Then you add a second customer, and every place that reads or writes data needs to know whose data it is.

It looks like adding `WHERE org_id = ?` everywhere. With agents, "everywhere" is bigger than it looks.

**The model chooses what to read.** A tool that forgets to filter by customer doesn't fail. It answers, with someone else's data.

**Nobody is logged in.** A scheduled run fires at 9:00, a crashed answer resumes at 3:00. There's no request and no session. Whose data may it touch, and is that person still allowed to?

**Not everything is private.** A support desk wants one shared set of conversations for the whole team, next to each person's private assistant.

**Identity goes stale.** A schedule created in March still carries March's view of the person. They might have changed roles, or left.

## What Latch does

**You describe who is asking; Latch carries it everywhere.** The principal is your own type, for example `{ orgId, userId }`. You build it from your login on each request, and Latch passes it to every storage call, every tool and every background run.

**Storage is scoped by design.** Each conversation is stamped with an owner when it's created, and every read checks it. A chat id from another customer simply isn't found.

```ts
createPostgresAdapter(db, {
  owner: (p) => [p.orgId, p.userId], // private: each person sees their own chats
  // owner: (p) => [p.orgId],        // shared: the whole team sees the same chats
});
```

**Tools get the right customer, not a way to choose one.** What your `context` function returns reaches every tool, built fresh from the principal on every turn. Hand tools clients that are already bound to this customer, and a tool can't reach anyone else's rows.

```ts
createRuntime({
  …,
  context: async ({ principal }) => ({
    orders: ordersFor(principal.orgId), // this customer's orders, nobody else's
    can: (action: string) => permissions.check(principal, action),
  }),
});
```

**Background runs check again.** When nobody is there, Latch rebuilds the principal from what it stored and asks you whether it's still valid. Return `null` and the schedule stops, or the resumed run ends.

```ts
createRuntime({
  …,
  reconstructPrincipal: async (identity) => {
    const { orgId, userId } = identity as Principal;
    return (await isMember(orgId, userId)) ? { orgId, userId } : null;
  },
});
```

<Callout title="Ids in the principal, names in the context">
The principal is saved with every run and schedule, so keep it to plain ids. Look up names, plans and permissions in the `context` function, which runs again on every turn and always reads the current values. See [Principal, context and params](/docs/concepts/inputs).
</Callout>


---

# Agent versions

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/agent-versions

Agents your users edit live in a database, not in code. Changing the prompt is a row update: no deploy, no review.

**Saving overwrites.** The prompt that worked yesterday is gone the moment someone saves a worse one.

**Rolling back isn't one field.** A prompt was written for a model and a set of tools. Restoring the old prompt next to today's model gives you a combination that never ran.

**Two people edit at once.** Two admins tweak the same agent; the second save quietly erases the first.

**Trying a change means shipping it.** There's no way to run the new prompt without every user getting it.

**"What answered this?"** A customer complains about an answer from last week. Which configuration produced it?

**A publish lands mid-answer.** A server crashed during an answer, and someone published a new version before another server picked it up. The answer would finish with a different model and different instructions than it started with.

## What Latch does

**Publishing appends a version.** The whole configuration is kept: prompt, model, tools, settings. A version is always a set of choices that actually ran together. Saving with no changes adds nothing.

**Rolling back moves a pointer.** Making an old version live adds no new version, and a separate log records who made which version live, and when. The version list says what the agent looked like; the log says what it was running on Tuesday.

**Changes are tried as candidates.** A candidate is a named, unpublished configuration that everyone editing the agent can see. It never affects the live agent, but you can run it by name (`support@c_…`) to test it. It remembers which version it started from, so publishing over someone else's newer version can be caught instead of silently reverting it.

**Every run records its version,** when it starts, so even a failed run says what it ran. A recovered answer finishes on the version it started on.

```ts
await store.upsert(principal, record, { author: userId, message: "Tighter refund wording" });
await store.listVersions(principal, "support", "org");
await store.activateVersion(principal, "support", "org", 2); // roll back
```

<Callout title="What a version can't bring back">
A version records the tools an agent chose, not the tools your app still provides. Rolling back to a version that used a connection you've since removed restores the reference, not the connection.
</Callout>

How to set it up: [Agents in a database](/docs/guides/agents-in-a-database).


---

# Approvals

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/approvals

Some tools shouldn't run until a person says yes: sending an email, issuing a refund, deleting a page. The AI SDK gives you the button: mark the tool, and the run pauses with an approval request.

What happens after the click is where it gets interesting.

**Tomorrow.** The user closes the laptop and approves the next morning, on their phone. If the paused run lived in a server's memory or a websocket, it's gone.

**Twice.** A double click, or the same card open in two tabs. The second click shouldn't send the email again.

**Too late.** A card in a tab left open since yesterday, from a conversation that has moved on. Approving it shouldn't restart an old answer.

**From somewhere else.** The approval arrives as a Slack button, or a subagent deep in a delegation needs one. The browser that started the run isn't involved at all.

**Forged.** The approval comes from the client. If the server trusts the client's copy of the paused message, a client can approve a refund it was never shown, with an amount of its choosing.

## What Latch does

**The pause lives in the conversation.** A paused run is just its message, waiting for a decision. It survives restarts and deploys, and the decision can come from any device or channel, any time later.

**Only the decision comes from the client.** Latch takes which approval and yes or no, nothing else. The tool runs with the input the agent asked for, as stored.

**Repeats and stale cards are ignored.** A decision for an approval that's already handled, or that the conversation has moved past, does nothing.

**Several at once.** If one step asks for three approvals, the run waits until all three are answered, then runs exactly the approved ones and tells the agent which were declined.

**Subagents ask in the parent chat.** An approval needed by a subagent appears where the user actually is.

```ts
defineAgent({
  model,
  tools: { send_email, refund },
  toolApproval: { send_email: "user-approval", refund: "user-approval" },
});
```

```tsx
// The client: the AI SDK's own approval flow, nothing custom.
const { addToolApprovalResponse } = useChat({
  transport,
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
<button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: true })}>Approve</button>;
```

Decisions from elsewhere, like a Slack button, go to `runtime.applyApproval({ chatId, principal, decisions })`.

<Callout title="Approval is consent, not permission">
An approval says "the user agreed". It doesn't say the action is allowed. A tool that moves money should still check its own rules on the server: is this the customer's order, is the amount within the total, may this user issue refunds at all.
</Callout>


---

# Files and images

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/attachments

A user drops a screenshot into the chat and asks what's wrong with it. The file has to reach the model now, show up in the chat next week, and not cause trouble in between.

The simple version keeps the file inside the message. It works, until it doesn't:

**It gets heavy.** A file inside a message is stored as text, a third bigger than the file. A conversation with a few screenshots can weigh tens of megabytes, and every answer loads all of it again.

**It doesn't fit.** The file travels inside the chat request, and serverless functions cap request size (4.5 MB on Vercel).

**Links expire.** Store a signed S3 link instead, and the conversation shows broken images a day later.

**Someone else downloads it.** If a file is just a URL the model can't fetch itself, your server downloads it. A URL that quietly points at an internal address turns that into a request into your private network.

**It ends up somewhere you didn't choose.** Uploading files to the model provider's own storage is one function call, and then they live there, under the provider's retention rules.

## What Latch does today

**Every file form the AI SDK supports works as is**: a file inline, a URL, or a file id you uploaded to a provider yourself. Your `useChat` setup doesn't change.

**Inline files are capped,** at 20 MB per message by default. Larger ones are refused before anything is saved, so one message can't fill your database.

```ts
createRuntime({ …, maxInlineFileBytes: 10 * 1024 * 1024 });
```

**Downloads for the model are checked.** When the model can't fetch a URL itself, the download checks where the address actually leads, stops at 50 MB and 60 seconds, and stops with its run. On runtimes where that check isn't possible (Bun, Deno, edge), Latch refuses the download rather than guess.

**Nothing is uploaded to a model provider** unless you do it yourself.

## Coming next: your storage, plain links

The next step lets files live in storage you choose, so messages carry only a small link that never expires:

```ts
createRuntime({ …, attachments: myS3Store }); // put, get and delete; about 20 lines for S3
```

- The model gets each file the best way it can take it: a fresh signed link, or the bytes.
- Links are reused while they're valid, so prompt caching keeps working.
- People see files through a link that checks they may see that chat.
- Deleting a chat deletes its files.

This part is designed but not built yet.


---

# Chat apps

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/chat-apps

The agent should answer in Slack and Telegram too. Receive a webhook, call the agent, post the reply: it works in an afternoon.

**Platforms send things twice.** Telegram and WhatsApp resend until they get a quick `200`; Slack sometimes sends one message as two events. Answer slowly and you get the same question again, and answer it twice.

**The bot answers itself.** Some of the bot's own posts come back as new messages. Get the check wrong and it talks to itself.

**Who is this?** A Slack user has to be linked to an account in your app. The sign-in link opens in a browser that might be logged in as someone else.

**People leave.** Someone removed from the company still has a linked Slack account, and a schedule still delivering to them.

**Buttons are pressed by anyone.** An approval card in a shared channel can be clicked by whoever sees it, and twice by someone impatient.

**"Streaming" by editing.** Editing a message every half second to look like streaming hits rate limits and sends a notification per edit.

## What Latch does

**Every delivery is claimed once,** through a small store you provide (a table with an expiry), so a resend or a duplicate event is dropped on any server. The reply happens after the quick `200`, so platforms stop resending.

**The bot knows its own id,** and ignores its own messages by that, not by guessing from their shape.

**Unknown users are asked to link, and nothing is lost.** Your `authorizeUser` decides who a platform user is, or returns a sign-in link. The message that triggered it is held and answered once they've linked.

**Your check runs on every message,** so removing someone in your app stops the bot for them on their next message.

**Buttons belong to someone.** In Slack, approval buttons are signed and carry the person allowed to press them. In every app, a second press is ignored.

**Answers stream where the platform really streams** (Slack) and are posted once, when done, everywhere else, with a typing indicator meanwhile.

<Callout title="The link page is yours">
The page a sign-in link opens lives in your app. Show both accounts, the chat account and the one logged in to your app, and ask before joining them: the link may be opened in a browser logged in as someone else.
</Callout>

One conversation, wherever it happens: a chat in Slack is a chat in storage like any other, with history, limits, approvals and crash recovery.

How it fits: [Channels](/docs/concepts/channels). In depth: [Chat apps in depth](/docs/guides/channels).


---

# Connecting accounts

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/connections

The user asks the agent to file a ticket in their Linear. The agent needs the Linear tools, signed in as *that* user. MCP makes the tools easy; the account is the hard part.

**The user isn't signed in yet.** The agent is mid-answer. Failing is unhelpful, and sending the user to a settings page loses the conversation.

**The sign-in comes back somewhere else.** The OAuth redirect lands in a browser tab, maybe a different one, maybe hours later. Something has to connect that sign-in to the right person and continue the right conversation.

**Tokens expire.** Many access tokens last an hour. Some providers only hand out a refresh token if you ask the right way, and the connection quietly dies an hour later if you don't.

**The model shouldn't see the token.** Anything in the prompt or a tool result can end up in an answer, a log, or a trace.

**The address can lie.** A server URL that looks fine can resolve to an address inside your network when the request is made, carrying the user's credentials with it.

## What Latch does

**Asking to sign in is part of the conversation.** Until a user connects a service, the agent has one tool for it: `connect_linear`. Calling it pauses the answer like an approval, showing a sign-in button. After the redirect, the client approves it, and the answer continues with the Linear tools.

**Tokens live in the vault,** encrypted per secret, stored for the person who signed in, refreshed when they expire, and attached to requests outside the model. The model sees tool results, never credentials.

**Servers are checked where it counts.** Only the hosts you allow can be reached, and the check is on the address the request actually connects to, not just the URL text.

**Tools that change things can ask first.** By default, tools the server marks as destructive, or as not read-only, need approval. Servers don't always mark their tools, so for a service that can do damage, set `approval: "all"` or choose per tool.

```ts
createMcpConnections({
  vault,
  allowedHosts: ["mcp.linear.app"],
  callbackBaseUrl: "https://app.example.com/api/latch/connections",
  connections: {
    linear: { url: "https://mcp.linear.app/mcp", auth: oauth() },
  },
});
```

<Callout title="Registering with the provider">
`oauth()` registers your app with the server on first use, which works for servers that allow it (Linear, most MCP servers). Google and most large providers need an app you registered yourself: pass `oauth({ clientId, clientSecret })`.
</Callout>

How to set it up: [Connect other services](/docs/guides/connections).


---

# Running on many servers

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/durability

An answer can take a minute: the agent thinks, calls tools, thinks again. Somewhere in that minute a deploy restarts the server, or the process crashes. The user is left with half an answer, or none.

"Resume after a crash" sounds like saving progress and picking it up again. On one server, it nearly is. Production is rarely one server.

**Five servers behind a load balancer.** A request can land on any of them. When one dies mid-answer, which of the other four picks it up? If two do, the answer is written twice and every tool it calls runs twice.

**The server that wasn't dead.** A long pause, a network blip: everyone decides a server is gone, another one takes over its run, and then the first one wakes up and carries on writing.

**Clocks that disagree.** If each server judges "has this run been abandoned?" by its own clock, a server running a few seconds fast takes over a run that's still alive.

## What Latch does

**The database is the referee.** A running answer holds a lease on its run, a claim that expires unless renewed. The server running it renews it every few seconds. If the server dies, the lease runs out, and on the next tick exactly one other server takes the run over and finishes the answer. Servers never need to know about each other.

**A server that lost its run is fenced out.** Each takeover gets a new number, and every write carries the number of the run it belongs to. A server that wakes up with an old number finds its writes refused, notices, and stops.

**One clock.** Lease times come from the database's clock, so servers whose clocks drift apart still agree.

**Answers survive the browser too.** A closed tab doesn't stop an answer: it keeps going on the server and is saved, ready when the user comes back.

```ts
createRuntime({ …, durability: { enabled: true } });

// Every minute: from a cron route on serverless,
// or an interval on a long-running server.
await runtime.cron();
```

With `createLatchApp` from `@intentface/latch-server`, both are on by default. On serverless, point your platform's cron at a route that calls `runtime.cron()`; see [Deploy on Vercel](/docs/guides/deploy-on-vercel).

<Callout title="Already on Temporal or Durable Objects?">
If your platform already guarantees one instance per conversation, it covers this part. Latch's job there is the rest: conversations, approvals, limits, channels.
</Callout>

<Callout type="warn" title="A tool that was running when the server died">
Recovery picks the answer up from the last finished step. A tool that was in the middle of running at the crash may run again. Tools with side effects, like sending email or charging a card, should be safe to repeat: check whether the action already happened, or pass an idempotency key to the service you call.
</Callout>


---

# Conversation history

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/history

A chat needs to remember. The simple version saves each message to a table and loads them back on the next turn.

That holds until the chat is used like a real one.

**A second tab.** The user has the chat open twice and sends from both. Two answers are being written into one conversation at once, and the history ends up interleaved.

**A retry.** The network drops just after a message arrives. The browser sends it again, and now it's in the history twice, with two answers.

**A client that writes history.** A chat request carries a message from the browser, and the browser can send anything: a message claiming to be the assistant's, a tool result that never happened, a copy of an earlier message with new words. Stored as sent, it becomes "what was said", for the model and for everyone reading the chat afterwards.

**Regenerate.** "Try again" looks like deleting the last answer. But the answer may have been summarized into a compacted context, learned into memory, or approved in another tab. Deleting it leaves those pointing at nothing.

## What Latch does

**One answer at a time per chat.** While an answer is being written, a second message to the same chat is turned away (`409`, with how long to wait) and nothing of it is saved. Two tabs can't interleave.

**Messages come in as messages, not as history.** A chat request may add a user message with text and files, and nothing else. A message claiming another role, a tool result, or the id of an existing message is refused. The one exception is a resend of the chat's last question, explained next.

**The last question can be regenerated or edited.** Resend it unchanged (`useChat`'s `regenerate()`) or changed (`sendMessage({ messageId })`): the old answer is hidden, not deleted, and the agent answers again. Hidden answers are kept for the record but never shown, never sent to the model, and never learned into memory. Changing an earlier question is refused, because everything after it builds on it; that's what forking a chat is for.

**The client stays stock.** All of this works with the AI SDK's own `useChat`, as is.

```ts
// Your chat route: one call per message.
return runtime.handleChat({ agent: "support", chatId, principal, message });

// The sidebar, and a chat's history when the page loads:
await runtime.listChats({ principal, kind: "user" });
await runtime.loadHistory({ chatId, principal });
```

Very long conversations can be [compacted](/docs/concepts/turns#compaction): the model works from a summary while the user still sees everything.


---

# Overview

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts

Most of what an agent product needs sounds simple. Save the conversation. Ask before sending an email. Keep going if the server restarts.

Each one is simple in a demo. Then a user opens a second tab, a server restarts in the middle of an answer, a teammate approves from Slack while someone else approves in the browser, and the simple version starts doing things nobody intended. Often quietly: nothing errors, you just pay twice, or show the wrong answer.

These pages walk through those parts one at a time: what the simple version misses, and what Latch does instead. Each ends with the few lines it takes.

<Cards>
  <Card href="/docs/hard-parts/history" title="Conversation history" description="Saving messages is easy. Two tabs, retries and forged messages are not." />
  <Card href="/docs/hard-parts/approvals" title="Approvals" description="An Approve button, clicked tomorrow, twice, from Slack." />
  <Card href="/docs/hard-parts/durability" title="Running on many servers" description="A crash mid-answer, five servers, and exactly one of them finishing it." />
  <Card href="/docs/hard-parts/access" title="Who sees what" description="Customers, users, teams, and the runs nobody is logged in for." />
  <Card href="/docs/hard-parts/limits" title="Usage limits" description="Caps that hold when five requests arrive in the same moment." />
  <Card href="/docs/hard-parts/attachments" title="Files and images" description="Where files live, how they reach the model, and what never leaves your servers." />
  <Card href="/docs/hard-parts/streaming" title="Streaming" description="Then the user closes the tab, reloads, or sends another message." />
  <Card href="/docs/hard-parts/schedules" title="Schedules" description="A run with nobody there, on every server at once." />
  <Card href="/docs/hard-parts/connections" title="Connecting accounts" description="Signing in mid-conversation, staying signed in, tokens the model never sees." />
  <Card href="/docs/hard-parts/subagents" title="Subagents" description="A second conversation that can pause, crash and cost money." />
  <Card href="/docs/hard-parts/chat-apps" title="Chat apps" description="Platforms that resend, bots that answer themselves, buttons anyone can press." />
  <Card href="/docs/hard-parts/agent-versions" title="Agent versions" description="Someone will edit an agent, break it, and ask what ran last Tuesday." />
</Cards>


---

# Usage limits

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/limits

A free plan gets 20 requests a day. A customer has a monthly token budget. One expensive research agent gets its own cap.

The simple version reads how much was used, compares it with the cap, and lets the request through if it's under.

**Five requests at once.** A user at 19 of 20 has five tabs open. All five requests read "19", all five pass, all five run. The busier the customer, the more often this happens.

**One long answer.** A request admitted at 99% of the token budget can still spend the rest of the day's budget, because an agent keeps going for many steps after it was let in.

**Background work.** A scheduled report and a person chatting probably shouldn't share one limit.

## What Latch does

**Requests are counted before they're checked.** Each request takes its number first, then sees whether it's over. Five requests against one free slot get the numbers 20 to 24, and exactly one runs.

**A long answer stops at the budget.** Once an answer has used up the remaining token budget, it stops there and keeps what it has written, instead of carrying on.

**You decide what counts against what.** For each run, you return the limits it counts against: per organization, per user, per agent, and different for background runs if you like.

```ts
import { DAY_MS } from "@intentface/latch-core";
import { createPostgresLimitStore } from "@intentface/latch-drizzle";

createRuntime({
  …,
  limits: {
    store: createPostgresLimitStore(db),
    limitsFor: ({ principal, agent }) => [
      { key: `user:${principal.userId}`, windowMs: DAY_MS, max: { turns: 20 }, label: "your daily requests" },
      { key: `org:${principal.orgId}`, windowMs: 30 * DAY_MS, max: { tokens: 5_000_000 }, label: "your team's monthly budget" },
      ...(agent === "research" ? [{ key: `agent:${principal.orgId}:research`, windowMs: DAY_MS, max: { cost: 20 } }] : []),
    ],
  },
});
```

A refused request gets `429` with how long until the limit resets, and the label tells the user which limit it was.

<Callout title="What holds exactly, and what doesn't">
Request limits (`turns`) hold exactly, however many requests arrive at once. Token and cost limits are checked against what's already been recorded, so answers that are still running can take them over by what those answers spend. Set those caps with that margin in mind.
</Callout>


---

# Schedules

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/schedules

A user asks for a summary of last week's signups every Monday at 9:00, their time. It looks like a cron job that calls the agent.

**Every server has the cron.** Five servers wake at 9:00 and all five see the schedule is due. The user gets five reports, and you pay for five.

**A server dies mid-report.** Should the next tick run it again? Then a slow crash means two reports. Never again? Then the report is lost.

**Nobody is logged in.** The run needs to act as the user, but there's no request and no session. And since March, the user may have changed teams or left.

**The agent stops to ask.** The report reaches a tool behind approval. Nobody is there to click, and next Monday a second report stops at the same place, and the Monday after.

**"9:00" is somewhere.** 9:00 in Helsinki moves against UTC twice a year.

## What Latch does

**Schedules are rows, not timers.** Every server's cron calls `runtime.cron()`, and each due occurrence is claimed by exactly one of them. The schedule moves to its next time *before* the run starts, so an occurrence fires at most once. If the server dies mid-run, the run itself is picked up by [crash recovery](/docs/hard-parts/durability), not fired a second time.

**The user is checked again on every fire.** The schedule stores who created it. Each fire rebuilds that person through your `reconstructPrincipal`; return `null` and the schedule stops.

**A run that stops for a person waits once.** By default, the next occurrence is skipped while the last one is still waiting, so one unanswered question doesn't become a pile of them. Chats waiting on someone are easy to list, and you can be told when one happens.

**Times are in the user's time zone,** worked out by the cron library you pass in.

```ts
await runtime.schedule({
  principal,
  agent: "reporter",
  cron: "0 9 * * 1",
  timezone: "Europe/Helsinki",
  prompt: "Summarise last week's signups.",
});
```

The report can also land in the thread the user actually reads, in Slack or Telegram, instead of a new chat.

<Callout title="Running it">
Point your platform's cron at `runtime.cron()` every minute, or use `createIntervalScheduler` on a long-running server. The same call drives crash recovery.
</Callout>

How to set it up: [Run agents on a schedule](/docs/guides/schedules).


---

# Streaming

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/streaming

The answer should appear as it's written. The AI SDK streams it to the browser, and on one happy path that's all there is to it.

**The user closes the tab.** Should the answer stop? If the server stops with the connection, the user comes back to half an answer, and the tools it ran are left half done.

**The user reloads.** The page loads the saved conversation. Where is the answer that's still being written?

**The user sends another message.** The first answer is still running. Two answers at once write into the same conversation in an order nobody chose.

**An approval comes back later.** After the click, the answer continues. Is that a new message, or the rest of the old one?

**The server dies mid-sentence.** Another server finishes the answer, but the browser was connected to the one that died.

## What Latch does

**The answer doesn't depend on the browser.** Closing the tab ends the stream, not the run: the answer finishes on the server and is saved, ready when the user comes back.

**One answer at a time per chat.** A message sent while an answer is running gets `409 chat_busy` with how long to wait, and nothing is saved, so the client can resend it.

**With crash recovery on, every step is saved as it finishes.** A reload shows the answer up to its last finished step; the rest appears when it's done. A server that took over a crashed answer saves to the same place.

**A continued answer is the same message.** After an approval, the stream continues the paused assistant message (same id) instead of starting a new one, so the UI fills in where it stopped.

<Callout type="warn" title="What isn't there yet">
A reloaded page doesn't re-attach to an answer that's still streaming; it shows finished steps, then the whole answer once saved. The `stop` button in `useChat` ends the stream in the browser, but the answer keeps running on the server and is saved. Someone else viewing a shared chat sees an answer once it's saved, not live.
</Callout>

In chat apps, streaming is real or not at all: Slack streams natively, other platforms get the finished answer. See [Chat apps](/docs/hard-parts/chat-apps).


---

# Subagents

Source: https://intentface-latch-docs.vercel.app/docs/hard-parts/subagents

A support agent hands a research question to a researcher agent and uses the answer. It looks like a tool that calls another agent.

**Whose context is it?** If the researcher's twenty tool calls land in the support conversation, the support agent's context fills with someone else's work.

**The researcher needs approval.** It wants to run a tool behind approval. The person who can approve is talking to the *support* agent, and the researcher has no conversation of its own with them.

**A server dies halfway.** The support answer is waiting on the researcher. Recovering one without the other leaves an answer that never finishes.

**Agents calling agents calling agents.** Without a limit, a loop of delegation can run up a bill before anyone notices.

**The cost goes somewhere.** A researcher's tokens count against someone's budget. Whose?

## What Latch does

**Each subagent gets its own thread.** It's saved like any conversation, marked internal so your chat list can leave it out (`listChats({ kind: "user" })`), and the parent can continue it later with full context. The parent only sees the answer.

**Approvals come up to the person.** A tool waiting for approval inside the researcher pauses the support answer too, and shows up in the user's chat as an approval card. One click, and the researcher continues, then the support agent.

**Recovery follows the tree.** A resumed subagent run is rebuilt exactly as the parent shaped it, and finishes inside the parent's answer.

**Delegation has a limit.** Subagents can go four levels deep, then the tool to delegate isn't offered.

**It's the same person's budget.** A subagent run is checked against the same [limits](/docs/hard-parts/limits) as the person who started it, and its cost is recorded.

```ts
defineAgent({
  model,
  instructions: "Hand research questions to the researcher.",
  subagents: ["researcher"],
});
```

The agent gets a `spawn_agent` tool, and progress from the researcher streams into the parent's answer as it works.


---

# Memory

Source: https://intentface-latch-docs.vercel.app/docs/memory

<Callout type="warn" title="Experimental">
Memory is experimental. The only production store today is a Loredex workspace, and the interface still carries Loredex conventions. Expect the design to change; don't build on it for anything you can't adjust later.
</Callout>

Memory lets an agent carry what it learned in one conversation into the next. It exists because an assistant that is no better on day thirty than on day one is not an assistant; and because building extraction, storage and injection per project is exactly the plumbing Latch is for.

**What it makes possible.** An agent that remembers a user's preferences and context; an org-shared agent whose memory a whole team benefits from, next to personal memory that never leaks; a background sweep that needs only the cron you already run; per-principal prompt caching that stays correct when the prompt differs per user.

## Memory — persistent facts across sessions

```ts
defineAgent<Principal>(() => ({
  model,
  memory: { scope: "agent-user", connection: "loredex" },
}));
```

With a `MemoryProvider` wired, each turn gets the provider's compiled index appended to the instructions plus `memory_search`/`memory_save` tools; background jobs handle extraction and consolidation out of band.

The load-bearing part is that **scopes are hard boundaries**:

| Scope | Remembers |
| --- | --- |
| `agent-user` | this agent, for this user |
| `agent-org` | this agent, shared across the org |
| `user` | this user, across agents |

Tools are path-locked at construction and consolidation never crosses a scope, so a personal agent's memory and a company-wide agent's memory differ by *declaration*, not by convention. `@intentface/latch-memory` is the implementation.

## How memory grows

The runtime does not extract during a turn. `@intentface/latch-memory` runs a background job — driven by the same cron as everything else — that finds idle conversations, extracts episodes, consolidates them under the scope's schema, and rebuilds the compiled index the next turn injects. Failed extractions retry after a backoff; a window that yielded nothing is tombstoned so it is never re-read; a schema edit re-opens it.

## Next

- [Reference: packages — latch-memory](/docs/reference/packages#latch-memory)
- [Hooks: prompt caching](/docs/reference/hooks#prompt-caching--the-6-default) — why memory-enabled agents get a per-principal cache key


---

# Agent store

Source: https://intentface-latch-docs.vercel.app/docs/reference/agent-builder

The store behind [agents in a database](/docs/guides/agents-in-a-database). Create one with `createPostgresAgentBuilderStore` or `createSqliteAgentBuilderStore` from [`@intentface/latch-drizzle`](/docs/reference/drizzle), and pass its `resolve` and `list` to `createRuntime`'s `dynamicAgents`.

Agents have a scope: `"org"` agents are shared by an organization, `"user"` agents belong to one person. A name can pin a version or candidate: `support@7` runs version 7, `support@c_…` a candidate.

| Option | Type | Description |
| --- | --- | --- |
| `resolve` | `(name: string, principal: P, ctx?: DynamicAgentResolveContext) => AgentConfig<P> \| Promise<AgentConfig<P> \| undefined>` | Build an agent's config for this caller, or undefined if not found. |
| `list` | `(principal: P) => AgentInfo[] \| Promise<AgentInfo[]>` | The caller's dynamic agents, for the picker. |
| `listRecords` | `(p: P) => Promise<AgentRecord[]>` | Full records visible to the caller (org + personal), for the builder/editor. |
| `getRecord` | `(p: P, name: string) => Promise<AgentRecord \| undefined>` | One record by name — personal wins over org on a clash, unless the name is pinned with an `org:`/`user:` prefix (see `parseAgentRef`), which is how channel bindings and run records refer to an agent. |
| `upsert` | `(p: P, rec: AgentRecord, opts?: AgentPublishOptions) => Promise<void>` | Create or update by (owner, name), and PUBLISH: the record becomes live for the next turn and is appended to the agent's version history. Callers pass a NORMALIZED record (`normalizeAgentRecord`). Publishing a record identical to the current version appends nothing — the history stays a list of real changes. `opts` only decorates the history row. |
| `remove` | `(p: P, scope: AgentScope, name: string) => Promise<void>` | Delete the agent, its version history and (host tables aside) its drafts. |
| `listVersions` | `(p: P, name: string, scope: AgentScope) => Promise<AgentVersionMeta[]>` | Publish history for one agent, newest first. Empty for an agent that has never been published through this store (e.g. rows written before versioning, if the backfill has not run). |
| `getVersion` | `(p: P, name: string, scope: AgentScope, version: number) => Promise<AgentRecord \| undefined>` | The full record as published at one version, for preview, diff or restore. |
| `activateVersion` | `(p: P, name: string, scope: AgentScope, version: number, opts?: { actor?: string; }) => Promise<void>` | Make an EXISTING version live — rollback. Moves the published pointer and copies that version's record into the live row; it does NOT append a version, so history stays a list of distinct configurations rather than growing a duplicate every time someone flips between two of them. Nothing is lost: every version between the two remains, and the move is reversible. Distinct from `upsert`, which publishes NEW content. "Restore an old version and edit it before shipping" is the host's job: `getVersion` into a draft, then `upsert` when the user is ready. |
| `listActivations` | `(p: P, name: string, scope: AgentScope, limit?: number) => Promise<AgentActivation[]>` | The publish timeline, newest first: which version became live, when, and by whom. Distinct from `listVersions`, which is the set of distinct CONFIGURATIONS — activating an old version appends no version, so only this records that it happened. |
| `getDraft` | `(p: P, name: string, scope: AgentScope) => Promise<AgentDraft \| undefined>` | The CALLER's unpublished working copy, if any. |
| `saveDraft` | `(p: P, name: string, scope: AgentScope, rec: AgentRecord, baseVersion?: number) => Promise<void>` | Create or replace the caller's working copy. Never touches the live record. |
| `discardDraft` | `(p: P, name: string, scope: AgentScope) => Promise<void>` | Drop the caller's working copy (discarded, or published and no longer needed). |
| `listDrafts` | `(p: P) => Promise<AgentDraftSummary[]>` | Every draft under the caller's visible owners — theirs and colleagues' — for the agent list's "unpublished draft" badge and the editor's "someone else is editing this" banner. Metadata only; a colleague's in-progress text is not the caller's to read. |
| `listCandidates` | `(p: P, name: string, scope: AgentScope, opts?: { closed?: boolean; }) => Promise<AgentCandidate[]>` | The agent's candidates, newest first; closed ones only when asked. |
| `getCandidate` | `(p: P, name: string, scope: AgentScope, id: string) => Promise<AgentCandidate \| undefined>` | One candidate by id, open or closed. |
| `saveCandidate` | `(p: P, name: string, scope: AgentScope, c: { id?: string; title: string; record: AgentRecord; baseVersion?: number; }) => Promise<AgentCandidate>` | Create (no `id`) or update one. Never touches the live record. Returns the saved candidate. Updating a closed candidate is refused: what was published or discarded stays as it was. |
| `closeCandidate` | `(p: P, name: string, scope: AgentScope, id: string, opts?: { publishedAs?: number; }) => Promise<void>` | Close it — discarded, or published as `publishedAs`. It still resolves. |
| `listMemoryEnabledNames` | `() => Promise<string[]>` | For background jobs only, across every customer: the names of agents that may have memory switched on. A quick filter, not an answer: check each one again with `getRecord` as the chat's own principal before acting on it. |
| `purgeOwner` (optional) | `((segments: string[], opts?: { batchSize?: number; }) => Promise<PurgeResult>)` | OPTIONAL. Delete everything this store keeps (agents, versions, activations, candidates, and the drafts a person is writing) for an owner path, in passes (see `StorageAdapter.purgeOwner`): call until `done`. Without it, `runtime.purgeOwner` refuses when this store is passed to it. |


---

# Channels

Source: https://intentface-latch-docs.vercel.app/docs/reference/channels

What channels are for: [Channels](/docs/concepts/channels). What they handle for you: [Chat apps](/docs/hard-parts/chat-apps).

Each platform is a subpath, and each channel has one `handleRequest(request)` for its webhook route.

## Telegram

```ts
import { telegramChannel } from "@intentface/latch-channels/telegram";
```

| Option | Type | Description |
| --- | --- | --- |
| `agent` | `string` | The agent this bot answers as, by name. |
| `botRef` | `string` | Your own id for this bot, for example its row id in your database. Chat ids are built from it (`telegram_<botRef>_<userId>`), so changing the bot's token or account later keeps every conversation. |
| `botToken` | `TelegramToken` | The bot's token from BotFather, or a function that reads it, for example from the vault. |
| `webhookSecret` | `string \| (() => string \| Promise<string>)` | The secret you registered with Telegram's `setWebhook`; requests without it are refused. |
| `runtime` | `ChannelRunOps<P>` | Your runtime. |
| `authorizeUser` | `(user: TelegramUser) => Promise<AuthResult<P>>` | Who this Telegram user is in your app. Return `{ allowed: true, principal }`, or `{ allowed: false, linkUrl }` to send them a link to connect their account; their message is held (`stashPending`) and answered once they have. Runs on every message, so removing someone in your app stops the bot for them. |
| `seen` (optional) | `((deliveryKey: string) => Promise<boolean>)` | Returns `true` if this delivery was seen before, and records it if not. Telegram resends until it gets a quick reply; back this with a table with an expiry so a resend is ignored on any server. |
| `claimTurn` (optional) | `((chatId: string) => Promise<string \| null>)` | Only for a storage adapter that doesn't refuse a second answer per chat itself; the built-in ones do. |
| `releaseTurn` (optional) | `((chatId: string, token: string) => Promise<void>)` |  |
| `stashPending` (optional) | `((input: TelegramUser & { text: string; media?: InboundMedia<TgFileRef>[]; }) => Promise<void>)` | Keeps the message from a user who still has to connect their account, to answer after they do. |
| `authorizeUrl` (optional) | `((connectionName: string, ctx: { chatId: string; telegramUserId: string; approvalId: string; }) => string)` | The URL that starts signing in to a service when the agent asks the user to connect it. |
| `transcribe` (optional) | `TranscribeAudio` | Turns voice notes into text. Without it, the user is asked to type instead. |
| `askState` (optional) | `AskStateStore` | Keeps the state of a question the agent asked with buttons, between taps. |
| `thread` (optional) | `ChannelThreadStore` | Remembers the user's current conversation, so `/new` can start a fresh one. |
| `waitUntil` (optional) | `((p: Promise<unknown>) => void)` | Keeps a serverless function running after the quick reply to Telegram, for example Vercel's `waitUntil`. |
| `apiOptions` (optional) | `TelegramApiOptions` | For tests: a different Bot API URL or `fetch`. |

## Slack

```ts
import { slackChannel } from "@intentface/latch-channels/slack";
```

| Option | Type | Description |
| --- | --- | --- |
| `agent` | `string` | The agent this Slack app answers as, by name. Each agent has its own Slack app. |
| `appId` | `string` | The Slack app's id. Chat ids are built from it, so two apps never share a conversation. |
| `principal` | `P` | Who the bot acts as, unless `authorizeUser` returns a principal for the person asking. |
| `botToken` | `SlackToken` | The workspace's bot token, or a function that reads it, for example from the vault. |
| `runtime` | `ChannelRunOps<P>` | Your runtime. |
| `botUserId` | `string` | The bot's own Slack user id, from installing the app. The bot ignores its own messages by this id, so it never answers itself. |
| `teamId` | `string` | The workspace the app is installed in. Needed to stream answers in channels. |
| `signingSecret` | `string \| (() => string \| Promise<string>)` | The app's signing secret; requests not signed with it are refused. |
| `waitUntil` (optional) | `((p: Promise<unknown>) => void)` | Keeps a serverless function running after the quick reply to Slack, for example Vercel's `waitUntil`. Without it the reply waits for the whole answer, which is fine on a long-running server but makes Slack retry on serverless. |
| `claimOnce` | `(key: string, ttlMs: number) => Promise<boolean>` | Claims a key for a while: returns `true` for the first call, `false` while it's held. Back it with a table with an expiry. It's how Slack's retries, one message arriving as two events, and repeated button presses are handled once, on any server. |
| `claimTurn` (optional) | `((chatId: string) => Promise<string \| null>)` | Only for a storage adapter that doesn't refuse a second answer per chat itself; the built-in ones do. |
| `releaseTurn` (optional) | `((chatId: string, token: string) => Promise<void>)` |  |
| `threads` | `SlackThreadStore` | Remembers who started each thread, and whether the bot was told to stop in it. |
| `authorizeUser` (optional) | `((user: { teamId: string; userId: string; channelId: string; threadRoot: string; anchorTs?: string; prompt: string; }) => Promise<AuthResult<P>>)` | Decides whether this Slack user may use the bot, and as whom. Return `{ allowed: true, principal }` to answer as that person (with their own connected accounts), `{ allowed: false, linkUrl }` to ask them to connect their account, or add `silent: true` to refuse without replying. |
| `authorizeUrl` (optional) | `((connectionName: string, ctx: { chatId: string; channelId: string; threadTs: string; userId: string; approvalId: string; }) => string)` | The URL that starts signing in to a service when the agent asks the user to connect it. |
| `onLifecycle` (optional) | `((info: { type: "tokens_revoked" \| "app_uninstalled"; teamId: string; }) => Promise<void>)` | Called when the app is uninstalled or its tokens are revoked, so you can turn the bot off. |
| `onStreamMode` (optional) | `((info: { chatId: string; mode: "stream" \| "fallback"; code?: string; detail?: string; }) => Promise<void>)` | Called when Latch decides whether to stream an answer or post it when done, with Slack's reason when streaming was refused. Save it to show why a bot stopped streaming. |
| `apiOptions` (optional) | `SlackApiOptions` | For tests: a different Slack API URL or `fetch`. |

## WhatsApp

```ts
import { whatsappChannel } from "@intentface/latch-channels/whatsapp";
```

| Option | Type | Description |
| --- | --- | --- |
| `agent` | `string` | The agent this number answers as, by name. |
| `botRef` | `string` | Your own id for this number. Chat ids are built from it (`whatsapp_<botRef>_<waId>`), so changing credentials keeps every conversation. |
| `accessToken` | `LazyString` | Meta's access token for the WhatsApp Cloud API, or a function that reads it. |
| `phoneNumberId` | `LazyString` | The WhatsApp phone number id from Meta. |
| `appSecret` | `LazyString` | The Meta app secret; webhook requests not signed with it are refused. |
| `verifyToken` | `LazyString` | The verify token you entered when setting up the webhook in Meta. |
| `runtime` | `ChannelRunOps<P>` | Your runtime. |
| `authorizeUser` | `(user: WhatsappUser) => Promise<AuthResult<P>>` | Who this WhatsApp user is in your app. Return `{ allowed: true, principal }`, or `{ allowed: false, linkUrl }` to send them a link to connect their account. Runs on every message. |
| `seen` (optional) | `((deliveryKey: string) => Promise<boolean>)` | Returns `true` if this delivery was seen before, and records it if not. Meta resends webhooks; back this with a table with an expiry. |
| `claimTurn` (optional) | `((chatId: string) => Promise<string \| null>)` | Only for a storage adapter that doesn't refuse a second answer per chat itself; the built-in ones do. |
| `releaseTurn` (optional) | `((chatId: string, token: string) => Promise<void>)` |  |
| `stashPending` (optional) | `((input: WhatsappUser & { text: string; media?: InboundMedia<WaMediaRef>[]; }) => Promise<void>)` | Keeps the message from a user who still has to connect their account, to answer after they do. |
| `authorizeUrl` (optional) | `((connectionName: string, ctx: { chatId: string; waId: string; approvalId: string; }) => string)` | The URL that starts signing in to a service when the agent asks the user to connect it. |
| `askState` (optional) | `AskStateStore` | Keeps the state of a question the agent asked with buttons, between taps. |
| `thread` (optional) | `ChannelThreadStore` | Remembers the user's current conversation, so `/new` can start a fresh one. |
| `waitUntil` (optional) | `((p: Promise<unknown>) => void)` | Keeps a serverless function running after the quick reply to Meta, for example Vercel's `waitUntil`. |
| `apiOptions` (optional) | `WhatsappApiOptions` | For tests: a different Graph API URL or `fetch`. |


---

# Context and tools

Source: https://intentface-latch-docs.vercel.app/docs/reference/context

What these are for, and when to use which: [Principal, context and params](/docs/concepts/inputs).

## The context function

```ts
export const context = async ({ principal }: { principal: Principal }) => ({
  db: dbFor(principal.orgId),
  plan: await planOf(principal.orgId),
});
export type Ctx = Awaited<ReturnType<typeof context>>;

createRuntime({ storage, context, agents });
```

- **Called** at the start of every turn: new messages, the continuation after an approval, crash recovery, schedule fires, subagent runs.
- **Gets** only the principal. That's all Latch has when an answer continues without a request.
- **Never saved**, so it can return clients and functions, and always reads current values.
- **Optional.** Without it, the context is `{}`.

The `{ build }` object from `defineContext` still works but is deprecated: its `request` and `turnContext` are missing whenever an answer continues.

## What a tool receives

Every tool gets what the context function returned, plus what Latch adds, as `options.context`:

```ts
import type { ToolContext } from "@intentface/latch-core";

execute: async ({ reason }, { context }) => {
  const { db, principal, params, messageContext } = context as ToolContext<Principal, Ctx, TicketParams, View>;
  return db.tickets.close(params.ticketId, { by: principal.userId, reason });
},
```

`ToolContext<Principal, Ctx, Params, MessageContext>` is that shape. The cast is still needed: the AI SDK can only infer a tool's context type from a `contextSchema`.

| Option | Type | Description |
| --- | --- | --- |
| `principal` | `P` | Who the run is for: the principal your route passed, or the one restored for a resumed or scheduled run. |
| `run` | `{ id: string; chatId: string; agent: string; }` | The run this tool call belongs to. `agent` is the agent's name as the run resolved it. |
| `writer` | `TurnWriter` | Streams extra parts (for example `data-*` progress) into the answer while the tool runs. |
| `runtime` | `Runtime<P>` | The runtime itself, for tools that schedule, list chats or start runs. |
| `params` | `Params` | What this chat is about (see `defineAgent`'s `params`), or `undefined` for an agent that declares none. Sent by the client: look ids up through something limited to `principal` before trusting them. |
| `messageContext` | `MessageContext` | What the user is looking at, from the `data-context` part of the message being answered; `undefined` when it has none. Sent by the client, like `params`: describes, never authorizes. |

A field your context function returns with one of these names is replaced by Latch's.

## What the agent function receives

| Option | Type | Description |
| --- | --- | --- |
| `context` | `RuntimeContext` | What your `context` function built for this turn, from the principal: clients, the plan, permissions. |
| `principal` | `P` | Who is asking. |
| `messageContext` (optional) | `unknown` | What the user is looking at, from the `data-context` part of the message being answered (checked against `messageContext.schema` when set). The same on a continuation of that answer; `undefined` when the message has none. |
| `turnContext` (optional) | `unknown` |  |
| `params` (optional) | `Params` | What this chat is about: the params its first message passed, checked against `meta.params`. `undefined` for an agent that declares none. |


---

# createRuntime

Source: https://intentface-latch-docs.vercel.app/docs/reference/create-runtime

`createRuntime(config)` builds the runtime: the object your server calls to run chat turns, apply approvals, resume crashed runs and fire schedules. Create it once per process and reuse it.

```ts
import { createRuntime, defineAgent } from "@intentface/latch-core";
import { createPostgresAdapter } from "@intentface/latch-drizzle";

export const runtime = createRuntime<Principal>({
  storage: createPostgresAdapter(db, { owner: (p) => [p.orgId, p.userId] }),
  context: ({ principal }) => ({ orgId: principal.orgId }),
  agents: {
    support: defineAgent({ model: anthropic("claude-sonnet-4-6"), instructions: "…" }),
  },
});
```

Only `storage` and `agents` are required. Every other option switches a capability on; leave it out and that capability doesn't exist. Click a row to see its full type and description.

## Options

| Option | Type | Description |
| --- | --- | --- |
| `storage` | `StorageAdapter<P>` | Where conversations, runs, usage and schedules are stored. Use `createSqliteAdapter` or `createPostgresAdapter` from `@intentface/latch-drizzle`, or your own adapter (check it with `registerStorageConformance` from `@intentface/latch-core/testing`). |
| `context` (optional) | `ContextInput<P, RuntimeContext>` | What your agents and tools can work with, built from who is asking at the start of every turn, including resumed runs, approvals and schedule fires: `async ({ principal }) => ({ db: dbFor(principal.orgId), … })`. Never saved, so it can hold clients and functions and always reads current values. Optional. The `{ build }` object from `defineContext` still works but is deprecated. |
| `messageContext` (optional) | `{ schema?: FlexibleSchema; toModel?: (context: any) => string \| undefined; }` | What the user is looking at, sent as a `data-context` part on their message (the open file, the selection, a model pick). The model reads it as text in that message; chat UIs don't show it; agent functions and tools get the latest one as `messageContext`. It's saved with its message, so a continuation of the answer sees the same values, and the instructions stay the same between messages, which keeps prompt caching working. Works without configuration. `schema` checks the part when a message arrives (a mismatch is refused, 400); `toModel` decides how it reads to the model (default: a `[Context]` block of `key: value` lines). An agent can override `toModel` in its own `defineAgent` meta. |
| `agents` | `Record<string, AgentFactory<P, any, any>>` | Your agents, by name: `{ support: defineAgent({ … }) }`. The name is what clients send to pick an agent. Agents created by users at runtime come from `dynamicAgents` instead. |
| `durability` (optional) | `DurabilityConfig` | Crash recovery: a run cut off by a crash or redeploy is finished by another server, and no two servers work on the same run at once. Without it, a crashed run stays unfinished until the user sends another message. |
| `pricing` (optional) | `Pricing` | Prices per model, so each run records what it cost. Needed for cost-based limits and usage reports. `providerDefaults` from `@intentface/latch-core/models` supplies it from a model catalog. Without it, every run's cost is recorded as 0. |
| `connections` (optional) | `ConnectionRegistry<P>` | Turns the names in an agent's `connections: [...]` into tools, typically MCP servers. Use `createMcpConnections` from `@intentface/latch-mcp`. Without it, an agent's `connections` are ignored. |
| `dynamicAgents` (optional) | `DynamicAgentStore<P>` | Agents that live in a database rather than in code, such as agents your users create in a UI. Looked up by name, per caller, when a name isn't in `agents`. The Agent Builder's store (`createPostgresAgentBuilderStore` or `createSqliteAgentBuilderStore` from `@intentface/latch-drizzle`) provides `resolve` and `list`. Without it, only the agents in `agents` exist. |
| `memory` (optional) | `MemoryProvider<P>` | Long-term memory for agents that declare `memory`: what they learned is added to their instructions, and they get tools to search and save it. Use `createMemoryProvider` from `@intentface/latch-memory`. **Experimental.** The only production store today is a Loredex workspace, and the design may change. Without it, an agent's `memory` setting is ignored. |
| `reconstructPrincipal` (optional) | `ReconstructPrincipal<P>` | Rebuilds the caller's principal when nobody is there to send a request: a scheduled run firing, or a crashed run being resumed. Use it to re-check that the person still has access. Return `null` to refuse, which stops the schedule or ends the run. Without it, the principal stored when the run or schedule was created is used as it is, unchecked. Default: `(identity) => identity`. |
| `cron` (optional) | `((expr: string, afterMs: number, timezone?: string) => number \| null)` | Works out the next time a cron expression fires, so recurring schedules can run. Wire a cron library, for example `(expr, after, tz) => new Cron(expr, { timezone: tz }).nextRun(new Date(after))?.getTime() ?? null` with `croner`. Without it, only one-off schedules can be created. |
| `runSchedule` (optional) | `((input: { schedule: ScheduleRecord; principal: P; }) => Promise<boolean \| { handled: boolean; chatId?: string; parked?: boolean; }>)` | Runs a scheduled fire yourself, for example to post the result into a Slack channel or a Telegram chat instead of a new web chat. Return `true` when you handled it; return `false` to let Latch run it in a new chat as usual (a stale or missing target, say). A throw, or a result that's neither, counts as an error for that occurrence and nothing else runs, since the hook may already have done the work: once you've delivered, return `true` even if a later step fails. Return `{ handled: true, chatId, parked }` to tell Latch which chat the fire ran in and whether it is waiting on a person; schedules that skip a fire while the previous one waits need that. Without it, every scheduled fire runs in a new chat. |
| `sandbox` (optional) | `((args: { principal: P; chatId: string; agent: string; provider?: string; }) => Experimental_SandboxSession \| Promise<Experimental_SandboxSession>)` | Provides a sandbox (a workspace with files and a shell) for agents that set `sandbox: true`, one per chat. Return an AI SDK `Experimental_SandboxSession` from any provider: a simulated shell, a Daytona VM, and so on. Without it, `sandbox: true` on an agent has no effect. |
| `harnessTools` (optional) | `{ sandbox?: Record<string, unknown>; app?: Record<string, unknown> \| ((args: { principal: unknown; chatId: string; agent: string; }) => Record<string, unknown> \| Promise<Record<string, unknown>>); render?: Record<string, unknown>; approval?: Record<string, unknown>; }` | Built-in tools to offer agents: shell and file tools that work in the sandbox, and app tools such as web fetch, asking the user a question and scheduling. `defaultHarnessTools()` from `@intentface/latch-core/harness` gives you the standard set. Each agent picks them up through its `sandbox` and `defaultTools` settings. |
| `resolveProviderTools` (optional) | `((modelId: string) => Record<string, unknown>)` | Returns the model provider's own web tools (its server-side search and fetch) for a model id, for agents that set `providerWebTools`. Return `{}` for a provider without them. `providerDefaults` supplies it. |
| `resolveReasoningOptions` (optional) | `((modelId: string, effort: string) => { providerOptions?: Record<string, unknown>; maxOutputTokens?: number; } \| undefined)` | Turns an agent's `effort` setting into the provider-specific reasoning options for a model. Return `undefined` to leave the provider's default, for example for a model that doesn't reason. `providerDefaults` supplies it. Without it, `effort` on an agent has no effect. |
| `resolvePromptCaching` (optional) | `((modelId: string, args: { agent: string; memoryScoped: boolean; principal: P; }) => PromptCachingPlan \| undefined)` | Controls prompt caching per model. Return `undefined` for a model to turn caching off for it. `memoryScoped` is true when the system prompt differs per person (an agent with memory), which matters for how OpenAI routes its cache. Without it, caching is on for Anthropic and OpenAI models, which can cut the cost of tool-heavy agents several times over. Turn it off everywhere with `resolvePromptCaching: () => undefined`, or for one run with `promptCaching: false`. |
| `telemetry` (optional) | `LatchTelemetry<P>` | Observes runs: tracing, per-step and per-run callbacks. Use `@intentface/latch-otel` for full tracing, or only the callbacks you need (usage dashboards, alerts when a scheduled run waits on a person). Subagent runs are reported too. Without it, nothing is observed. |
| `onOwnerKeyMismatch` (optional) | `((event: { error: OwnerKeyMismatchError; during: "resume" \| "schedule" \| "turn"; runId?: string; scheduleId?: string; }) => void \| Promise<void>)` | Called when the storage adapter refuses a call because its owner key doesn't match the row (`OwnerKeyMismatchError`). Latch never retries it; crash recovery and schedule firing leave that run or schedule alone, so an interrupted answer stays unfinished until someone looks. Send it to your alerting. It also appears in `cron()`'s `errors`. Without it, the mismatch is logged and nothing else happens. |
| `limits` (optional) | `LatchLimits<P>` | Usage limits per organization, user and agent: requests, tokens and cost per time window. You decide which limits a run counts against; Latch refuses a run over a limit (HTTP 429 with a retry time), stops a long run when it uses up its token budget, and records usage when it finishes. Request limits hold even when many requests arrive at once. Token and cost limits are checked against usage already recorded, so runs that are still going can take them over by what those runs spend. Without it, nothing is limited. |
| `extensions` (optional) | `Extension[]` | Extensions that apply to every run, before each agent's own. An extension can react to points in a run (before a turn, after each step, on finish) to add context, store data or stop the run. |
| `maxInlineFileBytes` (optional) | `number` | The most bytes of files a user message may carry inline (as `data:` URLs, which is how `useChat` sends attachments by default). Inline files travel in the chat request and are stored in the message, then reloaded on every turn; a message over the limit is refused (`InvalidMessageError`, HTTP 400). Send larger files by URL. Default: `20 MB`. |

## durability

Crash recovery. Turn it on, then call `runtime.cron()` on a timer: that is what finds and resumes interrupted runs.

```ts
createRuntime({ …, durability: { enabled: true } });
```

| Option | Type | Description |
| --- | --- | --- |
| `enabled` (optional) | `boolean` | Turn crash recovery on. You also need to call `runtime.cron()` regularly (for example from a cron route every minute): that is what finds and resumes interrupted runs. Default: `false`. |
| `leaseTtlMs` (optional) | `number` | How long a server's claim on a run lasts without being renewed, in ms. When a server dies, its runs are picked up once this has passed, so a crashed run is resumed after roughly this long plus your cron interval. Default: `30_000`. |
| `heartbeatMs` (optional) | `number` | How often a server renews its claim while a run is going, in ms. Keep it well below `leaseTtlMs`, so a slow renewal never lets a live run expire. Default: `10_000`. |
| `instanceId` (optional) | `string` | A name for this server, recorded on the runs it claims. Set it to something stable (a pod or instance name) to see in the database which server owns which run. Default: `a random id per process`. |

## limits

Usage limits. `limitsFor` returns the limits a run counts against; each limit is a counter, a window and caps.

```ts
import { DAY_MS } from "@intentface/latch-core";
import { createPostgresLimitStore } from "@intentface/latch-drizzle";

createRuntime({
  …,
  limits: {
    store: createPostgresLimitStore(db),
    limitsFor: ({ principal }) => [
      { key: `org:${principal.orgId}`, windowMs: 30 * DAY_MS, max: { tokens: 5_000_000 }, label: "your organization's monthly budget" },
      { key: `user:${principal.orgId}:${principal.userId}`, windowMs: DAY_MS, max: { turns: 200 }, label: "your daily requests" },
    ],
  },
});
```

| Option | Type | Description |
| --- | --- | --- |
| `store` | `LimitStore` | Where the counters are kept: `createPostgresLimitStore` or `createSqliteLimitStore` from `@intentface/latch-drizzle`. |
| `limitsFor` | `(input: { principal: P; agent: string; trigger: TurnTrigger; }) => LimitPolicy[] \| Promise<LimitPolicy[]>` | Returns the limits this run counts against. Called before every run, so keep it fast; reading the customer's plan from a cache is fine. Return `[]` for no limits. A typical setup returns one limit per level: the organization's monthly tokens, the user's daily requests, and a tighter budget for an expensive agent. `trigger` lets background runs (schedules, webhooks) count against different limits from people chatting. |
| `onRefused` (optional) | `((input: { principal: P; agent: string; trigger: TurnTrigger; policy: LimitPolicy; usage: LimitUsage; retryAfterMs: number; }) => void \| Promise<void>)` | Called when a run is refused for being over a limit. For metrics and alerts; it must not throw. |

Each limit:

| Option | Type | Description |
| --- | --- | --- |
| `key` | `string` | The counter's name. Runs that return the same key share one counter, so name the level in it: `org:o1` for a whole organization, `user:o1:u1` for one person, `agent:o1:research` for one agent. |
| `windowMs` | `number` | How long one window lasts, in ms: `HOUR_MS`, `DAY_MS`, or your own. Windows are fixed, not rolling: a daily limit resets at midnight UTC. |
| `max` | `{ turns?: number; tokens?: number; cost?: number; }` | The caps within one window. Leave a cap out for no limit on it. |
| `label` (optional) | `string` | How the refusal names this limit to the user, for example `"your organization's daily token budget"`. |

<Callout title="Which caps hold under load">
Request caps (`turns`) are counted before a run is allowed, so many requests arriving together can't all slip through. Token and cost caps are checked against usage already recorded: runs that are still going can take the total over by what they spend.
</Callout>


---

# defineAgent

Source: https://intentface-latch-docs.vercel.app/docs/reference/define-agent

`defineAgent` declares an agent: its model, instructions and what it may do. Pass a config object, or a function that returns one when the agent depends on who is asking.

```ts
import { defineAgent } from "@intentface/latch-core";

// The same for everyone:
const support = defineAgent({
  model: anthropic("claude-sonnet-4-6"),
  instructions: "You answer support questions.",
  tools: { search_docs, create_ticket },
  toolApproval: { create_ticket: "user-approval" },
});

// Depends on the caller: built per turn from the context.
const assistant = defineAgent(({ context }) => ({
  model: context.plan === "pro" ? anthropic("claude-sonnet-4-6") : anthropic("claude-haiku-4-5"),
  instructions: `You work for ${context.orgName}.`,
}), { title: "Assistant", description: "Answers questions about your account." });
```

## Options

| Option | Type | Description |
| --- | --- | --- |
| `configVersion` (optional) | `number` | Which saved version of a database agent this config came from. Set by agent stores that keep versions; leave it out for agents defined in code. Recorded on every run, so an answer can be traced to the exact configuration that produced it. |
| `model` | `LanguageModel` | The model the agent runs on, as an AI SDK model, for example `anthropic("claude-sonnet-4-6")`. |
| `instructions` (optional) | `string` | The system prompt. |
| `tools` (optional) | `Record<string, unknown>` | Tools available on every turn, as AI SDK `tool()` definitions, by name. |
| `toolSources` (optional) | `ToolSource<P>[]` | Tools that are opened for each turn and closed after it, for example from an MCP server. Each source is opened with the caller's principal, so its tools can act as that person. |
| `connections` (optional) | `string[]` | The connections this agent may use, by name (MCP servers and other integrations from the runtime's `connections`). A connection the user hasn't authorized yet appears as a `connect_<name>` tool that starts its sign-in. Names the runtime doesn't know are ignored. |
| `toolApproval` (optional) | `unknown` | Which tools need a person's approval before they run, by tool name: `{ send_email: "user-approval" }`. A gated call pauses the run and shows an approval request; `runtime.applyApproval` continues it. Same format as the AI SDK's `toolApproval`, including the function form. |
| `smoothStream` (optional) | `boolean \| { delayInMs?: number; chunking?: "word" \| "line"; }` | Evens out the speed of streamed text, so it appears word by word instead of in bursts. `true` uses the AI SDK defaults; pass `delayInMs` or `chunking` to tune it. Only affects display. |
| `maxSteps` (optional) | `number \| "unlimited"` | The most model steps (a step is one model call and the tools it calls) a turn may take before it stops. `"unlimited"` lets the agent run until it stops calling tools; use with care, as nothing then stops a runaway loop. Default: `20`. |
| `enabledTools` (optional) | `string[]` | Limits the agent to these tools, by name (connection tools are prefixed, like `github_create_issue`). Sign-in tools such as `connect_<name>` stay available. Default: `all tools`. |
| `toolDescriptions` (optional) | `Record<string, string>` | Replaces tool descriptions for this agent, by tool name: tailor how a connection's tool is described to the model without changing the tool. |
| `subagents` (optional) | `string[]` | Agents this one can hand work to, by name. The agent gets a `spawn_agent` tool: the other agent runs on its own and its final answer comes back as the tool's result. Each handover is a saved thread the agent can continue with the returned `threadId`. A subagent's approval requests are shown in the parent chat. |
| `sandbox` (optional) | `boolean` | Gives the agent a workspace with files and a shell, one per chat, plus the tools that use it: `bash`, `read_file`, `write_file`, `glob`, `grep`. Needs `sandbox` and `harnessTools` in the runtime config. |
| `sandboxProvider` (optional) | `string` | Which sandbox provider to use, for example `"daytona"`. Passed to the runtime's `sandbox` function, which picks the default. |
| `defaultTools` (optional) | `boolean \| string[]` | Adds the built-in app tools: `web_fetch`, `askUser`, `list_schedules`, `cancel_schedule`. `true` adds all of them; a list adds only those. Needs `harnessTools` in the runtime config. |
| `renderTools` (optional) | `boolean` | Adds tools that open web pages in a real browser, for pages that need JavaScript to show their content. Needs `harnessTools.render`, which you supply with a deployed render service. |
| `disableTools` (optional) | `string[]` | Built-in tools to leave out for this agent, by name. |
| `providerWebTools` (optional) | `boolean` | Uses the model provider's own web search and fetch, run on the provider's side. Anthropic offers search and fetch, OpenAI search only; other providers ignore it. Needs `resolveProviderTools` in the runtime config. |
| `effort` (optional) | `"high" \| "low" \| "medium" \| "minimal"` | How hard a reasoning model thinks before answering. Higher is slower and costs more. Ignored by models that don't reason. Needs `resolveReasoningOptions` in the runtime config. Default: `the provider's default`. |
| `memory` (optional) | `MemoryConfig` | Long-term memory across conversations: what the agent learns is added to its instructions on later turns, and it gets tools to search and save memories. Needs `memory` in the runtime config. **Experimental.** The only production store today is a Loredex workspace, and the design may change. |
| `extensions` (optional) | `Extension[]` | Extensions for this agent's runs, applied after the runtime-wide ones. |

## Params

An agent that's always *about* something declares it in the second argument, with a schema. The chat's first message passes the value; every turn after gets it. When to use params: [Principal, context and params](/docs/concepts/inputs#params-what-the-chat-is-about).

```ts
const Ticket = z.object({ ticketId: z.string() });

const support = defineAgent<Principal, Ctx, z.infer<typeof Ticket>>(
  async ({ context, params }) => {
    const ticket = await context.db.tickets.get(params.ticketId);
    return { model, instructions: `You help with ticket ${ticket.id}.`, tools: { reply_to_ticket } };
  },
  { title: "Support", params: Ticket },
);

runtime.handleChat({ agent: "support", chatId, principal, message, params: { ticketId: "T-9" } });
```

Refusals: params that don't match the schema, or sent to an agent without one, get 400 `invalid_params`; different params for an existing chat get 409 `params_fixed`.

## What the function receives

| Option | Type | Description |
| --- | --- | --- |
| `context` | `RuntimeContext` | What your `context` function built for this turn, from the principal: clients, the plan, permissions. |
| `principal` | `P` | Who is asking. |
| `messageContext` (optional) | `unknown` | What the user is looking at, from the `data-context` part of the message being answered (checked against `messageContext.schema` when set). The same on a continuation of that answer; `undefined` when the message has none. |
| `turnContext` (optional) | `unknown` |  |
| `params` (optional) | `Params` | What this chat is about: the params its first message passed, checked against `meta.params`. `undefined` for an agent that declares none. |

## Display, params and message context

The second argument says how the agent appears in a picker, what its chats are about, and how it reads the message's `data-context`:

| Option | Type | Description |
| --- | --- | --- |
| `title` (optional) | `string` | The name shown to users. Default: `the agent's key in `agents``. |
| `description` (optional) | `string` | One line on what the agent does. |
| `hidden` (optional) | `boolean` | Hides the agent from pickers. It still runs when called by name, for example as a subagent. `listAgents()` returns it with this flag set; the HTTP server's agent list leaves it out. |
| `params` (optional) | `FlexibleSchema` | What a chat with this agent is about, as a schema (Zod, Valibot, any Standard Schema): a ticket, a document, a model fixed for the chat. Set on the chat's first message (`handleChat({ params })`, `runAgent`, `schedule`), validated, saved on the chat, and handed to the agent function and its tools on every turn. Most agents need none; an agent without a schema refuses params. Params come from the client. An id in them says which thing the chat is about, not whether the user may see it: load it through something limited to the principal, in the agent function. |
| `messageContext` (optional) | `{ toModel?: (context: any) => string \| undefined; }` | How this agent reads the `data-context` part on user messages, in place of the runtime's `messageContext.toModel`. Return `undefined` to leave it out for this agent. |


---

# Postgres and SQLite

Source: https://intentface-latch-docs.vercel.app/docs/reference/drizzle

`@intentface/latch-drizzle` keeps everything in your own Postgres or SQLite database (including libSQL and Turso), through a [Drizzle](https://orm.drizzle.team) instance you create and own.

## Storage adapters

```ts
import { createPostgresAdapter, migratePostgres } from "@intentface/latch-drizzle";

await migratePostgres(db); // once per deploy: creates and updates the latch_* tables
const storage = createPostgresAdapter<Principal>(db, { owner: (p) => [p.orgId, p.userId] });
```

`createSqliteAdapter` and `migrate` do the same for SQLite. Both adapters take:

| Option | Type | Description |
| --- | --- | --- |
| `owner` | `(principal: P) => string[]` | Whose conversation is whose. Every chat is stamped with this key when it's created, and every read checks it: `[p.orgId, p.userId]` gives each person their own chats, `[p.orgId]` shares them with the whole team. Build it only from the principal your login produced, never from what the client sends. |

Postgres needs a driver that supports transactions (`pg`, `postgres`, Neon's `neon-serverless`), not `neon-http`.

## Everything else in the package

| Export | What it is |
| --- | --- |
| `createPostgresLimitStore(db)`, `createSqliteLimitStore(db)` | Counters for [limits](/docs/guides/plans-and-limits). |
| `createPostgresSecretStore(db, { owner })`, `createSqliteSecretStore(db, { owner })` | Where the vault keeps encrypted secrets. Its `owner` can differ from storage's: shared per team while chats are per person. |
| `createPostgresAgentBuilderStore(db, options)`, `createSqliteAgentBuilderStore(db, options)` | [Agents in a database](/docs/guides/agents-in-a-database), with versions. Options below. |
| `createPostgresMemoryJobStore`, `createMemoryJobStore` | For memory, which is experimental. |
| `schema`, `pgSchema` | The Drizzle table definitions, for your own queries. |

## Agent store options

| Option | Type | Description |
| --- | --- | --- |
| `owner` | `(principal: P, scope: AgentScope) => string[]` | Who owns an agent at each scope: `"org"` agents are shared by the organization, `"user"` agents belong to one person. For example `(p, scope) => scope === "org" ? [p.orgId] : [p.orgId, p.userId]`. |
| `modelFor` | `(id: string) => LanguageModel` | Turns a model id saved with an agent into a model, for example `(id) => gateway(id)`. |
| `loadAuthoredTools` (optional) | `((principal: P, names: string[], agentScope: AgentScope) => Promise<Record<string, unknown>>)` | Loads tools your users wrote themselves, by name, if your app lets them. When omitted, an agent's `authoredTools` are simply not attached. |
| `decorateResolve` (optional) | `((cfg: AgentConfig<P>, record: AgentRecord, principal: P) => AgentConfig<P>)` | Adjusts each agent after it's built from its saved record: add tools every agent gets, change which tools need approval. Return the config to use. |
| `editorKey` (optional) | `((principal: P) => string)` | Who is editing: one person, for their own drafts and for crediting a publish. Separate from `owner`, which a whole organization shares, so two admins never overwrite each other's draft. Default: `the `"user"` scope's owner key`. |

The store's methods: [Agent store](/docs/reference/agent-builder).


---

# Errors and refusals

Source: https://intentface-latch-docs.vercel.app/docs/reference/errors

The runtime refuses in a small number of typed ways. Each carries a stable `code` so a host can match it structurally, and `@intentface/latch-server` maps every one on every run-producing route (a message, an approval decision, a client-tool result).

| Status | `code` | Class | Meaning | What the client does |
| --- | --- | --- | --- | --- |
| `400` | `invalid_message` | `InvalidMessageError` | The chat message isn't one a person can send: another role, a part other than text or a file, an id already in the chat, or inline files over `maxInlineFileBytes`. Nothing was persisted. | Send a new user message; send large files by URL. |
| `409` | `rewind_not_supported` | `RewindNotSupportedError` | An earlier question was regenerated or edited. Only the last question can be changed in place, because later messages (and what the agent did since) build on it. | Fork the chat at that question, or regenerate the last one instead. |
| `409` | `pending_approval` | `PendingApprovalError` | A decision is outstanding on this chat. | Render the approval; resolve it first. |
| `409` | `chat_busy` | `ChatBusyError` | A turn is already running in this chat — two tabs, a retry racing a reply, a brief landing mid-conversation. Nothing was persisted. | Wait `Retry-After` (also `retryAfterMs` in the body), resend the same message. |
| `409` | `chat_conflict` | `ChatConflictError` | The client-chosen chat id exists and isn't the caller's. | Pick another id. |
| `429` | `limit_exceeded` | `LimitExceededError` | A turn, token or cost budget is spent. `retryAfterMs` is when the window turns over. | Wait `Retry-After`; tell the user which budget (`message`). |

Structural checks are exported from core: `isChatBusyError`, `isLimitExceededError`, `isInvalidMessageError`; `@intentface/latch-channels` adds `isPendingApprovalError` and `isChatBusyError` for hosts that only see the runtime through its `ChannelRunOps` view.

## What is not an error

- **A denied approval** is an answer: the turn resumes and the agent says what it will do instead.
- **A parked unattended run** (`status: "awaiting_input"`, `trigger: "schedule"`) is a legitimate outcome — see [Run agents on a schedule](/docs/guides/schedules#when-a-fire-stops-for-a-human).
- **An empty `200` from a decision route** means the turn cannot resume yet because another gate in the same step is undecided.


---

# Hooks

Source: https://intentface-latch-docs.vercel.app/docs/reference/hooks

Every optional capability is a plain option on `createRuntime`: pass it and the capability appears, leave it out and it doesn't exist. The ones with pages of their own: [Connections](/docs/guides/connections) (connections registry, vault), [Memory](/docs/memory), [Limits](/docs/guides/plans-and-limits). The rest are catalogued here.

## Pricing — per-run cost

```ts
import { staticPricing, computeCost } from "@intentface/latch-core";

createRuntime({
  // …
  pricing: staticPricing({
    "claude-sonnet-4-6": {
      inputPer1M: 3,
      outputPer1M: 15,
      cacheReadPer1M: 0.3,
      cacheWritePer1M: 3.75,
    },
  }),
});
```

Without it, recorded cost is `0` — usage is still tracked. `ModelPrice` handles what real invoices actually contain: separate cache-read and cache-write rates, and a `tier` that reprices the whole turn once input exceeds a threshold (long-context pricing).

Cost lands in three places: the message metadata, the run row, and a usage row per turn — so per-tenant COGS is a query, not a reconstruction.

## Prompt caching — the 6× default

On by default for first-party Anthropic and OpenAI models — no configuration
needed. An MCP-heavy agent re-sends its tool catalogue on every step; provider
prompt caching is the difference between a viable and an unviable unit cost:

| Same question, same agent | Input | Cache read | Cost |
| --- | --- | --- | --- |
| Without caching | 33,355 | 0 | $0.1698 |
| With, warm | 29,028 | 27,306 | $0.0290 |

The default derives the provider family from the model object itself and
places the breakpoints for you (tools → system prompt → a rolling request-level
marker, with the volatile date line kept after the system breakpoint so the
daily flip never invalidates it). Supply `resolvePromptCaching` when you own a
model catalog or need per-principal cache routing:

```ts
import { promptCachingPlanFor } from "@intentface/latch-core";

createRuntime({
  // …
  resolvePromptCaching: (modelId, { agent, memoryScoped, principal }) =>
    promptCachingPlanFor(providerOf(modelId), {
      agent,
      // Memory-enabled agents have per-principal system prompts — scope
      // OpenAI's cache routing key so different prefixes don't co-route.
      cacheKeySuffix: memoryScoped ? ownerKey(principal) : undefined,
    }),
});
```

Off switches: `resolvePromptCaching: () => undefined` (everywhere), or
`promptCaching: false` on a single `runAgent` call (the control arm of an A/B
cost comparison). The built-in plan leaves the cache TTL at Anthropic's
5-minute default, so agents whose turns arrive slower than that won't benefit
from it as shipped — Anthropic also offers an explicit `ttl: "1h"` (at a
higher cache-write price), which you can opt into by returning your own plan:
`{ ...cacheControl: { type: "ephemeral", ttl: "1h" } }` from
`resolvePromptCaching`.

## Telemetry — tracing

```ts
import { otelTelemetry } from "@intentface/latch-otel";

createRuntime({
  // …
  telemetry: otelTelemetry({ user: (p) => p.orgId }), // OTLP to OTEL_EXPORTER_OTLP_ENDPOINT
});
```

The option supplies per-run AI SDK telemetry options, an optional trace wrapper around the model call, and a post-turn hook. `latch-otel` implements it over OpenTelemetry: one trace per run, its id derived from the run id, with the AI SDK's model and tool spans under it, sent over OTLP to any backend (`export: langfuse()` for Langfuse). Leave it out and nothing is traced, at zero cost.

## Telemetry hooks — usage, analytics, and "someone needs to be told"

You don't need a tracer to use it. `LatchTelemetry` is also the place for plain callbacks: `onStep` after each generation step, `onRunFinished` after the turn is persisted. Every hook is guarded — a thrown or rejected hook is swallowed, so telemetry can never break a turn.

```ts
createRuntime({
  // …
  telemetry: {
    onStep: (run, step) => metrics.increment("agent.step", { agent: run.agent }),
    onRunFinished: (run, outcome) => analytics.track("turn_finished", { ...run, ...outcome }),
  },
});
```

`run` carries `principal`, `chatId`, `runId`, `agent`, `modelId`, `startedAt`, `depth`, `trigger`, and the subagent linkage (`parentRunId`, `rootChatId`); `outcome` adds `status`, `usage`, `cost`, the error message when it errored, and the turn's messages. Two fields make the hook actionable rather than merely statistical:

- **`depth`** — `0` = top-level turn, `>0` = a subagent turn.
- **`trigger`** — what started the turn, and so whether anyone is watching it: `"chat"` (a user message), `"decision"` (a human answered an approval or a client-handled tool), `"schedule"` (a cron/one-shot fire), `"programmatic"` (`runAgent`), `"resume"` (the durability sweep recovering a crashed run). A subagent inherits its parent's trigger, so a whole delegation tree stays attributable to what set it off.

That combination is how you push a notification exactly when one is warranted — a turn that finishes `awaiting_input` is a person who must be told when the trigger is `"schedule"` or `"resume"`, and just a gate rendering in front of someone who can answer it when it's `"chat"` or `"decision"`:

```ts
telemetry: {
  onRunFinished: async (run, outcome) => {
    if (outcome.status !== "awaiting_input") return;
    if (run.trigger !== "schedule" && run.trigger !== "resume") return;
    // Nobody is at the other end of this turn — go tell its owner.
    await notify(run.principal, { chatId: run.chatId, cost: outcome.cost });
  },
},
```

A tracer and these callbacks are one object: `otelTelemetry({...})` implements `telemetryFor`/`withRunSpan`/`onRunFinished`; add your own `onStep` alongside by wrapping or composing. An impl whose `telemetryFor` returns `undefined` for a run opts that run out of every hook.

## Sandbox — a workspace for the agent

```ts
createRuntime({
  // …
  sandbox: ({ principal, chatId, agent, provider }) =>
    provider === "daytona"
      ? createDaytonaSession({ chatId })
      : createLocalSession({ chatId }),
  harnessTools: { sandbox: sandboxTools, app: appTools },
});
```

Core holds no sandbox backend. It resolves the AI SDK `Experimental_SandboxSession` through this function and passes it to the model; the tools that operate on it come from `harnessTools.sandbox`. One sandbox per chat.

<Callout title="Escalate compute deliberately">
Most agents need well-shaped tools over your own API, not a shell. When a task genuinely needs compute, the rungs are: an embedded JS runtime, a virtual shell, then a real Linux sandbox. Keeping the agent *outside* the sandbox — handing it in as a tool — is what stops sandboxes becoming your unit of scale.
</Callout>

## Harness tools

```ts
harnessTools: {
  sandbox,          // bash / read_file / write_file / glob / grep
  app,              // web_fetch, todo, ask_question — record or per-turn factory
  render,           // headless-browser fetch (separate capability flag)
  approval,         // default gates by tool name, e.g. { create_schedule: "user-approval" }
}
```

`approval` here is a *default* — an agent that names the same tool in its own `toolApproval` wins, so a host can ship a side-effecting harness tool gated by default without freezing that choice for every agent.

## Provider tools and reasoning

```ts
resolveProviderTools: (modelId) =>
  modelId.startsWith("claude")
    ? { web_search: anthropic.tools.webSearch_20260209() }
    : {};

resolveReasoningOptions: (modelId, effort) =>
  modelId.startsWith("claude")
    ? { providerOptions: { anthropic: { thinking: { budgetTokens: budgetFor(effort) } } } }
    : undefined;
```

Both exist so core carries no provider SDK. You don't have to write them: [`@intentface/latch-core/models`](/docs/reference/packages#latch-core) ships `providerDefaults(catalog)`, which supplies these two plus `pricing` and `resolvePromptCaching` from one models.dev-backed catalog. Keep the tool keys consistent across providers (`web_search`, `web_fetch`) and your prompts and rendering stay provider-agnostic.

## Extensions — moments and effects

Every option above adds one capability. `extensions` is how you add a capability of your own: an extension attaches handlers to the **moments** of a turn and returns **effects**, and the runtime composes any number of them with one merge rule per moment. Attach them runtime-wide or per agent; with none attached, a turn is exactly the same as without them.

```ts
import { defineExtension } from "@intentface/latch-core";

const auditTools = defineExtension("audit", (ext) => {
  // Refuse a tool for this tenant. First block wins.
  ext.on("tool_call", ({ toolName }, run) =>
    toolName === "delete_record" && plan(run.principal) === "free"
      ? { block: true, reason: "Deleting records needs the Team plan." }
      : undefined,
  );
  // Tell the customer something after each step. The model never sees it.
  ext.notify((step) => `${step.usage.inputTokens ?? 0} tokens in`);
  // A slash command answered without a model call.
  ext.registerCommand("stats", { description: "Turns this month", handler: async () => `${await count()} turns` });
});

createRuntime({
  // …
  extensions: [auditTools],            // every turn
});
defineAgent({ /* … */, extensions: [x] }); // this agent's turns, after the runtime's
```

A handler returns an effect, never a rewritten world; `undefined` means no effect. The effect type per moment is the permission — you cannot do everything everywhere:

| Moment | Effect | Touches |
| --- | --- | --- |
| `session_start`, `before_agent_start` | `{ inject }` / `{ replace }` | the stored transcript, before the model runs |
| `input` | `{ transform }` / `{ handled, reply }` | the user's text, or answer without the model |
| `register_tools` | a new toolset | tools — additions are capabilities, always active |
| `tool_call` | `{ block, reason }` | a veto; the reason reaches the model as the result |
| `tool_result` | `{ output }` | the stored result **and** what the model reads |
| `instructions` | `{ append }` / `{ replace }` | the system prompt this turn (a lens) |
| `context` | `{ messages }` | the model's view of history this turn (a lens) |
| `step_start` | `{ stop, model, activeTools }` | whether and how the next step runs |
| `step_end` | `{ append }` | parts appended to the reply — stored, and streamed live |
| `render` | `{ hide }` / `{ replace }` | what the customer sees — every part on load, and extension-added parts live; never storage |
| `error`, `agent_end` | — | observe |

Three rules that follow from the table. **Storage keeps the truth**: `tool_result` changes what is stored, so a secret masked there is gone everywhere; `render` changes only the view, for the rarer case where the model should keep the truth and the person should not see it. **Order matters at `step_end`**: handlers run in extension order and the next model call waits for all of them, so list an extension that persists before the ones that observe. **Failure is open by default**: a throwing handler is reported and skipped, so one broken extension never breaks a turn — except one marked `critical`, which fails the run instead, because a persistence handler whose database is down has lost the step, not produced nothing.

Identity reaches every moment as a trailing `run` argument (`run.principal`). Declare its shape once and every handler is typed:

```ts
declare module "@intentface/latch-core" {
  interface Principal { orgId: string; userId: string }
}
```

Not available to extensions yet: per-step re-evaluation of `instructions`/`context` (both apply to the turn's prompt), live `render` of the model's own text, and an extension contributing routes, tables or jobs. Runtime-wide extensions also render on `loadHistory`; an agent's own do not, since no agent is resolved there.


---

# Connections (MCP)

Source: https://intentface-latch-docs.vercel.app/docs/reference/mcp

`@intentface/latch-mcp` gives agents the tools of MCP servers, signed in as each user. What it's for: [Tools and connections](/docs/concepts/tools-connections). Setting it up: [Connect other services](/docs/guides/connections).

```ts
import { createVault } from "@intentface/latch-core";
import { createPostgresSecretStore } from "@intentface/latch-drizzle";
import { createMcpConnections, oauth, bearer } from "@intentface/latch-mcp";

const vault = createVault<Principal>({
  store: createPostgresSecretStore(db, { owner: (p) => [p.orgId, p.userId] }),
  masterKey: process.env.LATCH_MASTER_KEY!, // base64 of 32 random bytes
});

createRuntime({
  …,
  connections: createMcpConnections({
    vault,
    secrets: vault.resolver,
    allowedHosts: ["mcp.linear.app", "mcp.internal.example.com"],
    callbackBaseUrl: "https://app.example.com/api/latch/connections",
    connections: {
      linear: { url: "https://mcp.linear.app/mcp", auth: oauth() },
      crm: { url: "https://mcp.internal.example.com/crm", auth: bearer({ getToken: (p) => vault.resolver(p, "crm_token") }) },
    },
  }),
});
```

## createMcpConnections

| Option | Type | Description |
| --- | --- | --- |
| `vault` | `Vault<P>` | Where each user's tokens are kept, encrypted. From `createVault`. |
| `secrets` (optional) | `SecretResolver<P>` | Reads secrets for `bearer` connections, usually `vault.resolver`. |
| `allowedHosts` | `string[] \| ((principal: P) => string[] \| Promise<string[]>)` | The hosts connections may reach, for example `["mcp.linear.app"]`. A request anywhere else is refused, so a connection URL can't be pointed at an unexpected server. Pass a function to allow different hosts per caller. |
| `connections` | `Record<string, McpConnectionDef<P>>` | The services, by the name agents use in `connections: ["…"]`. |
| `callbackBaseUrl` (optional) | `string` | Where sign-ins return to, for example `https://app.example.com/api/latch/connections`. Each service's redirect URL is this plus `/<name>/callback`; register that URL with providers that ask for one. |
| `onConnectionError` (optional) | `((name: string, error: unknown) => void)` | Called when a service can't be reached for a turn. The turn goes on without its tools. |

## A connection

| Option | Type | Description |
| --- | --- | --- |
| `url` | `string` | The MCP server's URL (streamable HTTP). |
| `description` (optional) | `string` | What the service is for. Shown to the user, and helps the model pick it. |
| `auth` (optional) | `McpConnectionAuth<P>` | How to sign in: `oauth()`, `bearer({ getToken })` or `headers({…})`. Leave out for a server without sign-in. |
| `tools` (optional) | `{ allow?: string[]; block?: string[]; }` | Only offer some of the server's tools (`allow`), or hide some (`block`), by the server's own tool names. |
| `approval` (optional) | `McpApprovalPolicy` | Which tools need a person's approval. `"destructive"` (the default) asks for tools the server marks as destructive or as not read-only; tools the server doesn't mark at all run without asking. `"all"`, `"none"`, or a per-tool predicate `(tool) => boolean` — which may instead return a per-CALL function `(input) => boolean \| Promise<boolean>`, evaluated with the model's arguments each call, for meta-tool servers whose one tool multiplexes every operation (gate `execute_tool` only when `input.toolName` starts with `create_`). For policy that varies by caller, use approvalFor instead. |
| `approvalFor` (optional) | `((principal: P) => McpApprovalPolicy \| Promise<McpApprovalPolicy>)` | Like `approval`, but decided per caller, for example from their permissions. Used instead of `approval` when set. |
| `visibleTools` (optional) | `((tool: McpToolInfo) => boolean)` | Hides tools: one it returns `false` for isn't offered at all. For a rule that depends on the caller, use `visibleToolsFor`. |
| `visibleToolsFor` (optional) | `((principal: P) => ((tool: McpToolInfo) => boolean) \| Promise<(tool: McpToolInfo) => boolean>)` | Like `visibleTools`, but decided per caller. Used instead of `visibleTools` when set. |
| `allowPrivateHost` (optional) | `boolean` | Lets this connection reach a private address (localhost, your internal network). Blocked by default, including names that resolve to one; turn it on only for a server you run yourself. |

## Signing in: oauth()

`oauth()` with no options works for servers that let apps register themselves. For the rest, pass your own app's `clientId` and `clientSecret`.

| Option | Type | Description |
| --- | --- | --- |
| `scopes` (optional) | `string[]` | The permissions to ask for, for example `["read", "write"]`. |
| `clientId` (optional) | `string` | Your app's client id at the provider. Leave it out and Latch registers an app with the server automatically, which most MCP servers allow. Google, GitHub, Slack and most large providers don't: create an app there, add `<callbackBaseUrl>/<name>/callback` as a redirect URL, and pass its id. |
| `clientSecret` (optional) | `string` | Your app's client secret, with `clientId`. Leave out for a public client. |
| `clientName` (optional) | `string` | The app name shown to users when Latch registers automatically. |
| `tokenEndpointAuthMethod` (optional) | `string` | How the app proves itself when exchanging codes for tokens. Default: `"none" (a public client)`. |
| `normalize` (optional) | `{ forceScheme?: "https" \| "http"; hosts?: string[]; }` | Normalize a provider's non-conformant discovery documents (e.g. a proxied `http://` authorization-server reference, or a trailing-slash issuer the SDK's strict RFC 8414 check rejects). `forceScheme: "https"` is the common case; `hosts` bounds the rewrite to those hostnames. |
| `omitResource` (optional) | `boolean` | Omit the RFC 8707 `resource` parameter from the authorization request and token exchange even when the server's protected-resource metadata advertises one. Google's MCP servers (calendarmcp.googleapis.com, …) do advertise a resource, but the audience-bound token Google then mints is rejected by the MCP server itself (401 `invalid_token`); a plain scope-bearing token is accepted. Set `true` for those servers. |
| `authorizationParams` (optional) | `Record<string, string>` | Extra query params appended to the authorization redirect URL — provider extensions the SDK doesn't model, e.g. Google's `{ access_type: "offline", prompt: "consent" }` to be issued a refresh token (without it the connection silently dies when the 1h access token expires, since Google returns no refresh token by default). |
| `clientMetadata` (optional) | `Partial<{ redirect_uris: string[]; application_type?: "native" \| "web" \| undefined; token_endpoint_auth_method?: string \| undefined; grant_types?: string[] \| undefined; response_types?: string[] \| undefined; client_name?: string \| undefined; client_uri?: string \| undefined; logo_uri?: string \| undefined; scope?: string \| undefined; contacts?: string[] \| undefined; tos_uri?: string \| undefined; policy_uri?: string \| undefined; jwks_uri?: string \| undefined; jwks?: any; software_id?: string \| undefined; software_version?: string \| undefined; software_statement?: string \| undefined; }>` | Extra client-metadata passthrough (merged into the generated metadata). |
| `allowedAuthorizationServers` (optional) | `string[]` | Origins permitted to act as this connection's authorization server. Defaults to the MCP server's own origin — set this only when the server genuinely delegates authorization to a separate IdP, and list that IdP's origin. See `McpOAuthConfig.allowedAuthorizationServers`. |

`bearer({ getToken })` sends a token you supply, and `headers({ … })` sends fixed headers.


---

# Packages

Source: https://intentface-latch-docs.vercel.app/docs/reference/packages

Core is the runtime and the seams. Every other package is an implementation *behind* a seam — add it when you want the capability, skip it and the capability doesn't exist.

| Package | Add it when you want |
| --- | --- |
| [`latch-core`](#latch-core) | always — the runtime and the seams |
| [`latch-drizzle`](#latch-drizzle) | almost always — storage, on SQLite or Postgres |
| [`latch-server`](#latch-server) | an HTTP surface you don't have to write |
| [`latch-mcp`](#latch-mcp) | tools from MCP servers |
| [`latch-openapi`](#latch-openapi) | tools from an OpenAPI spec |
| [`latch-memory`](#latch-memory) | agents that remember across sessions (**experimental**) |
| [`latch-channels`](#latch-channels) | Slack, Telegram, WhatsApp |
| [`latch-agent-builder`](#latch-agent-builder) | agents your users create from your UI |
| [`latch-otel`](#latch-otel) | tracing over OpenTelemetry, Langfuse included |
| [`latch-net`](#latch-net) | SSRF protection for your own fetches |

---

## latch-core

**The runtime, the message model, and every seam.** `createRuntime` is both the composition root and the operations facade — it runs turns, persists messages and runs, and handles approvals, durability, subagents, schedules and compaction.

Dependency-light on purpose: `zod` is the only runtime dependency, `ai` (>= 7) is a peer, and there is no HTTP framework, no database driver and no provider SDK anywhere in it.

```ts
import { createRuntime, defineAgent, createVault, type ToolContext } from "@intentface/latch-core";
```

Key exports: `createRuntime` · `defineAgent` · `ToolContext` · `createVault` · `mergeConnections` · `mergeToolSources` · `toModelMessages` / `toClientMessages` · `staticPricing` / `computeCost` · `createIntervalScheduler`.

There is also `@intentface/latch-core/node` for the Node-only key derivation (`deriveMasterKey`), **`@intentface/latch-core/harness`** — the default harness tools (`bash`/`read_file`/`write_file`/`glob`/`grep` over the sandbox seam; `web_fetch` with clean markdown, media passthrough and SSRF gating; `askUser`; `list_schedules`/`cancel_schedule`), as `harnessTools: defaultHarnessTools()`; `web_fetch`'s `turndown` + `@intentface/latch-net` are optional peers loaded on first call — and **`@intentface/latch-core/models`** — the model side of a host, so you don't hand-write it: a models.dev-backed catalog (ids, labels, pricing, reasoning capability, max output — filtered to the providers whose SDK is installed and API key is set), provider inference from a model id, and the four `RuntimeConfig` hooks that depend on the provider.

```ts
import { loadModelCatalog, providerDefaults } from "@intentface/latch-core/models";

const catalog = await loadModelCatalog();
createRuntime({
  storage,
  context,
  agents: { assistant: defineAgent({ model: catalog.modelFor("claude-sonnet-4-6"), instructions: "…" }) },
  ...providerDefaults<Principal>(catalog, { principalKey: (p) => p.orgId }),
});
```

`providerDefaults` supplies `pricing`, `resolveProviderTools`, `resolveReasoningOptions` and `resolvePromptCaching`; list any of them after the spread to override it. The provider SDKs (`@ai-sdk/anthropic`, `@ai-sdk/openai`, `@ai-sdk/google`) are *optional* peers, imported lazily per provider — a host with one API key never loads the other two, and the core root imports none of them.

→ [Runtime](/docs/concepts/runtime) · [Hooks](/docs/reference/hooks)

## latch-drizzle

**Storage, plus the tables.** Implements the `StorageAdapter` contract for SQLite/libSQL and Postgres, and ships the migrations — tables prefixed `latch_*`.

```ts
import { createSqliteAdapter, migrate, schema } from "@intentface/latch-drizzle";

const storage = createSqliteAdapter<Principal>(db, { owner });
```

It takes the `db`, not a connection string — your app owns the connection and the pool.

Beyond the core adapter it also provides the **secret store** (envelope-encrypted rows behind `createVault`), the **memory job store**, and an **agent-builder store** so DB-defined agents work without you writing that table.

The agent-builder store is versioned: `upsert` publishes (appending a snapshot unless the content is unchanged), `activateVersion` rolls back by moving a pointer, `listVersions` / `getVersion` / `listActivations` read the history, and `getDraft` / `saveDraft` / `discardDraft` / `listDrafts` hold one unpublished working copy **per editor**. See [Agents → Versions, drafts and rollback](/docs/guides/agents-in-a-database#versions-drafts-and-rollback).

Key exports: `createSqliteAdapter` · `createPostgresAdapter` · `migrate` / `migratePostgres` · `createSqliteSecretStore` · `createPostgresSecretStore` · `createMemoryJobStore` · `createSqliteAgentBuilderStore` · `schema`.

<Callout title="Writing your own adapter?">
The [Storage](/docs/reference/storage) page lists four behavioural guarantees — atomic
claims, fencing tokens, owner scoping, append-only messages. They only fail
under concurrency, so point the durability and resume smoke tests at a new
adapter before trusting it.
</Callout>

## latch-server

**One mountable handler for the whole runtime.** A framework-agnostic `(Request) => Promise<Response | undefined>` covering chat, approvals, chat and run lists, history, schedules, and the generic connection OAuth.

`createLatchApp({ storage, agents, resolveContext })` is the composition root: durability on, a croner cron evaluator, the models.dev catalog with its four provider hooks, the default harness tools, the handler mounted over the runtime, and an in-process cron driver on a long-running host (disabled on serverless — hit `POST /cron/run`). `agents` may be a function of the loaded catalog. App-level options (`models`, `principalKey`, `cronIntervalMs`) live on the config; runtime and handler defaults are overridable through `runtime` / `handler`. It returns `{ runtime, handler, models, scheduler?, stop }`; `createLatchHonoApp` returns the same plus `app` (a mounted Hono app).

```ts
import { createLatchApp, createLatchHandler, createCronerEvaluator } from "@intentface/latch-server";
import { createLatchHono, createLatchHonoApp } from "@intentface/latch-server/hono";
```

`hono` stays an optional peer because the adapter lives on a subpath. Also supplies `createCronerEvaluator()` for the `cron` seam, since core ships no cron library.

→ [Hosting](/docs/guides/mount-the-handler)

## latch-mcp

**MCP servers as agent tools**, with two guards core doesn't have: a per-tenant **SSRF allowlist** (an unlisted host throws before a socket opens) and **vault-brokered auth** (tokens resolved per tenant, never in agent config).

```ts
import { createMcpConnections, defineMcpConnection, oauth, bearer } from "@intentface/latch-mcp";
```

Handles both auth styles: **DCR** (dynamic client registration — the MCP default, no pre-registration needed) and a pre-registered client via `oauth({ clientId, clientSecret })`, which is what Google, Slack and most non-MCP-native providers require.

An MCP tool's own annotations (`destructiveHint`, `readOnlyHint`) drive whether it needs approval, so a mutating third-party tool arrives gated without you enumerating it. For servers that ship no annotations there's `nameHeuristicApproval`.

Key exports: `createMcpConnections` · `defineMcpConnection` · `createMcpHost` · `createMcpOAuth` · `none` / `bearer` / `headers` / `oauth` · `needsMcpApproval` · `nameHeuristicApproval`.

## latch-openapi

**Tools from an OpenAPI spec.** Same `ConnectionRegistry` shape as `latch-mcp`, so an agent's `connections: [...]` resolves across both kinds — useful for your own internal APIs, which usually have a spec and no MCP server.

Key exports: `bearer` · `apiKey` · `headers` · `isHostAllowed`. Uses `@apidevtools/swagger-parser` to resolve specs.

## latch-memory

**Experimental.** The only production store today is a Loredex workspace, and the design may change.

**Facts that survive the conversation.** Wire the `MemoryProvider` seam and each turn gets a compiled index appended to its instructions plus `memory_search` / `memory_save` tools; background jobs handle extraction and consolidation out of band.

The load-bearing property is that **scopes are hard boundaries** — `agent-user`, `agent-org` and `user` are stored in disjoint subtrees, tools are path-locked at construction, and consolidation never crosses a scope. A personal agent's memory and a company-wide agent's memory differ by *declaration*, not convention.

```ts
defineAgent<Principal>(() => ({
  model,
  memory: { scope: "agent-user", connection: "loredex" },
}));
```

Key exports: `createMemoryProvider` · `createLoredexStore` · `scopeRefOf` · the extraction/consolidation prompts and schemas.

→ [Memory](/docs/memory)

## latch-channels

**Slack, Telegram and WhatsApp.** Each adapter verifies the webhook, acknowledges fast, and runs the turn in the background. Approvals render as native platform UI. Per adapter: Slack uses native message streaming with tool calls as task cards, the Agents-pane surface, initiator-owned threads and signed approval buttons. Telegram and WhatsApp are two `Surface`s over one **turn kernel** (`createTurnKernel` in the package root): dedupe, the identity gate with post-link replay, media folding, the answer posted once at finish (split past the platform cap, with a typing hint meanwhile), approvals, questionnaires and the `/new` `/compact` `/cancel` `/status` commands are written once — a new DM platform is a webhook parser plus a Surface.

```ts
import { slackChannel } from "@intentface/latch-channels/slack";
import { telegramChannel } from "@intentface/latch-channels/telegram";
import { whatsappChannel } from "@intentface/latch-channels/whatsapp";
```

A channel is just another caller of the runtime — the same `handleChat`, the same storage, the same approvals as your web app. Only the rendering differs.

You supply the persistence: which agent a bot serves, the identity links, held messages, and delivery dedup. One turn per chat is guarded by core when your storage adapter has the one-active-run-per-chat invariant (the shipped drizzle adapters do): a second live run is refused and the channels render that as "still working". On a custom adapter without it, wire the optional `claimTurn` / `releaseTurn` lease — it is then your only turn serialisation.

<Callout type="warn" title="Resolve the human, not the installer">
Decide deliberately whether a turn runs as the *person messaging* or as the
account that installed the bot. Getting this wrong keys every row to the wrong
owner and silently disables per-user memory — see the worked example in
[Principals](/docs/guides/ownership-patterns#make-sure-the-principal-is-the-person-you-think-it-is).
</Callout>

## latch-agent-builder

**Agents as data.** The `AgentRecord` model and the `AgentBuilderStore` seam, which doubles as core's `DynamicAgentStore` — so records your users create in your UI are runnable by your runtime, resolving to the same `AgentConfig` a code-declared agent would.

A record carries the model, instructions, connections, per-tool selection and approval overrides, execution capabilities, and a scope (`user` or `org`). `latch-drizzle` ships the storage.

Key exports: `createAgentBuilder` · `normalizeAgentName` · `sharedReadTools` · the `AgentBuilderExtension` seam for host-specific builder tools.

## latch-otel

**Tracing over OpenTelemetry, to any backend.** One trace per run, its id derived from the run id (`traceIdForRun`), with the AI SDK's own spans under it in the GenAI semantic conventions: each step, each model call with its messages and token usage, each tool call with its arguments and result. Every span carries the session (the conversation), the user and the run, so the backend can filter on them. Implements core's `telemetry` seam; omit it and nothing is traced, at zero cost.

```ts
import { otelTelemetry } from "@intentface/latch-otel";
import { langfuse } from "@intentface/latch-otel/langfuse";

createRuntime({
  telemetry: otelTelemetry({
    user: (p) => p.orgId,
    export: langfuse(), // or leave out: OTLP to OTEL_EXPORTER_OTLP_ENDPOINT
  }),
});
```

`langfuse()` reads `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` and `LANGFUSE_BASE_URL` and adds what Langfuse shows: the trace name, session, user, tags (`agent:`, `run:`, `agent-version:` …) and environment. Without a preset, spans go over OTLP to whatever `OTEL_EXPORTER_OTLP_ENDPOINT` points at (Grafana, Honeycomb, Datadog, Jaeger), or pass your own `SpanExporter`. It runs its own tracer provider, so it doesn't replace one your app has. Install `@ai-sdk/otel` at the version matching your `ai`.

Replaces `@intentface/latch-langfuse`, which also read traces and wrote scores back; that's left to your app or an evaluation tool.

## latch-net

**SSRF protection.** The host blocklist (private ranges, loopback, link-local, IPv6 equivalents) and a **pinned fetch** on the `./pinned` subpath that resolves a host once and connects to that address — closing the DNS-rebinding window between the check and the request.

Used by `latch-mcp` and `latch-openapi`. Worth importing directly for any fetch driven by model output or user-supplied URLs.

```ts
import { isBlockedHost } from "@intentface/latch-net";
import { pinnedFetch } from "@intentface/latch-net/pinned";
```

## Choosing a starting set

<Tabs items={['Minimal', 'Typical SaaS', 'Everything']}>

<Tab value="Minimal">
```bash
pnpm add @intentface/latch-core @intentface/latch-drizzle ai @ai-sdk/anthropic
```
A runtime with persistence. You call `handleChat` from your own routes. No HTTP
surface, no tools beyond what you define in code.
</Tab>

<Tab value="Typical SaaS">
```bash
pnpm add @intentface/latch-core @intentface/latch-drizzle \
         @intentface/latch-server @intentface/latch-mcp \
         @intentface/latch-memory ai @ai-sdk/anthropic
```
Persistence, a mounted API, third-party tools with per-tenant auth, and agents
that remember. This covers most products.
</Tab>

<Tab value="Everything">
```bash
pnpm add @intentface/latch-core @intentface/latch-drizzle \
         @intentface/latch-server @intentface/latch-mcp \
         @intentface/latch-openapi @intentface/latch-memory \
         @intentface/latch-channels @intentface/latch-agent-builder \
         @intentface/latch-otel @ai-sdk/otel @intentface/latch-net \
         ai @ai-sdk/anthropic
```
Add capabilities when you want them rather than up front — each one is a seam
you fill, and an unfilled seam costs nothing at runtime.
</Tab>

</Tabs>

All packages version in lockstep, so matching majors and minors across
`@intentface/latch-*` is the supported combination.


---

# Runtime methods

Source: https://intentface-latch-docs.vercel.app/docs/reference/runtime

`createRuntime` returns the runtime. Your server calls it from its routes: one call per chat message, approval decision or cron tick. Every method that takes a `principal` only ever sees that principal's data.

| Option | Type | Description |
| --- | --- | --- |
| `listAgents` | `(principal: P) => Promise<AgentInfo[]>` | The agents this person can use (yours and their dynamic ones), for an agent picker. |
| `handleChat` | `(input: HandleChatInput<P>) => Promise<Response>` | Runs one chat turn and streams the answer. Returns a streaming `Response` in the AI SDK's UI message format, ready to return from your route to `useChat`. The run keeps going and is saved even if the client disconnects. |
| `runAgent` | `(input: RunAgentInput<P>) => Promise<RunAgentResult>` | Runs an agent to completion without a client and returns its answer: for webhooks, background jobs, tests and evaluations. Never throws for a run that fails once started; the failure comes back as `error`. |
| `compactChat` | `(input: { chatId: string; principal: P; focus?: string; request?: Request; turnContext?: unknown; }) => Promise<{ summary: string; compacted: number; } \| null>` | Summarizes a long conversation so later turns send the model the summary instead of the old messages. The stored history is kept in full. Returns `null` when there is too little to summarize or the chat isn't the caller's; throws `PendingApprovalError` while the chat waits on an approval. |
| `loadHistory` | `(input: { chatId: string; principal: P; }) => Promise<AppMessage[]>` | A chat's messages for showing to the user, with internal messages left out. Use it to render a conversation when the page loads. |
| `listChats` | `(input: { principal: P; limit?: number; kind?: string; }) => Promise<ChatRecord[]>` | The caller's chats, newest first, for a sidebar. Pass `kind: "user"` to leave out the threads subagents use internally. |
| `getChat` | `(input: { principal: P; id: string; }) => Promise<ChatRecord \| null>` | One chat, or `null` when it doesn't exist or isn't the caller's. |
| `listRuns` | `(input: { principal: P; limit?: number; }) => Promise<RunRecord[]>` | The caller's runs, newest first, with status, usage and cost: for an activity or usage view. |
| `getRun` | `(input: { principal: P; runId: string; }) => Promise<RunRecord \| null>` | One run, or `null` when it doesn't exist or its chat isn't the caller's. |
| `deleteChat` | `(input: { principal: P; chatId: string; }) => Promise<{ deleted: string[]; }>` | Deletes one of the caller's chats with its messages, runs and usage, and the subagent threads it spawned. Returns the ids deleted: empty when the chat isn't the caller's. Refuses a chat with an answer still running (`ChatBusyError`), and `UnsupportedOperationError` when the storage adapter can't delete. |
| `purgeOwner` | `(segments: string[], opts?: PurgeOptions & { also?: Record<string, OwnerPurgeable>; }) => Promise<{ removed: Record<string, Record<string, number>>; }>` | Deletes everything stored for an owner path, in every store you pass: `["t1"]` removes a whole tenant (keys `t1` and `t1/…`), `["t1", "u7"]` one person. Runs each store's purge until it's done and returns what each removed. Checks first that every store can purge, and refuses (`UnsupportedOperationError`) before deleting anything if one can't. Chats, messages, runs and schedules are covered by the storage adapter. The usage ledger is kept and re-filed one level up by default, since billing records usually must outlive the person (see `PurgeOptions`): pass `usage: "delete"` to remove a whole tenant. Pass your other stores by name in `also`: the secret store, stored agents, the memory job store, your own channel store. The result reports each under the same name (the storage adapter as `storage`). Usage-limit counters are keyed by names you chose, and may contain the person's id (`user:org1:u7`): clear those yourself for a complete erasure. |
| `resume` | `(run: string \| Pick<RunRecord, "id" \| "ownerKey">) => Promise<RunOutcome \| null>` | Resumes one interrupted run and finishes it. Needs `durability`. Returns `null` when the run can't be resumed: it already finished, or another server has it. You rarely call this yourself; `cron()` does it. Pass the run record (from `reapExpiredRuns`, say) rather than its id when your storage adapter checks or routes by owner key: the record carries it. |
| `sweep` | `(opts?: { now?: number; maxAttempts?: number; error?: string; }) => Promise<{ reaped: RunRecord[]; resumed: RunOutcome[]; errors: string[]; }>` | Finds runs whose server died and resumes them. Gives up on a run after `maxAttempts` tries (default 5), so one that crashes every time can't loop forever. Safe to call from every server at once. Needs `durability`; `cron()` calls it for you. |
| `cron` | `(opts?: { now?: number; maxAttempts?: number; }) => Promise<{ due: RunDueResult; reaped: RunRecord[]; resumed: RunOutcome[]; errors: string[]; }>` | The one function to call on a timer: fires due schedules, then (with `durability`) resumes interrupted runs. Call it every minute or so from a cron route, or with `createIntervalScheduler`. Safe to call from every server at once. Failures are returned in `errors`, never thrown. |
| `applyApproval` | `(input: ApplyApprovalInput<P>) => Promise<Response>` | Records a person's approve or deny decisions and continues the run: approved tools run, denied ones don't. Returns the continuation as a streaming `Response`. Works from any device or channel, any time later, because the waiting state is stored in the conversation. Repeated or stale decisions are ignored. |
| `applyToolResult` | `(input: ApplyToolResultInput<P>) => Promise<Response>` | Answers a tool the client handles (one without `execute`, like `askUser`) and continues the run. Returns the continuation as a streaming `Response`, or an empty response while other client tools in the same step still need answers. |
| `schedule` | `(input: ScheduleInput<P>) => Promise<ScheduleRecord>` | Schedules an agent to run on a timer, as the caller. |
| `listSchedules` | `(input: { principal: P; }) => Promise<ScheduleRecord[]>` | The caller's schedules. |
| `unschedule` | `(input: { principal: P; id: string; }) => Promise<void>` | Deletes one of the caller's schedules. |
| `runScheduleNow` | `(input: { principal: P; id: string; }) => Promise<boolean>` | Makes one of the caller's schedules fire on the next cron tick, exactly as a timed fire would. Returns `false` when there was nothing to run: the id is unknown, not the caller's, or a one-off that already ran. |
| `runDue` | `(opts?: { now?: number; }) => Promise<RunDueResult>` | Fires every schedule that is due, for all customers. `cron()` calls it; call it directly only if you drive schedules separately from crash recovery. Waits for each fired run to finish. |

## handleChat

Runs one chat turn and streams the answer back. Return its `Response` straight from your route to `useChat`.

```ts
export async function POST(req: Request) {
  const principal = await requireUser(req);
  const { id, message, agent } = await req.json();
  return runtime.handleChat({ agent, chatId: id, principal, message, request: req });
}
```

| Option | Type | Description |
| --- | --- | --- |
| `agent` (optional) | `string` | The agent to run, by name (a key of `agents`, or a dynamic agent's name). Required for a new chat; a continuing chat uses the agent it was started with when omitted. |
| `chatId` (optional) | `string` | The chat to continue. Pass the same id on every message of a conversation; a new id starts a new chat with that id. Omit it to start a new chat with a generated id. |
| `principal` | `P` | Who is asking, as your auth resolved it. Everything the run reads and writes is scoped to this principal, so build it on the server, never from client input. |
| `message` | `AppMessage` | The user's message, as `useChat` sends it. Accepted: - a new user message with text and files (files sent inline count against `maxInlineFileBytes`); - the chat's last question resent unchanged (`regenerate()`) or edited (`sendMessage({ messageId })`): the old answer is hidden, not deleted, and the agent answers again; - the paused assistant message with approval answers on it (`addToolApprovalResponse` with `sendAutomaticallyWhen`): only the decisions are read, as `applyApproval` would. Anything else is refused: another role or part type (`InvalidMessageError`, 400), or an earlier question regenerated or edited (`RewindNotSupportedError`, 409; fork the chat instead). |
| `params` (optional) | `unknown` | What the chat is about, for an agent that declares `params` (see `defineAgent`). Pass them with the chat's first message; they're validated, saved on the chat, and given to the agent on every later turn, so later messages don't send them again. Sending the same params again is fine; different ones are refused (`ParamsFixedError`, 409). |
| `request` (optional) | `Request` |  |
| `turnContext` (optional) | `unknown` |  |

## runAgent

Runs an agent to completion with no client attached: a webhook, a background job, a test. Returns the final answer.

```ts
const { answer, error } = await runtime.runAgent({
  principal,
  agent: "triage",
  prompt: `New ticket: ${ticket.title}`,
  trigger: "programmatic",
});
```

| Option | Type | Description |
| --- | --- | --- |
| `principal` | `P` | Who the run acts for. The run is scoped to this principal like a chat turn. |
| `agent` | `string` | The agent to run, by name. |
| `prompt` | `string` | The task, as the user message. |
| `files` (optional) | `{ url: string; mediaType: string; filename?: string; }[]` | Files sent with the prompt: `url` is a data URL or a URL the model provider can fetch. They come before the text in the user message. |
| `threadId` (optional) | `string` | Continue an earlier `runAgent` thread instead of starting a new one. |
| `params` (optional) | `unknown` | What the run is about, for an agent that declares `params`: validated and saved on the thread, so a resumed run gets them too. Continuing an existing `threadId` with different params is refused. |
| `blockGated` (optional) | `"simulate-success" \| boolean` | What happens to tools that need a person's approval, since nobody is there to give it: - `true`: they don't run; the model is told they were blocked. - `"simulate-success"`: they don't run; the model is told they succeeded. For testing what an agent would do next. - `false`: they run for real, without approval. Default: `true`. |
| `memory` (optional) | `boolean` | Set `false` to run without the agent's long-term memory: nothing is recalled and nothing saved. Useful when running one agent over many test inputs, so earlier inputs can't influence later ones. |
| `model` (optional) | `LanguageModel` | Run on a different model than the agent's own, for this run only. For comparing models on the same task. |
| `turnContext` (optional) | `unknown` |  |
| `promptCaching` (optional) | `boolean` | Set `false` to turn prompt caching off for this run, for example as the control in a cost comparison. |
| `trigger` (optional) | `TurnTrigger` | How telemetry labels this run. Pass `"chat"` when a person is waiting on the answer, for example a webhook that replies to them. Default: `"programmatic"`. |

It returns:

| Option | Type | Description |
| --- | --- | --- |
| `threadId` | `string` | The thread the run used. Pass it back as `threadId` to continue. |
| `answer` | `string` | The agent's final answer. Empty when the run failed. |
| `error` (optional) | `string` | Why the run failed, when it did: the model provider refused, a tool source wouldn't open, and so on. |

## applyApproval

Continues a run that paused on a tool needing approval. Decisions that are no longer pending (a double click, an old tab) are ignored, so it is safe to call more than once.

```ts
return runtime.applyApproval({
  chatId,
  principal,
  decisions: [{ approvalId, approved: true }],
});
```

| Option | Type | Description |
| --- | --- | --- |
| `chatId` | `string` | The chat whose run is waiting. |
| `principal` | `P` | Who is deciding. Must be allowed to see the chat. |
| `decisions` | `ApprovalDecision[]` | One decision per approval request. A decision for a request that is no longer waiting (already decided, or from an older message) is ignored. |
| `request` (optional) | `Request` |  |
| `turnContext` (optional) | `unknown` |  |

Each decision:

| Option | Type | Description |
| --- | --- | --- |
| `approvalId` | `string` | The approval request's id, from the tool part's `approval.id`. |
| `approved` | `boolean` | `true` lets the tool run; `false` stops it. |
| `reason` (optional) | `string` | Why, shown to the model, for example why a request was denied. |

## applyToolResult

Continues a run that paused on a tool the client answers, such as `askUser`.

| Option | Type | Description |
| --- | --- | --- |
| `chatId` | `string` | The chat whose run is waiting. |
| `principal` | `P` | Who is answering. Must be allowed to see the chat. |
| `toolCallId` | `string` | The tool call being answered, from the message part. |
| `output` | `unknown` | The tool's result, for example the user's answer to `askUser`. |
| `request` (optional) | `Request` |  |
| `turnContext` (optional) | `unknown` |  |

## schedule

Runs an agent on a timer, as the caller. Recurring schedules need `cron` in the runtime config, and a timer calling `runtime.cron()`.

```ts
await runtime.schedule({
  principal,
  agent: "brief",
  cron: "0 9 * * 1",
  timezone: "Europe/Helsinki",
  prompt: "Summarise last week's tickets.",
});
```

| Option | Type | Description |
| --- | --- | --- |
| `principal` | `P` | Who the schedule runs as. Every fire acts for this principal, re-checked with `reconstructPrincipal` when one is configured. |
| `agent` | `string` | The agent to run, by name. |
| `cron` | `string \| null` | When to run, as a cron expression (`"0 9 * * 1"` is Mondays at 9:00). `null` runs it once, on the next cron tick. Recurring schedules need `cron` in the runtime config. |
| `timezone` (optional) | `string` | The time zone the cron expression is read in, for example `"Europe/Helsinki"`. Default: `"UTC"`. |
| `prompt` | `string` | The message the agent receives on every fire. |
| `delivery` (optional) | `unknown` | Where the result should go, for your `runSchedule` hook to read (a Slack channel, a Telegram chat). Saved as it is; Latch never reads it. |
| `onParked` (optional) | `"fire" \| "skip"` | What to do when it's time to fire but the previous fire is still waiting on a person (an approval nobody has given): - `"skip"`: skip this fire, so unanswered runs don't pile up. - `"fire"`: run anyway. For schedules where every fire matters, like a daily digest. Default: `"skip"`. |
| `params` (optional) | `unknown` | Params for the agent (see `defineAgent`): checked now, saved with the schedule, and given to the chat each fire starts. |

## cron

The one function to call on a timer, every minute or so. It fires due schedules and, with `durability` on, resumes interrupted runs. Safe to call from every server at once.

```ts
// app/api/cron/route.ts — called by your platform's cron every minute
export async function GET() {
  const { due, resumed, errors } = await runtime.cron();
  return Response.json({ fired: due.fired, resumed: resumed.length, errors });
}
```

What `due` reports:

| Option | Type | Description |
| --- | --- | --- |
| `fired` | `number` | Fires that ran. |
| `errors` | `number` | Fires that failed. |
| `skipped` | `number` | Fires skipped because the previous one is still waiting on a person (`onParked: "skip"`). |
| `parked` | `number` | Fires that ran and then stopped, waiting on an approval or a question nobody is there to answer. Tokens were spent and no answer was delivered, so a non-zero number is worth looking at. |
| `rejected` | `number` | Fires refused because `reconstructPrincipal` returned `null` (the person no longer has access). Their schedules are disabled. |


---

# Server

Source: https://intentface-latch-docs.vercel.app/docs/reference/server

`@intentface/latch-server` serves the runtime over HTTP. What it's for: [Server](/docs/concepts/server). Mounting it in your framework: [Mount in your framework](/docs/guides/mount-the-handler).

## createLatchApp

Builds the runtime with sensible defaults (crash recovery, schedules, the model catalog, built-in tools) and the handler over it. Returns `{ runtime, handler, models, stop }`.

```ts
const { runtime, handler } = await createLatchApp<Principal>({ storage, agents, resolveContext });
```

| Option | Type | Description |
| --- | --- | --- |
| `storage` | `StorageAdapter<P>` | Where conversations, runs and usage are stored, for example `createPostgresAdapter(db, { owner })`. |
| `agents` | `Record<string, AgentFactory<P, any, any>> \| ((models: ModelCatalog) => Record<string, AgentFactory<P, any, any>>)` | Your agents, by name. Pass a function to get the model catalog, so an agent can pick its model by id: `(models) => ({ assistant: defineAgent({ model: models.modelFor("claude-sonnet-4-6"), … }) })`. |
| `resolveContext` | `(request: Request) => P \| Promise<P \| null> \| null` | Your login: turns a request into the principal, or `null` for a `401`. |
| `context` (optional) | `ContextInput<P, RC>` | What your agents and tools can work with, built from who is asking on every turn: `async ({ principal }) => ({ … })`. Default: ``{}``. |
| `models` (optional) | `ModelCatalog` | A model catalog you already loaded. Default: ``loadModelCatalog()``. |
| `principalKey` (optional) | `((principal: P) => string)` | A key per caller for prompt caching, so one person's cached prompt is never reused for another. Usually the same as the storage owner. |
| `runtime` (optional) | `Partial<Omit<RuntimeConfig<P, RC>, "agents" \| "context" \| "storage">>` | Any other `createRuntime` option (limits, connections, schedules…), or a different value for one of the defaults. |
| `handler` (optional) | `Partial<Omit<LatchHandlerConfig<P>, "resolveContext" \| "runtime">>` | Any other `createLatchHandler` option, such as `basePath`, `connections` or `cronSecret`. |
| `cronIntervalMs` (optional) | `number \| false` | How often to call `runtime.cron()` in this process, in ms, or `false` for never. Off on serverless (when `VERCEL` or `AWS_LAMBDA_FUNCTION_NAME` is set), where nothing runs between requests: call `runtime.cron()` from a cron route instead. Default: `30000 on a long-running server`. |

## createLatchHandler

The handler on its own, over a runtime you built. Returns `(request) => Promise<Response | undefined>`: `undefined` means the path isn't one of its routes.

```ts
const handler = createLatchHandler<Principal>({ runtime, resolveContext, basePath: "/api/latch" });
```

| Option | Type | Description |
| --- | --- | --- |
| `runtime` | `Runtime<P> \| ((principal: P) => Runtime<P> \| Promise<Runtime<P>>)` | The runtime the routes call. For a database per customer, pass a function instead: it gets the principal from `resolveContext` and returns that customer's runtime. In that case also set `cronRun`, because the cron route has no principal to pick a runtime with. |
| `resolveContext` | `(request: Request) => P \| null \| Promise<P \| null>` | Your login: turns a request into the principal, or `null` for a `401`. Not used when a sign-in to a connection returns (the OAuth callback): the principal that started the sign-in is saved with it and restored with `reconstructPrincipal`, so whatever session the returning browser has can't change whose account the credentials are saved to. |
| `reconstructPrincipal` (optional) | `((identity: unknown, request: Request) => P \| null \| Promise<P \| null>)` | Restores the principal that started a connection sign-in, when the sign-in returns. Check it's still valid here (for example, still a member); return `null` to refuse with a `401`. By default the saved principal is used as is. |
| `oauthFlowMaxAgeMs` (optional) | `number` | How long a user has to finish signing in to a connection. A later return redirects with `?error=flow_expired`. Default: `30 minutes`. |
| `connections` (optional) | `McpConnections<P> \| ((principal: P) => McpConnections<P> \| Promise<McpConnections<P>>)` | The services users can connect, from `createMcpConnections`. Adds the routes to list, sign in to, test and disconnect them. Pass a function to give each customer their own set. |
| `onConnected` (optional) | `((principal: P, connectionName: string) => void \| Promise<void>)` | Called after a user connects a service, including when they were already connected. |
| `onError` (optional) | `((error: unknown, info: { route: string; name?: string; }) => void \| Promise<void>)` | Receives errors the client is only told about briefly. A connection's errors can quote the other server's response, so the client gets a short code and the full error comes here. Wire it to your logger, or the detail is lost. |
| `connectedParam` (optional) | `string` | The query parameter that tells your page which service was just connected, after a sign-in returns: `?connected=linear`. Default: `"connected"`. |
| `basePath` (optional) | `string` | Where the handler is mounted, for example `"/api/latch"`. Default: `"/"`. |
| `cronSecret` (optional) | `string` | Turns on `cron/run`, which calls `runtime.cron()` for every customer: fires due schedules and finishes interrupted answers. Requests must carry this secret, as `Authorization: Bearer <secret>` or in the `x-latch-cron-secret` header, with `GET` or `POST`. That means Vercel Cron can call it directly: set this to `process.env.CRON_SECRET` and point `vercel.json` at the route. Leave it out and the route doesn't exist. |
| `cronRun` (optional) | `(() => Promise<unknown>)` | What `POST /cron/run` does. Needed with a database per customer: call `cron()` on each customer's runtime. Default: ``runtime.cron()``. |

## Routes

Paths are relative to `basePath`. Every route except the cron route and the connection sign-in callback calls `resolveContext` first. The callback restores the principal that started the sign-in instead (see `reconstructPrincipal` above).

| Route | What it does |
| --- | --- |
| `GET agents` | The agents the caller can use, for a picker. |
| `POST agents/:agent/:chatId` | Send a message; streams the answer. Body: `{ message }` or the AI SDK's `{ messages }`. |
| `POST agents/:agent/:chatId/approvals` | Approve or deny paused tools; streams the rest of the answer. |
| `POST agents/:agent/:chatId/tool-result` | Answer a tool the browser handles, such as a question for the user. |
| `GET chats` | The caller's chats. |
| `GET chats/:chatId` | One chat's messages. |
| `DELETE chats/:chatId` | Delete the chat with its messages, runs and usage, and the subagent threads it spawned. `404` if it isn't the caller's, `409` while an answer runs, `501` if storage can't delete. |
| `GET runs` | The caller's runs, with usage and cost. |
| `GET schedules`, `POST schedules` | List or create the caller's schedules. |
| `GET schedules/:id`, `DELETE schedules/:id` | Read or delete one. |
| `POST schedules/:id/run-now` | Fire it on the next cron tick. |
| `GET connections` | The services the caller can connect, and which are connected. |
| `GET connections/:name/authorize` | Start signing in; redirects to the provider. |
| `GET connections/:name/callback` | Where the provider sends the user back. |
| `POST connections/:name/disconnect` | Remove the caller's credentials. |
| `POST connections/:name/test` | Check the connection works; returns its tool names. |
| `GET` / `POST cron/run` | Your `cronRun`, or by default `runtime.cron()` for everyone (with a runtime per customer and no `cronRun`, it answers `400`). Needs `cronSecret`, as a bearer token or `x-latch-cron-secret`, so Vercel Cron can call it. |

Refusals come back as JSON with a `code`: see [Errors](/docs/reference/errors).


---

# Storage

Source: https://intentface-latch-docs.vercel.app/docs/reference/storage

Storage is the only required seam with real behavioural obligations. `@intentface/latch-drizzle` implements it for Postgres and SQLite; this page is what you need if you're writing another one, or debugging one.

```ts
createRuntime({ storage, context, agents });
```

## What it stores

Five record types. The adapter owns the **owner key** — an opaque string it derives from the principal — and every method that *takes a principal* scopes its read by it. The runtime's own machinery also calls a few methods by id (`appendMessages`, `updateRun`, `getRun`, `heartbeatRun`, `reclaimRun`, `completeSchedule`, `noteScheduleFire`). Those carry the owner key as an optional last argument, so the adapter can check it or route by it: see [Owner keys on id-only calls](#owner-keys-on-id-only-calls). Reach for the [Runtime operations](/docs/reference/runtime) rather than the adapter when you're serving a request.

| Record | Holds |
| --- | --- |
| `ChatRecord` | id, owner, agent, title, `kind`, timestamps |
| `RunRecord` | status, `ownerKey` (its chat's), identity blob, lease fields, attempt count, token usage, cost, error, the agent version it ran |
| `RunLease` | `{ owner, fencingToken }` — proof of single-writer ownership |
| `UsageRecord` | per-run token/cost row for billing rollups |
| `ScheduleRecord` | cron, timezone, prompt, opaque `delivery`, `nextRunAt`, lease fields |

Two fields are **opaque and must round-trip byte-for-byte**: `RunRecord.identity` (the serialized principal) and `ScheduleRecord.delivery` (the host's delivery target). Core never interprets either.

`RunRecord.agentVersion` is the stored agent-record version this run started on (null for code-declared agents, which have no stored record). It is written when the run is **opened**, not when it settles: a run that errors or crashes never reaches a terminal update, and "which version produced these failures" is exactly the question worth answering. It stays fixed across a resume.

`ChatRecord.kind` is an open vocabulary: `"user"` for conversations, `"internal"` for runtime-created sub-threads (the `sub_…` chats `spawn_agent` mints). `listChats(principal, { kind: "user" })` is how a sidebar hides the machinery.

## The methods

```ts
interface StorageAdapter<P> {
  // Chats
  createChat, getChat, listChats
  // Messages
  loadMessages, appendMessages
  // Runs
  createRun, updateRun, getRun, listRuns
  // Durability
  claimRun, heartbeatRun, reclaimRun, reapExpiredRuns
  // Usage
  recordUsage
  // Schedules
  createSchedule, listSchedules, deleteSchedule, bumpSchedule,
  claimDueSchedules, completeSchedule
  // Deletion (optional)
  deleteChats, purgeOwner
}
```

## Guarantees you must provide

These are what make the runtime safe on multiple nodes. Getting them wrong produces bugs that only appear under concurrency — the expensive kind.

### Claims are atomic and exactly-once

`claimRun`, `reclaimRun` and `claimDueSchedules` must be **single atomic transitions** (one conditional `UPDATE … WHERE`), not read-then-write. Every node's cron fires `sweep()` and `runDue()` concurrently; correctness comes entirely from the database refusing the second claimant.

Get this wrong and you get double-fired schedules and two workers streaming into the same run.

### Fencing tokens gate stale writers

`RunLease` carries a monotonic `fencingToken`, bumped on every claim. Guarded writes must land **only** if `(owner, fencingToken)` still match the row.

The scenario: a worker's lease expires (it was slow, not dead), the reaper reassigns the run, and then the original worker wakes up and tries to write. Without fencing it overwrites newer state. This is why the token exists and why it must be checked in the `WHERE` clause, not in application code.

Message writes are fenced too. A durable turn passes `appendMessages(chatId, messages, fence)`, where `fence` is `{ runId, owner, fencingToken }`. The write lands only while that run still holds the lease, checked **in the same statement or transaction as the write**: a check followed by a separate insert leaves a window in which a reclaim can slip between the two. A refused write persists nothing and returns `false`, and the runtime then stops the turn as if its heartbeat had failed. Without `fence` the write is unconditional, as before. The shipped adapters evaluate the lease inside each upsert, and on Postgres also lock the run row `FOR SHARE`, so a reclaim waits for an in-flight write to commit.

### Lease times come from one clock

Every node must agree when a lease expires. If each app server stamped and compared leases with its own clock, a server running 20 seconds fast would reap runs that their owner is still renewing. The shipped adapters take every lease time — claim, heartbeat, reclaim, supersede, reap — from the **database's** clock (`clock_timestamp()` on Postgres, `julianday('now')` on SQLite). `reapExpiredRuns(now?)` uses the adapter's clock when `now` is omitted, which is how the runtime calls it; pass `now` only to simulate time in a test.

### A schedule consume reports whether it applied

`claimDueSchedules` stamps a lease that is never renewed, so a slow pass can lose its claim before it consumes the occurrence — to another node, or to a later `runDue` pass on the same instance (same owner id). `completeSchedule` is the write that settles which claimant fires:

- Fence it on `owner` **and** on `claim.leaseExpiresAt` when given — the expiry that claim stamped, which is strictly later on every re-claim, so it tells two claims by the same owner apart.
- Return `false` when the fence rejected the write. The runtime then does not fire; the current holder owns the occurrence.

Returning `void` is still accepted and treated as applied, so older adapters keep working — but they keep the duplicate-fire window this closes.

### Ownership scoping is not optional

Every read that **takes a principal** must filter by the derived owner key — `listChats`, `loadMessages`, `listRuns`, `listSchedules`. Filter in the query, not after it: a capped scan that slices in application code silently stops being correct once a tenant has more rows than the cap.

`getRun(id)` takes no principal: the reaper and resume path look runs up by id. Never serve it straight to a caller; go through `Runtime.getRun`, which scopes the lookup for you.

### Owner keys on id-only calls

Every call the runtime makes by id carries an `OwnerScope` as its last argument: `{ ownerKey }`, the key **your adapter** put on the record it's acting on (`ChatRecord.owner`, `RunRecord.ownerKey`, `ScheduleRecord.owner`), handed back. The runtime never works out a key itself; it only passes yours on. The one exception is `Runtime.getRun`, which has no record yet and passes `{ principal }` instead.

```ts
appendMessages(chatId, messages, fence?, scope?)
updateRun(id, patch, guard?, scope?)
getRun(id, scope?)
heartbeatRun(id, owner, ttlMs, fencingToken, scope?)
reclaimRun(id, owner, ttlMs, scope?)
completeSchedule(id, patch, owner, claim?, scope?)
noteScheduleFire(id, patch, scope?)
```

It's optional, and an adapter may ignore it. Two reasons not to:

- **Check it.** Throw `OwnerKeyMismatchError` when the key isn't the row's owner, so a bug can't write into another owner's run or chat. The runtime never retries a mismatch; recovery and schedules report it through `createRuntime({ onOwnerKeyMismatch })` and leave the row alone. The shipped adapters check it.
- **Route by it.** An adapter that keeps each tenant in its own schema or database reads the tenant from the key (`decodeOwnerKey(key)[0]`) and goes straight there, with no global index of ids.

For either, return `ownerKey` on **every** run record, reaped and reclaimed ones included: crash recovery starts from `reapExpiredRuns` with nothing else in hand. Stamp it when the run opens, from its chat's owner.

`reapExpiredRuns` and `claimDueSchedules` are the two calls across all owners. An adapter with one schema per tenant keeps a small global table of active leases and due schedules for them, each row with its owner key.

### Chat id collisions surface as a conflict

`createChat` must throw `ChatConflictError` when the id exists. Clients supply chat ids, so a caller can collide with a chat they don't own: the scoped `getChat` misses, and the insert races into the primary key. The typed error is what lets the HTTP layer answer `409` instead of `500`.

```ts
import { ChatConflictError } from "@intentface/latch-core";

try {
  await storage.createChat(principal, { agent, id });
} catch (error) {
  if (error instanceof ChatConflictError) return new Response(null, { status: 409 });
  throw error;
}
```

### Messages append, never mutate

`appendMessages` is the write path. Approval decisions, tool results and compaction boundaries are all recorded as appended message state — the log is the source of truth, and history is not rewritten in place.

**One active run per chat.** `createRun` and `claimRun` throw `ChatBusyError` (code `chat_busy`) when the chat already has a live active run — a lease that hasn't expired, or for non-durable runs an active row younger than the adapter's stale timeout. An expired one is superseded first (marked errored, fencing token bumped), so a crashed turn never blocks its chat. The shipped adapters enforce this with a partial unique index on `(chat_id) WHERE status = 'active'`, so it holds under concurrency, not just in the happy path. `reclaimRun` returns null for a run whose chat has since started a newer active run. `openTurn` (implemented by the shipped adapters) opens the run and appends the user message in one transaction, so a refused turn persists nothing and a crash can't leave a run without its message. It is optional in the type for compatibility, but **a durable adapter must implement it**: without it a turn opens as two writes, and a process that dies between them leaves a run whose own message never landed — crash recovery then reads the previous turn's tail as that run's history. The runtime warns at startup when durability is on and `openTurn` is missing. This is what keeps two tabs, a webhook retry racing a reply, or a schedule firing mid-conversation from interleaving one history.

### Deletion removes everything under a chat or an owner

Both are optional; without them `runtime.deleteChat` and `runtime.purgeOwner` refuse with `UnsupportedOperationError` (`501` over HTTP).

- **`deleteChats(principal, ids)`** removes the chats with their messages and runs, all or nothing: if any of them has a live run, none goes and it throws `ChatBusyError`. Ids that aren't the caller's are skipped; it returns the ids deleted. The runtime passes a chat together with the subagent threads it spawned, so a busy thread keeps the whole set.
- **Nothing lands for a deleted chat.** A run, message or usage row written for a chat (or its run) that's gone must fail rather than become an orphan row nobody can see or delete. The shipped adapters look the chat up inside each such insert (on Postgres with `FOR SHARE`, which also makes a run starting in a chat wait for that chat's deletion to commit).
- **Usage stays.** Neither operation rewrites the usage ledger on its own: `deleteChats` leaves a chat's usage rows, and `purgeOwner` does what `PurgeOptions.usage` says (default `"refile"`: move the rows to `refileTo`, by default the path one level up, and set their `run_id` to `"erased"`; or `"keep"`, or `"delete"`). Report re-filed rows as `usageRefiled`, deleted ones as `usage`.
- **`purgeOwner(segments, { batchSize, usage, refileTo })`** removes every chat (with messages and runs) and every schedule whose owner key falls under the path: the key itself or anything starting with it plus `/` (`isUnderOwner`). Compare prefixes as plain strings (`substr`), never with `LIKE`: encoded keys contain `%` and `_`. Settle live runs first with their fencing token bumped, so a worker still running one has its writes refused. Stop each pass at `batchSize` rows per table and return `done: false`; the runtime calls again until `done`, and stops with an error if a pass reports `done: false` but removed nothing.

## Using the shipped adapter

```ts
import { createSqliteAdapter, migrate, schema } from "@intentface/latch-drizzle";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";

const client = createClient({ url: "file:local.db" });
const db = drizzle(client, { schema });

// `migrate` executes DDL, so it takes the libSQL client — not the drizzle db.
await migrate(client);

const storage = createSqliteAdapter(db, { owner });
```

The adapter takes the db, not a connection string — your app owns the connection and the pool. Tables are prefixed `latch_*`, and the package ships the migrations; consume those rather than hand-declaring the schema in your own migration folder.

## Testing your own adapter

The failure modes above are invisible in normal use: an adapter that skips a fence or races a claim works until two nodes meet the same run. `@intentface/latch-core/testing` packages the guarantees as runnable checks: tenant scoping, message ordering, one active run per chat, racing claims and reclaims, heartbeats, the reaper, fenced message writes, chat id conflicts, and schedules (racing claims, stale completions, bumps, one-shots, deleting mid-fire). Set `checksOwnerKey: true` when your adapter stamps and checks owner keys, to add those checks. It runs under Vitest, Jest or `node:test`:

```ts
import { describe, it } from "vitest";
import { registerStorageConformance } from "@intentface/latch-core/testing";

registerStorageConformance({ describe, it }, "my adapter", async () => {
  const db = await openTestDatabase();
  return {
    store: createMyAdapter(db, { owner: (p) => [p.org] }),
    // Two principals your adapter maps to different owners.
    principals: [{ org: "a" }, { org: "b" }],
    close: () => db.close(),
  };
});
```

Each check opens a fresh target. The shipped SQLite and Postgres adapters run the same suite in CI.


---

# Vault

Source: https://intentface-latch-docs.vercel.app/docs/reference/vault

What the vault is for and how to use it: [Secrets (the vault)](/docs/concepts/secrets).

```ts
import { createVault } from "@intentface/latch-core";
import { createPostgresSecretStore } from "@intentface/latch-drizzle";

const vault = createVault<Principal>({
  store: createPostgresSecretStore(db, { owner: (p) => (p.userId ? [p.orgId, p.userId] : [p.orgId]) }),
  masterKey: process.env.LATCH_VAULT_KEY!, // base64 of 32 random bytes
});
```

`createVault` throws if a key isn't 32 bytes, or a key id isn't letters, digits, `_` and `-` (up to 32), or two keys share an id.

| Option | |
| --- | --- |
| `store` | Where the encrypted records live (below). |
| `masterKey` | The key new secrets are saved under: base64 of 32 random bytes, `{ id, key }`, or a `VaultKeyProvider` that calls your key service. A plain string has the id `default`. |
| `previousKeys` | Keys, in either form, that still read secrets but never save new ones. Keep the old key here while rotating. |

Each saved secret records the id of the key it was saved under, in `wrappedDek`. A secret is read with that key when it's configured, and otherwise with whichever configured key held here (not a key service) opens it. Rotating is described in [Secrets](/docs/concepts/secrets#rotating-the-master-key).

A key in a key service (example in [Secrets](/docs/concepts/secrets#a-master-key-in-a-key-service)):

| Option | Type | Description |
| --- | --- | --- |
| `id` | `string` | Letters, digits, `_` and `-`, up to 32: recorded with every secret it wraps. |
| `wrap` | `(dataKey: Uint8Array<ArrayBuffer>, binding: VaultKeyBinding) => Promise<Uint8Array>` | Encrypt a 32-byte data key. Bind it to `binding` (the service's encryption context or additional data). |
| `unwrap` | `(wrapped: Uint8Array<ArrayBuffer>, binding: VaultKeyBinding) => Promise<Uint8Array>` | Decrypt what `wrap` returned. Must throw, not return something else, when the bytes weren't wrapped by this key with this same `binding`: that's what stops a secret copied into another owner's row from opening there. |

| Option | Type | Description |
| --- | --- | --- |
| `context` | `Record<string, string>` | As an encryption context (AWS KMS `EncryptionContext`): `vault`, `secret` and, when the store says it, `owner`. |
| `aad` | `Uint8Array<ArrayBufferLike>` | The same, as bytes, for services that take additional authenticated data (Google Cloud KMS, AES-GCM). |

A record whose key id isn't configured is tried against the keys held here, never against a key service.

`deriveMasterKey(passphrase, { salt, cost? })` from `@intentface/latch-core/node` derives a key from a passphrase (Node only). `salt` must be stable for the deployment. `cost` is scrypt's N, a power of two from `2 ** 14` (the default, kept so existing keys don't change) to `2 ** 20`; use `2 ** 17` for a new deployment. A different cost gives a different key.

## Methods

| Option | Type | Description |
| --- | --- | --- |
| `rotate` | `(opts?: { batchSize?: number; }) => Promise<{ checked: number; rewrapped: number; remaining: number; errors: { owner: string; key: string; error: unknown; }[]; }>` | Re-wraps every secret not yet under the current master key (and binding) onto it. Values aren't re-encrypted, so it's quick. Run it once every instance has the new `masterKey` with the old one in `previousKeys`: an instance still on the old config saves under the old key, even secrets this has passed. Then, when it reports `remaining: 0`, nothing needs the old keys any more and you can remove them. Safe to run again or stop part-way. Needs a store with `scan` and `replaceWrap` (the shipped ones have them); throws `UnsupportedOperationError` otherwise. |
| `set` | `(principal: P, key: string, value: string) => Promise<void>` | Saves a secret, encrypted, replacing any value under that key. This is also how you rotate one. |
| `get` | `(principal: P, key: string) => Promise<string \| undefined>` | The secret's value, or `undefined` if there is none. Use it in your server code and tools; never return it to the model or the browser. |
| `has` | `(principal: P, key: string) => Promise<boolean>` | Whether a secret is set, without decrypting it. For a settings page's "connected" state. |
| `delete` | `(principal: P, key: string) => Promise<void>` | Removes a secret. Nothing that used it can read it afterwards. |
| `list` | `(principal: P) => Promise<string[]>` | The keys set for this owner. Names only, never values. |
| `resolver` | `SecretResolver<P>` | `get` as a function, for what takes a resolver: `createMcpConnections({ secrets: vault.resolver })`. |

`createVault` returns a `RotatableVault`. Tools and connections take a `Vault`, the same without `rotate`, so a wrapper of your own around a vault doesn't need to implement it.

## Secret store

`createPostgresSecretStore(db, { owner })` and `createSqliteSecretStore(db, { owner })` keep the encrypted records in `latch_secrets`. `owner(principal, key?)` returns the owner's segments, and receives the secret's key so different kinds of secret can belong to different owners. `list` has no key to pass, so it lists the owner `owner(principal)` gives without one.

They also implement `ownerKeyOf`, `scan` and `replaceWrap`, so secrets are tied to their owner and `vault.rotate()` works. A store of your own implements:

| Option | Type | Description |
| --- | --- | --- |
| `put` | `(principal: P, record: SecretRecord) => Promise<void>` | Saves an encrypted record for this owner, replacing one with the same key. |
| `get` | `(principal: P, key: string) => Promise<SecretRecord \| null>` | This owner's record for the key, or `null`. |
| `delete` | `(principal: P, key: string) => Promise<void>` | Removes this owner's record for the key. |
| `list` | `(principal: P) => Promise<string[]>` | This owner's keys, never values. |
| `purgeOwner` (optional) | `((segments: string[], opts?: { batchSize?: number; }) => Promise<PurgeResult>)` | OPTIONAL. Delete everything this store keeps (every secret under it) for an owner path, in passes (see `StorageAdapter.purgeOwner`): call until `done`. Without it, `runtime.purgeOwner` refuses when this store is passed to it. |
| `ownerKeyOf` (optional) | `((principal: P, key: string) => string)` | OPTIONAL. The owner key this store files `key` under for this principal: the same value it stores in the row. With it, the vault binds each secret to its owner, so a record moved into another owner's row won't decrypt; without it, only to the secret's name. |
| `scan` (optional) | `((opts: { after?: string; limit: number; }) => Promise<{ records: StoredSecret[]; next?: string; }>)` | OPTIONAL system op, for `vault.rotate()`: every record, with the owner key it's stored under, in pages ordered by a stable cursor. |
| `replaceWrap` (optional) | `((owner: string, key: string, wrap: Pick<SecretRecord, "wrappedDek" \| "dekIv">, expectedWrappedDek: string) => Promise<boolean>)` | OPTIONAL system op, for `vault.rotate()`: replace a record's wrapped data key, only while it still has `expectedWrappedDek` (a secret saved again in the meantime keeps its new value). Returns whether it applied. |

It only ever handles encrypted records; values are encrypted and decrypted in `createVault`. Without `ownerKeyOf`, secrets are tied to their name only, so a record copied into another owner's row would still open there. Without `scan` and `replaceWrap`, `rotate()` throws `UnsupportedOperationError`.
