Tool binds a Pydantic schema to one HTTP endpoint and executes it in your
process. Build one directly for a single endpoint; build them through
the factories when several endpoints share a base URL,
an auth method and a casing convention.
Tool
Identity and routing
str
required
The tool’s name, as the model sees it and as observability records report it.
"GET" | "POST" | "PATCH" | "PUT" | "DELETE"
required
HTTP method.
str | Sequence[str]
required
Path relative to
base_url, with a placeholder per Path() field —
"gmail/v1/users/{userId}/messages/send". Placeholder names are matched
against the path keys after casing is applied. For one operation served at
several paths, a sequence of templates, most specific first: a call goes to
the first whose placeholders it filled. See
routes. tool.url_template is the first route
and tool.url_templates all of them.Type[BaseModel]
required
The schema — the contract. Markers on its fields decide routing, encoding,
visibility and key casing.
str | Callable[[], str]
required
The API’s base URL. A callable is resolved on every request, which is what a
single-tenant-per-installation host needs — a Shopify store, a Zendesk
account. The host must never be a schema field.
str
default:"\"\""
What the tool does, written for the model. Appears in
to_json_schema().Optional[str]
default:"None"
Short user-facing phrase for an approval UI —
"Send an email". Metadata; the
runtime does not read it.Auth
Exactly one ofapi_key_headers and credential_provider is required. Passing
both, or neither, raises DeclarationError at build time.
Dict[str, str] | Callable[[], Dict[str, str]]
default:"None"
Headers injected on every request. A callable is resolved per request, which
is how a credential can arrive after the tools are built. A literal mapping
with an empty value is rejected at build time rather than at the first 401.
Optional[CredentialProvider]
default:"None"
Supplies the bearer token, fetched on every call. See
Credentials.
Optional[str]
default:"None"
The identifier passed to
get_credentials(provider), and the peer.service
label on every ToolCall. One provider implementation can serve several APIs.Optional[ScopesDeclaration]
default:"None"
What this tool needs from a grant: scope strings, an
AnyOf where the provider accepts several, or
Unavailable. A mapping of
Kind to such lists declares them for each kind of
credential the provider issues. Metadata for consent screens and approval UIs —
Charter never requests them. Read back by
scopes_for.int
default:"10"
How dead a bearer token must be before the runtime refuses to send it. A last
guard against sending something already expired, not a refresh policy — see
OAuth2Client, which renews well before
this.Optional[Iterable[int]]
default:"None"
Response statuses that mean “your credential”, raising
CredentialError
rather than APIError. Defaults to {401}. Declare {401, 403} for an API
that means “your token” by 403; most mean “not allowed”, which is an
APIError. Stored as a frozenset.Casing and encoding
"camel" | "snake" | "pascal" | "kebab"
default:"\"camel\""
Case convention for body keys.
"camel" | "snake" | "pascal" | "kebab"
default:"\"snake\""
Case convention for query keys.
"camel" | "snake" | "pascal" | "kebab"
default:"\"snake\""
Case convention for path keys.
"json" | "form"
default:"\"json\""
"form" sends application/x-www-form-urlencoded with bracket notation for
nested values — what Stripe, Twilio and OAuth2 token endpoints expect."repeat" | "bracket"
default:"\"repeat\""
"repeat" sends a list as repeated keys (labelIds=A&labelIds=B);
"bracket" sends expand[0]=A.Optional[Dict[str, Any]]
default:"None"
Query parameters sent verbatim on every request. Keys are not case-converted
and do not appear in the LLM schema.
Optional[Dict[str, str]]
default:"None"
Headers sent verbatim on every request — an Azure
api-version, a
Notion-Version.Optional[Dict[str, Any]]
default:"None"
Body keys sent verbatim on every request. The GraphQL query document is the
case this exists for: the constant belongs to the operation, so it is declared
per tool.
Behaviour
Optional[str]
default:"None"
The tool’s mode, which decides which
Mode-marked fields survive into the LLM
schema. See the mode system.Optional[Callable[[BaseModel], TransportOverride]]
default:"None"
Escape hatch: a function returning a
TransportOverride that replaces the
path, query, body or headers derived from the schema. When omitted, one is
generated from args_schema — including the Format transforms, which is why
a hand-written build_request takes over responsibility for them.Optional[ResponseHandler]
default:"None"
Async callable that reshapes the payload before it is returned. Runs only on a
success — HTTP and envelope failures have already raised. A non-async callable
raises
DeclarationError at build time. See
response handling.Optional[Iterable[ScopeRefusal]]
default:"None"
How this API says the token’s grant does not cover the call, so each such answer
raises
InsufficientScope. The standard
WWW-Authenticate challenge needs no declaration. See
ScopeRefusal.Optional[Pagination]
default:"None"
Where this API keeps its cursor. Declares the location; does not loop. See
Pagination.int
default:"20"
Request timeout, in seconds.
Optional[CallSink]
default:"None"
Receives a
ToolCall after every
invocation, successful or not. There is no default sink and no ambient
registry: a tool reports to whoever was named when it was built, or to nobody.Metadata
Optional[int]
default:"None"
What one call costs against the provider’s own rate limit, in that provider’s
units — Gmail bills
messages.send at 100 and messages.list at 5. Nothing
in the runtime enforces it; it is the input a budget policy needs.Optional[str]
default:"None"
Link to the API’s own rate-limit documentation.
Attributes
Every constructor parameter above is readable as an attribute of the same name, with these normalised on the way in.List[ScopeRequirement]
The default kind’s requirements, always a list: what every caller that knows
nothing of kinds reads.
None becomes [].Optional[Kind], Dict[Kind, List[ScopeRequirement]]
The kind a plain list means, when the tool’s pack names one, and every other
kind’s requirements.
None and {} for a tool declared with a plain list.Tuple[ScopeRefusal, ...]
Always a tuple.
None becomes ().Optional[Dict]
Copies, so the caller’s dict cannot be mutated through the tool.
Optional[FrozenSet[int]]
A
frozenset, or None when the default {401} applies.Type[BaseModel]
The schema the tool was declared with — the full one, including
Mode("response_only") fields the model never sees.Methods
Tool.llm_schema
args_schema in two ways:
Mode("response_only") and Mode("disabled") fields are gone, and Format
fields carry their semantic type — EmailContent rather than a base64 string.
Derived once, on first use, and then memoised.
Tool.prepare pays that cost at startup instead
of inside a request.
Tool.to_json_schema
name, description, and
parameters from the LLM schema’s model_json_schema().
Generated once and copied out, so an adapter may run it inside the turn loop.
The first call derives the view if nothing else has, which on a recursive schema
takes about 1.8 seconds. This method is synchronous, so it blocks whichever
thread calls it. Tool.prepare moves that to
startup.
Tool.prepare
ainvoke derives what it needs on a worker
thread if this was never called, so skipping it costs latency on one call rather
than blocking the event loop. Nothing can do that for a synchronous caller:
to_openai_tools is documented to run inside the turn loop and
calls to_json_schema, which blocks the thread it is on.
A ToolSession does this for the tools it
publishes, when it publishes them: at the default threshold it builds nothing on
construction, and ToolSearch prepares each tool as it loads it. Give it a
positive threshold and it has to size every schema to place it, which builds
every schema — 2.5s for Linear’s 128 tools, on whichever thread constructs the
session, so construct it at startup rather than per request.
A non-zero
schema_ms on a call in production means a
tool nobody prepared.
Tool.derived
extra="forbid". A projection can only remove, so an argument outside it
raises ToolValidationError before a
request is built.
keep selects within the sibling group it names, leaving the rest of the schema
alone. drop removes a path. pin removes a path from the view the model fills
in and keeps it in the one the runtime executes, so the field is neither visible
to the model nor reachable by it, and its value takes the same Format
transform, body unwrapping, key casing and escaping a supplied one would.
response_handler is the one argument here that narrows nothing: it replaces what
the model is handed back. Left unset, the projection inherits this tool’s — for a
pack tool, the handler written for that endpoint. Pass
pass_through to turn the trimming off
and receive the response whole, or your own async callable to reshape it. A
non-async one raises DeclarationError here,
not mid-request.
scopes replaces what the tool says it needs from a grant, for an agent that
only ever touches part of what the tool can reach: public repositories on GitHub,
files its own app made on Drive. It changes the consent
scopes_for builds and nothing about the call.
Charter cannot check the claim; the provider will, with a 403 or a 404 on the
first resource the scope does not reach. A list replaces the default kind’s
requirements and keeps the others; a mapping of Kind
to lists replaces the kinds it names.
DeclarationError
when a selector names nothing, names several things, would drop a field the API
requires, or pins a field Mode withholds from the
executed view, which would be rejected on every call.
Projections compose. Both keep and pin appear in
egress_map().
Tool.with_mode
Mode in force: same
name, same URL, same credentials, same projections, and a view built by filtering
the same declarations against mode as well as the one the tool was declared
with. Passing the label already applied returns the tool itself, so the views it
has built are reused.
ToolSession(tools, mode=...), which
does this to every tool it is given.
It adds a label, it does not replace one. The author’s mode= says which
operation a tool is — create replaces and update merges — and that is not a
deployment decision. A deployment’s label says which surface the conversation
is. Both are in force at once (Tool.modes is the pair), so a
tool declared mode="create" and resolved at "pro" keeps its create fields
and gains its pro ones. That is what makes the parameter predictable against a
pack you have not read: a session label only ever adds the fields carrying
it. with_mode(None) clears a deployment’s label and leaves the author’s.
Not a projection. derived can only remove, which is what makes one safe to
hand to whoever owns a deployment. This adds, so against the tool it came from it
widens. What it cannot do at any label is reach a field marked
Mode("response_only") or Mode("disabled"), which are refused without
consulting the mode at all. The author’s floor holds at every label, and so does
the author’s partition.
The published name does not change. A pro variant of messages_list is still
messages_list, because a tool’s name is a function of that tool
alone and an allow-list or a log grep over a half-suffixed
surface misses the other half in silence.
Raises DeclarationError for a mode that
returns a tool which cannot work, rather than returning it:
modeis not a label. An empty string is refused rather than read asNone, becauseNoneis the widest view and not the narrowest — a plan lookup that came back empty would otherwise be served every tier.- The mode hides a field this tool pins. The pinned value is merged into the executed view and revalidated there, so a pin whose field the mode removed would fail every call.
- The mode hides a field the API requires. The request would go out without
it.
dropalready refuses that removal when a projection names the field; a label reaching the same end is refused the same way.
Tool.modes
mode, the one its author declared, and
session_mode, the one a deployment resolved it at. A field carrying a custom
Mode is offered when either names it.
An empty set means no partition is in force, which is the widest view and not
the narrowest — a custom label is inert when there is nothing to match it
against. That one rule is why a first label narrows and a second only ever
widens.
egress_map(), as mode, session_mode
and modes.
Tool.paths
Tool.derived can name, one level at a
time. Pass a prefix to drill down, depth=None for the whole subtree. Paths this
tool already pruned are gone, along with everything beneath them.
bool
default:"False"
Price each path instead of naming it. Returns
PathCost pairs, most expensive first.body.requests.replace_all_text from a tool priced at 7,336 tokens
leaves one of 7,822, which is its reported 361 exactly.
Only the level being returned is priced, one schema generation per path. Drill
down with under rather than pricing a whole subtree at once.
Four properties to read the numbers by. Each is something drop really does,
not noise in the measurement:
- Costs do not sum to the total. Where two fields share a
$def, dropping either one alone leaves it in place, so both price cheap and dropping both is worth more than the sum. The 33 members of the DocsRequestunion price at 6,827 between them; the branch they sit in costs 7,807. - A negative cost means the drop makes the tool larger. Pruning inside a model
several siblings share splits one
$definto per-path copies. Droppingbody.requests.insert_text.location.indexturns oneLocation_LLMinto two and takes the tool from 7,336 tokens to 7,584. - A cost of zero means the path is not in the view at all, because
Modealready removed it.events_insertpricesevent.i_cal_uidat 0 undermode="write";events_import, which reveals the field, prices the same path at 103. A projection naming a zero-cost path is inert. - A required path is priced even though
dropwould refuse it. The price is what says whetherpinis worth reaching for.
Drilling in with
under does not reopen a cycle. The models passed through to
reach under are still held against the walk, so a field whose type is already
its own ancestor is listed once, wherever you ask from. On a recursive Notion
filter, paths("body.filter") lists and_ and paths("body.filter.and_")
lists nothing, because and_ is that same filter.This is the listing only. Write such a path out and
derived still resolves it, and keep still
prunes its siblings. What it buys you is the negative-cost case above: pruning
inside a model its siblings share splits one $def rather than removing
anything, so the tool comes back larger.PathCost
paths(by_cost=True). A NamedTuple, so it unpacks as a pair and
reads as one.
format_path_costs
by_cost=True is asked in order to find the branch that dominates, and that is a
comparison of magnitudes — a column answers it before any path name has been read.
(no paths).
Tool.ainvoke
Optional[Dict[str, Any]]
default:"None"
Positional-only. Merged with
**kwargs, which win on a conflict.Optional[Mapping[str, Any]]
default:"None"
What the host knows about this call that the model’s arguments cannot say:
whom the agent is acting for, whether they were verified, whether they
confirmed the action. Fills in every
FromContext
field, and reaches every validator as Pydantic’s validation context
(info.context, always a mapping). Keyword-only and never in the tool’s
schema, so a model cannot supply it. A required value it lacks, or one that
does not fit, raises ContextError. See
per-call context.Optional[Mapping[str, str]]
default:"None"
Headers the host application decides for this one call — an
Idempotency-Key, a Stripe-Account, a correlation id. Keyword-only and
structurally separate from args, so a model filling in tool arguments can
never set a header. Charter generates none of these values.Optional[httpx.AsyncClient]
default:"None"
Reuse one client across calls. When omitted, a client is created and closed
per call.
context, headers and client shadow schema fields of those names. Pass such
a field in the positional dict.
Every call is measured, including the ones that fail. See on_call and
observability.
Tool.invoke
ainvoke, run with asyncio.run. Calling it from a thread that
already has a running event loop raises RuntimeError naming the tool; inside
async code, await ainvoke instead. It takes no client, since the loop that
would own one does not outlive the call.
Raises
Example
Related
- The wire contract — body format, static parameters, per-call headers
- The mode system — what the model is allowed to see
- Projections — narrowing a tool you did not declare
- Adapters — the LangChain, MCP and OpenAI views of this object