Skip to main content
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

Every parameter is keyword-only.

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 of api_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[Envelope]
default:"None"
How this API reports failure inside a 200 response. See Envelope.
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

The model the LLM fills in. Differs from 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

This tool as an OpenAI-style function definition: 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

Build everything this tool derives lazily: both views and the JSON schema. Returns the tool. Idempotent, and safe to call from several threads. Importing a pack does not build its tools’ views: a session exposes a handful of a pack’s tools and deriving all 128 of Linear’s would be most of the import. The cost does not disappear, it moves to whoever asks first, and on a schema whose types refer to each other it is seconds rather than milliseconds. Call this for the tools a process will actually expose, while it is still starting up:
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

The same tool with a narrower view: same URL, same credentials, same validators, same 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.
Selectors are dotted paths, unambiguous field names, or the model class a field is annotated with. Raises 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

The same tool with one more 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.
Usually reached through 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:
  • mode is not a label. An empty string is refused rather than read as None, because None is 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. drop already refuses that removal when a projection names the field; a label reaching the same end is refused the same way.
See the mode system.

Tool.modes

The labels in force for this tool: 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.
Both ends are reported separately by egress_map(), as mode, session_mode and modes.

Tool.paths

The 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.
The figure is the tokens the generated schema loses when that path goes, measured by pruning it and regenerating rather than estimated from subtree size. Dropping 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 Docs Request union 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 $def into per-path copies. Dropping body.requests.insert_text.location.index turns one Location_LLM into 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 Mode already removed it. events_insert prices event.i_cal_uid at 0 under mode="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 drop would refuse it. The price is what says whether pin is worth reaching for.
A projection prices what is left of it, not what the tool it came from had.
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

One entry from paths(by_cost=True). A NamedTuple, so it unpacks as a pair and reads as one.

format_path_costs

A list of costs as a bill: one path per line, counts right-aligned into a column. 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.
Order is preserved, so the dominant branch is the first line. An empty list returns (no paths).

Tool.ainvoke

Validate the arguments, build the request, send it, return the result.
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.
Three call shapes are accepted:
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

Synchronous 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