Skip to main content

Overview

Mode gives fine-grained control over which schema fields are exposed to the LLM. It matters most for APIs where one resource is used by several operations with different field sets — Gmail’s Message being the canonical case: send accepts only raw, while reads return a structured payload tree. It has a second job that is easy to miss and is arguably the more valuable one: because the LLM view is computed from these declarations before any request is made, Mode is also field-level data minimisation toward your model vendor — a withheld field cannot reach a context window, by construction. See egress control, including the audit artifact that renders it.

Core concepts

The marker

mode_marker.py

Special modes

Always enforced, whatever mode the tool declares:
  • response_only — the API returns it and never accepts it. Hidden from the LLM. IDs, timestamps, computed fields.
  • request_only — input-only. Always shown.
  • disabled — never exposed. Internal or deprecated fields.

Custom modes

Any other string. A custom mode narrows the view only when the tool declares a mode. With no mode= on the tool, custom-moded fields are all visible — setting mode= is what makes create and update mutually exclusive. create and update are the common case, not the mechanism. The string is arbitrary, so one resource model can serve any set of tool surfaces you can name:
The author declares the labels. The deployer puts one in force beside them. mode= on a tool is the parameter to reach for while writing tools. Tool.with_mode is the other end, for a pack that already ships, and ToolSession(tools, mode=...) applies it to a whole surface at once:
One line instead of a TOOLSETS = {"free": [...], "pro": [...]} dict and the code that chooses between its entries. The pack says which fields belong to which surface once; the deployment says which surface this conversation is. Three things to know about the deployer’s end. The session mode adds to the tool’s own; it does not replace it. The two ends answer different questions. The author’s mode= says which operation this tool is — create replaces and update merges — and that is not a deployment decision. The deployment’s label says which surface this conversation is. Both are in force at once, so a tool declared mode="create" and resolved at "pro" keeps the fields that make it a create and gains the ones that make it pro:
What follows is the property worth holding on to: a session label only ever adds the fields carrying it. You do not have to read a pack to know what keying it to a tier will do, and two packs with different internal labelling conventions behave the same way under the same session mode. It resolves once, when the session is built. Credentials resolve per call because the subject can change mid conversation; a schema cannot, because it was already serialised into a prompt the model is still reading. A surface that has to change means a new session. It cannot widen past the declaration. response_only and disabled are refused without consulting the mode at all, so there is no string a deployment can pass that reaches a field the author withheld unconditionally. What a mode moves is which of the author’s optional surfaces you get, and the map at egress control prints which label resolved the view.

A label, or None

None is the widest view, not the narrowest: a custom Mode is inert when there is nothing to match it against, so with no mode every tiered field is offered. That is what None means and it is the default. An empty string is therefore refused with a DeclarationError rather than read as None. A session mode comes from a runtime value — plan_of(request.user), where a nullable plan column or a user.plan or "" hands back "" for the smallest tier — and reading that as “no tier” would serve that user every tier, and would widen a tool the pack shipped at free while it was at it. Every other way a label can be wrong fails closed: "prro" matches nothing and withholds everything optional. A label that withholds a field the API requires is refused the same way, and where it is asked for rather than on every call. Mode moves visibility, and requiredness is a separate axis, so a label can take a required field out of the view and leave nothing to put it back: the tool would validate a call that never mentions the field and send a body without it. drop already refuses that removal when a projection names the field by path; a label reaches the same end and is refused the same way. A required field has to be visible at every label the pack offers — name each of them in its Mode(), or leave it unlabelled — and a constant the API needs on every call belongs in static_body/static_query on the factory. This is not keep, drop and pin, and does not replace them. A projection can only ever remove, works on fields and values, and needs no cooperation from the pack. A mode reads labels the author wrote and can widen against the tool it came from. Use a projection to narrow a tool nobody labelled; use a mode where the labels already exist.

Features

Multi-mode

Cascading

Child fields inherit the parent’s modes, so you mark the parent once:
cascading_modes.py
A response_only parent hides its whole subtree.

Mode + Format together

Processing order

  1. Mode filtering — fields are checked against the effective mode.
  2. Format transformation — only included fields are retyped.
  3. Metadata preservation — every marker survives onto the generated schema.

Declaring a mode on a tool

The builder’s mode= parameter picks the effective mode for that tool:
messages_send_tool.py

Worked example: Gmail

Problem. messages.send accepts only raw (RFC-822). Reads return a structured payload MIME tree. One schema for both confuses the model into sending fields the endpoint ignores. Solution.
gmail_message_schema.py
Result:
  • mode="A" sees raw, thread_id
  • mode="B" sees payload, thread_id
  • mode=None sees everything except response_only and disabled

Inspecting the result

Tool.llm_schema builds the filtered view — what the model is actually handed:

Do’s and don’ts

Do
  • lean on cascading instead of marking every nested field
  • say why a field has a mode, in the field description
  • test each mode configuration
  • use semantic mode names
Don’t
  • mark every nested field redundantly
  • mix concerns into one name (Mode("admin_read_v2"))
  • forget to set mode= on the tool when the schema depends on it
  • use modes for authorization — they are a schema-visibility tool, not a permission system
  • Mode — the marker, its values, and the cascade rules
  • Format — the retyping that runs after mode filtering
  • Tool.llm_schema — the filtered schema the model sees
  • Egress control — the audit artifact these declarations produce