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 nomode= 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:
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:
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:
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
response_only parent hides its whole subtree.
Mode + Format together
Processing order
- Mode filtering — fields are checked against the effective mode.
- Format transformation — only included fields are retyped.
- Metadata preservation — every marker survives onto the generated schema.
Declaring a mode on a tool
The builder’smode= 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
mode="A"seesraw,thread_idmode="B"seespayload,thread_idmode=Nonesees everything exceptresponse_onlyanddisabled
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
- 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
Related
Mode— the marker, its values, and the cascade rulesFormat— the retyping that runs after mode filteringTool.llm_schema— the filtered schema the model sees- Egress control — the audit artifact these declarations produce