Plane docs
Concepts

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.message is 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.

On this page