Metadata and filtering
A JSON document you own, that Plane never interprets and can query.
Every session carries metadata: a Record<string, Json>, always present ({} by
default), capped at 64 KB encoded, and never interpreted by Plane. A tenant, an
account id, a locale, a ticket number.
It is the supported way to give a tool handler facts about the conversation, because the alternative is asking the model to pass them and the model is the one participant in the loop that can make things up.
const session = await client.sessions.create({
agentId,
initialMessage: "where is my order?",
metadata: { customerId: "cus_42", locale: "en-GB" }
})
// Shallow merge at the top level; `null` deletes a key.
await client.sessions.updateMetadata({
id: session.id,
mode: "merge",
metadata: { locale: "fr-FR", ticket: null }
})
// `replace` swaps the whole document.
await client.sessions.updateMetadata({ id, mode: "replace", metadata: { locale: "de" } })Every change is an event (session.metadata) carrying the change as requested —
the patch for a merge, the whole document for a replace. Folding those reproduces the
row, which is what keeps the log the source of truth. The 64 KB cap is checked on the
result, and exceeding it is MetadataTooLarge.
Filtering
await client.sessions.list({ filter: { metadata: { customerId: "cus_42" } } })Filtering is containment and nothing else: every key of the filter is present on
the row and its value is deep-equal. Extra keys on the row are ignored. Filters are
capped at 20 keys and 4 levels; anything else is MetadataFilterInvalid.
There is one operator on purpose
No ranges, no $gt, no partial match on a nested object. Containment is the operation
an index can answer and the one whose semantics fit in a sentence. If you need to find
a session by something, put that something in session metadata at the top level.
Message metadata
sessions.send({ metadata }) attaches JSON to one message rather than to the
session: which Slack message, which email thread, which client request id produced
this turn.
await client.sessions.send({
id,
message: "and also…",
metadata: { slackTs: "1725339000.001" }
})Three rules, all deliberate:
- It never reaches the model. That is the point of the field — you can put internal identifiers in it without composing a prompt-injection surface.
- It is not queryable. Session metadata is the queryable surface; message metadata is the record. The answer to "find the session for ticket 88" is to put the ticket id in session metadata.
- Annotating is an append, never a mutation. An
agent.messageis what the model said and what a resume replays, so nothing rewrites one.
await client.sessions.annotate({
id: session.id,
seq: 12, // a user.message or an agent.message
metadata: { sentiment: "positive" },
mode: "merge"
})Where it goes
Session metadata leaves Plane in the body, never in the token. Every MCP request
for a session carries params._meta.plane = { sessionId, agentId, agentVersion, metadata, identity }, and a hook envelope's session object carries it too. It is
deliberately absent from the JWS claims: it is caller-owned and up to 64 KB, and
tokens end up in proxy logs. body_sha256 already makes the body tamper-evident,
which is what lets a handler trust ctx.session.metadata.
An update takes effect on the next tool call, mid-turn included.