Principal, context and params

What to pass where. Who is asking, what they can work with, what the chat is about, and what they're looking at right now.

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.

InputAnswersExample
principalWho is asking?{ orgId: "o_7", userId: "u_42" }
contextWhat can they work with?a database client limited to their organization, their plan
paramsWhat is this chat about?{ ticketId: "T-9" }
data-contextWhat 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.

Input1First message10:00, in the browser2Approved in Slackthe next day3Server crashedanother server finishes4Next messagein the same chat
principalWho is askingYou pass itfrom your loginLatch restores itre-checked firstLatch restores itre-checked firstYou pass itfrom your login
contextWhat they can work withRebuiltfrom the principalRebuiltcurrent plan, rolesRebuiltcurrent plan, rolesRebuiltfrom the principal
paramsWhat the chat is aboutYou pass itsaved on the chatLatch restores itfrom the chatLatch restores itfrom the chatLatch restores itnot sent again
data-contextWhat they're looking atYou pass iton the messageLatch restores itsame message, same valuesLatch restores itsame message, same valuesYou pass itthe new message's own
Your code supplies inputs only on new messages. In moments 2 and 3 nothing of yours runs except your context function, so everything else has to come from what Latch saved.
  • 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.

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) },
});
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

MistakeWhat goes wrongInstead
A plan or role in the principalFrozen into every saved run and schedule: a downgraded user keeps Pro on last month's reportThe context
What the user is looking at in the instructionsEvery navigation changes the system prompt and breaks the prompt cacheA data-context part
Reading the request inside contextThere's no request when an answer continues, so it's missing halfway throughRead it in your route; pass params or a data-context part
Trusting an id from params or data-contextAnother customer's ticket, if someone edits the requestLoad it through the user's own client in the agent function
A secret in params or data-contextSaved in your database, and sent to the modelFetch it inside the tool, from the vault
A new ticket in the same chatRefused: params are fixedStart a new chat

Go deeper