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.

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

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.