Skip to main content
Beyond where a field goes and how it is encoded, an API has a handful of constants: what its bodies look like, what it demands on every request, and where it keeps its cursor. Each of these is declared once per API, not repeated per tool.

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
Nested values use bracket notation — the Rack/Stripe convention: 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 one Body() 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’s sendMail 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
When 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
A request has three places to put something, and constants exist for all three. The third is the body — declared per tool, since a constant in the body belongs to one operation rather than to the API:
linear_issues_list.py
That is what makes a GraphQL API expressible. Every tool POSTs to the same URL; the query document is what distinguishes 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 at https://{shop}.myshopify.com/, a Zendesk account at https://{subdomain}.zendesk.com/. base_url therefore accepts a callable, resolved on every request:
shopify_factory.py
The host must never be a schema field. A 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:
Two properties are deliberate: The caller owns the value. Charter does not generate idempotency keys. A key minted fresh on every call is worse than none, because the entire point is that a retry sends the same key — and Charter does not own retries, so it does not own the key. This is a channel, not a feature. The model cannot reach it. 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 marked FromContext(), and pass its value per call as context:
The model is offered 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:
The list is scoped to the customer the host verified, and the model can neither see nor change the filter. 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:
A 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:
Inside 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 takes Field(validate_default=True).
  • A rule on the whole call reads the call as it is sent. A field the projection cut or a Mode withholds reads as its default, which is what the API receives; a pinned field reads as its pin, and a FromContext field 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 with Prefer: odata.maxpagesize=10, and offers nothing else that does it. That is a schema field marked Header():
The two kinds of header answer different questions, and the line between them is who decides. A per-call 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. A Pagination declares one of two styles. Cursor style. The API hands back a token you send on the next call:
slack_pagination.py
Page-number style. There is no token at all. You ask for page 1, 2, 3 and stop when a page comes back shorter than you asked for. GitHub works this way:
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
An empty cursor counts as absent — Slack returns "" 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?”.
Only the delta-seconds form is parsed. The HTTP-date form is legal but rare in JSON APIs, and guessing at clock skew is worse than reporting nothing.

Parameters that exclude each other

APIs state these in prose and answer them with a 400. Declare the rule on the field it is about with ConflictsWith, and the runtime builds the check:
Written as a validator instead, the rule needs a list of the fields it covers, which is a second place to keep in step: add a parameter and the list forgets it, delete one and the list keeps naming a field that is gone. Google Calendar’s 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:
Rules of a different shape — “exactly one of 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.