Body format
call_api sends JSON by default. Set body_format="form" for
application/x-www-form-urlencoded, which is what Stripe, Twilio,
Mailgun and every OAuth2 token endpoint expect:
stripe_factory.py
Key casing still applies before flattening, so the cascade works as it does for
JSON.
One constraint: a form body must be a mapping of named fields. A single
Body() field carrying a list unwraps to that bare
list, which would produce [0][price]=… — meaningless to any server. That raises
a clear error; use Body(envelop=True) to keep the field name, or mark several
fields Body(). A single Body() scalar needs nothing: it keeps its own
name, so customer=Body() sends customer=cus_1.
Bracket notation in the query too
Some APIs serialise structured query values the same way they serialise bodies.query_format="bracket" sends expand[0]=customer and created[gte]=1700000000
rather than repeated keys; "repeat" (the default) sends labelIds=A&labelIds=B,
which is what Google and most REST APIs expect.
One constraint on unwrapping
A schema that declares exactly oneBody() field unwraps: that field’s value
is the body. The count comes from the schema, not from which fields a
particular call populated — otherwise a schema with several optional body fields
would unwrap whenever only one of them was set, and the wire shape would depend
on the arguments rather than on the contract.
Unwrapping promotes a nested structure’s own fields to the root, so a lone
scalar is the exception: it has no fields to promote and keeps its name.
join(channel=Body()) sends {"channel": "C1"}, not "C1" — which no API
accepts, and which a form body cannot even represent.
A body in another media type
Some operations take their body in more than one format. Microsoft Graph’ssendMail takes a JSON message, or the same message as a base64 MIME document
sent as text/plain. One operation is one tool, so the second format is an
argument: mark it Body(media_type=...).
graph_send_mail.py
mime is set, its value — after its Format — is the whole body, sent
with that Content-Type, and no key casing touches it. When it is not, the body
is the JSON it always was: a media-typed field is not counted when deciding
whether the other body fields unwrap, so a create that gains a MIME alternative
still unwraps its message.
The field excludes every other body field, and that rule is derived from the
marker rather than written beside it. It is enforced in the view the model fills
in, like a ConflictsWith, and
format_conflicts lists it:
Routes
Some operations are served at more than one path, where the extra segment only scopes the collection: Graph lists messages at/me/messages and a folder’s at
/me/mailFolders/{id}/messages; GitLab lists issues at /issues,
/projects/:id/issues and /groups/:id/issues. Declared as two tools, the model
has to pick between near-identical functions, which is picking a URL. Declared as
one, the scope is an optional argument, and the runtime picks the URL.
url_template takes the routes most specific first. A call goes to the first one
whose placeholders it filled:
graph_messages_list.py
Nothing about routes reaches the model: the schema it is given has an optional
folder_id, and no adapter reads a URL. Path values are escaped per value as
always, so a folder_id cannot reach another route. Tool.url_template is the
first route and Tool.url_templates all of them; a
ToolCall records the route the call took.
A sequence is checked when the tool is built, and refused with a
DeclarationError if a placeholder is not a
Path() field, a Path() field appears in no route, an earlier route would win
every time a later one could, or the last route needs a value a call may leave
out.
Static parameters
Values an API demands on every request, which the model should never see or set:azure_factory.py
linear_issues_list.py
issues_list from issue_create, and
it is a constant, not a parameter. Keeping it in static_body keeps it off the
schema — which is the whole boundary, because a GraphQL endpoint accepts arbitrary
documents. A tool that let the model write the query would not be an integration
with a boundary, it would be a shell.
All three are sent verbatim. static_query and static_body keys are not
case-converted — an api-version stays api-version rather than becoming
apiVersion, and a GraphQL document stays under query — and none of them appear
in the tool’s LLM schema, so they cost nothing in context and cannot be set by a
model. All three are applied last, so tool input cannot overwrite infrastructure:
a schema field named query loses to the document.
The alternative before this existed was to put them in the schema with a default
(the model sees a field it must not touch) or to spend build_request on them
(the escape hatch, gone for anything else). Both were wrong.
A build_request header override still wins over static_headers, so the escape
hatch remains above the declaration.
A base URL that is not a constant
Usually the host is a property of the API. For a single-tenant-per-installation API it is a property of the installation: a Shopify store lives athttps://{shop}.myshopify.com/, a Zendesk account at
https://{subdomain}.zendesk.com/. base_url therefore accepts a callable,
resolved on every request:
shopify_factory.py
Path() parameter for the subdomain
would let the model choose which server the request goes to, which is the one
thing a boundary cannot permit. An unresolved base URL raises before anything
leaves the process, the same way an unconfigured credential does.
Per-call headers
static_headers covers values that are constant for an API. Some values are
decided per call by the host application:
headers is keyword-only and is never merged into
args, so it does not appear in the tool’s LLM schema and a model filling in
arguments has no path to it. Which connected account a call acts as is the host’s
decision, structurally rather than by convention.
Precedence, lowest to highest: auth → static_headers → a
Header() argument → a build_request override → per-call
headers. The call site is the most specific authority, so it is the final word.
A None value is dropped rather than sent empty, which lets a caller pass an
optional header unconditionally.
Per-call context
Some rules depend on a fact only the host knows: whom the agent is acting for. “Only this customer’s orders”, “nothing before the customer is verified” and “no change without their confirmation” cannot be decided from the arguments, because the model fills those in, and a model can be talked into naming somebody else’s order or account. Declare the fact as a field markedFromContext(), and pass its value per call
as context:
order_id and nothing else. The rule reads
self.customer_id, typed like any field, and a call it refuses never reaches the
API: the model reads the reason as the tool’s result, like any other refusal.
Requiring it and reading it are one declaration. A field with no default is
required, so a call whose context lacks customer_id is refused with a
ContextError before any rule runs. There is no
list of keys to keep in step with the rules that read them, and no rule can be
written so that a missing value lets the call through. A field with a default
takes it when the host leaves the key out.
The model cannot reach it. The field is not in the tool’s schema, and an
argument of that name is an undeclared field, refused like any other. context
itself is keyword-only, as headers is.
A missing or mistyped value is the host’s error. ContextError says so in its
message, which matters because the adapters hand a CharterError back to the
model as the tool’s result: told only that a field was missing, a model would try
to supply it. A rule that refuses because of the host’s value (“verify the
customer first”) is a refusal like any other, for the model to read and act on.
On its own it never reaches the wire. It is there for the rules. Add
Path(), Query(), Body() or Header() and it is sent there as well, which is
a pin whose value arrives with each call:
FromContext("customer") reads a different key than
the field’s name.
A reviewer sees it. egress_map lists the field
as withheld with the reason context, and the key it is read from:
FromContext field is top-level only, is never one a Mode withholds, and is
outside what a projection reaches: keep leaves it alone, and naming it in drop
or pin is refused, since a value comes from one place.
invoke and ToolSession.dispatch take
context too, and every adapter carries it: the OpenAI run(..., context=...),
LangChain’s CharterMiddleware, which reads the agent’s own context, and
build_server(..., context=...) for MCP. See
passing the host’s context.
Rules that read the context directly
A rule on a nested model cannot see its parent’s fields, so it reads the host’s mapping instead, as Pydantic’s validation context:ainvoke, info.context is always a mapping, empty when the host passed
nothing. The rule then decides what a missing key means, and the one above
refuses, which is the safe reading. Prefer FromContext wherever it reaches: it
makes that decision once, for every rule.
Two things hold for any rule, whichever way it reads the host’s value:
- A field validator does not run on a field the model leaves out. Pydantic
validates what was given, and an omitted field takes its default unchecked. A
rule that must hold on every call goes on a
model_validator(mode="after"), or the field takesField(validate_default=True). - A rule on the whole call reads the call as it is sent. A field the
projection cut or a
Modewithholds reads as its default, which is what the API receives; a pinned field reads as its pin, and aFromContextfield as the host’s value.
Header arguments
Some APIs take an argument the model decides as a header rather than in the query or the body. Microsoft Graph sizes a delta page withPrefer: odata.maxpagesize=10, and offers nothing else that does it. That is a
schema field marked Header():
headers value is the host’s — which account, which
idempotency key — and a model can never reach it. A Header() field is an
argument like any other, in the schema and filled in by the model, that happens
to travel as a header. A Header() field cannot name the credential’s header or
one the tool already sets in static_headers, and a value containing a line
break is refused before the request is built.
Pagination
Where an API keeps its place in a list. Universal concept, never the same location — and, it turns out, not always a cursor. APagination declares one of
two styles.
Cursor style. The API hands back a token you send on the next call:
slack_pagination.py
github_pagination.py
items_field names where the array lives; None means the response body is the
array. Both page_param and per_page_param are required, because without the
page size there is no way to tell a full page from the last one.
cursor_field and more_field are paths into the response — dotted for nesting,
with optional list indices such as data[-1] for APIs whose cursor is derived
from the page rather than returned. cursor_param names the schema field that
carries the cursor back, and may itself be dotted to reach into a nested argument:
a GraphQL tool carries its cursor at variables.after, because that is where the
wire puts it. The declaration says where the marker goes; it does not invent a
parameter, so the field it names must exist on the schema.
Declare more_field whenever the API offers one. Without it the fallback is “a
non-empty cursor means another page”, which is wrong for every API that returns a
cursor on its last page — a Relay connection does exactly that, and the walk never
terminates.
Paging then does not require knowing the convention:
paginate_loop.py
"" on the last page, and
treating that as a cursor loops forever. When more_field is declared it wins
over the cursor, because some APIs send a stale cursor on the final page. In
page-number style a short page is the only end signal there is, so pass the
previous arguments to next_page_args — that is where the page size lives.
One caution for pack authors: a response handler that trims a list must keep
whatever the declaration reads — the object id for a derived cursor, and
has_more even when it is False. Drop the latter and a derived cursor, which is
always present, leaves the loop with nothing to stop on.
This declares the location. It does not loop; see “What this can’t express” in
the README.
Retry-After
APIError.retry_after carries the parsed Retry-After header when the server
sent one — usual on 429, common on 503. Charter never retries; it just refuses to
discard the server’s own answer to “when?”.
Parameters that exclude each other
APIs state these in prose and answer them with a400. Declare the rule on the
field it is about with ConflictsWith, and
the runtime builds the check:
calendarList.list had exactly that — the validator named
showOwnOrganizationOnly, the schema had no such field, and nothing exercised
either.
Every conflict is reported in one message rather than one per call, and each
field is named the way the API names it, taking a
WireName when there is one:
a or b”, “b is required when
a is set”, “this flag may be set but not to False” — stay a
@model_validator(mode="after"). ConflictsWith says “not both” and nothing
else.
Related
ConflictsWith— the markerformat_conflicts— every rule in a pack, read back- Envelopes — when a 200 is not a success
- Key case cascade