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.
| 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.
- Is it who the user is? Use the principal. Ids only.
- Can you work it out from who they are? Use the context: their plan, their permissions, the organization's name, clients for their data.
- Is it what this conversation is about, decided when it starts? Use params: a ticket, a document, a model fixed for the chat.
- Does it change while they chat? Put it in a
data-contextpart 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.
| Input | 1First message10:00, in the browser | 2Approved in Slackthe next day | 3Server crashedanother server finishes | 4Next messagein the same chat |
|---|---|---|---|---|
principalWho is asking | You pass itfrom your login | Latch restores itre-checked first | Latch restores itre-checked first | You pass itfrom your login |
contextWhat they can work with | Rebuiltfrom the principal | Rebuiltcurrent plan, roles | Rebuiltcurrent plan, roles | Rebuiltfrom the principal |
paramsWhat the chat is about | You pass itsaved on the chat | Latch restores itfrom the chat | Latch restores itfrom the chat | Latch restores itnot sent again |
data-contextWhat they're looking at | You pass iton the message | Latch restores itsame message, same values | Latch restores itsame message, same values | You pass itthe new message's own |
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-contextpart 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
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
reconstructPrincipalcan 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
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 asoptions.context, together withprincipal:
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
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 },
);// 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
runAgentandschedule. - Checked against the schema (400
invalid_paramsif 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
paramsand every tool asoptions.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:
sendMessage({
parts: [
{ type: "text", text: "Summarize this" },
{ type: "data-context", data: { activeFile: "Q3 budget", openTabs: ["Roadmap"] } },
],
});What the model sees for that message:
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:
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, withdefineAgent(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.
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) },
});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
- Options: createRuntime (
context,messageContext), defineAgent (params), Context and tools