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