Agent tools you declare
instead of implement.
You define the schema. Charter does the request, the auth and the wire format.
549 tools · 15 APIs · 2 dependencies · Apache 2.0
01
What you write, and the
request it makes.
Four markers carry the mechanics. There is no per-endpoint code in Charter, generated or hidden.
from typing import Annotated
from pydantic import BaseModel
from charter import Path, Query
class ListMessages(BaseModel):
user_id: Annotated[str, Path()] = "me"
q: Annotated[str, Query()]
max_results: Annotated[int, Query()] = 10
list_messages = gmail(
name="list_messages",
method="GET",
url_template="gmail/v1/users/{user_id}/messages",
args_schema=ListMessages,
)# the model supplies only this
{"q": "is:unread", "max_results": 5}
GET /gmail/v1/users/me/messages
?q=is%3Aunread&maxResults=5
Host: gmail.googleapis.com
Authorization: Bearer ya29.a0Ae4lvC...When a call fails it was the arguments or it was the API. There is no tool logic for it to be. Charter describes one request: pagination, retries and multi-call compositions belong in your agent.
02
Same model. Same prompt.
A different tool surface.
One triage task, temperature 0, thirty generations per cell. The only thing that changed is how much of Linear's filter the tool exposed.
| the filter the model was given | bytes | glm-5p3-flash | deepseek-v4p1 | nemotron-lightning |
|---|---|---|---|---|
| removed entirely | 1,131 | 0/30 | 0/30 | 0/30 |
| the full mirror | 187,655 | 22/30 | 400 ×30 | 400 ×30 |
| curated | 15,531 | 28/30 | 30/30 | 4/30 |
The capability was never missing. The shape was.
The 400s are the provider refusing the request: the filter refers back to itself, and the cycle resolves past the depth limit before the model sees anything. The same model used the filter 21/21 times when the schema was accidentally flat, and 0/40 after.
03
Against the glue people write first.
534 runs on live accounts, no mocks. Two arms over the same tasks, models and prompts, differing only in the tool surface.
| across 534 measured runs | raw HTTP tool | charter |
|---|---|---|
| malformed GraphQL documents | 32 | 0 |
| calls to endpoints never declared | 10 | 0 |
| base64 the API rejected | 4 | 0 |
| response bytes forwarded per run | 36,383 | 7,382 |
The raw arm’s endpoint list is derived from the pack’s own tools, so nobody hand-picked what it could reach. The published record includes the tasks where the result was a wash, and the one template that goes the other way.
Full methodology →04
549 tools, already declared.
A pack mirrors its API rather than abstracting it. That is the trade that keeps a declaration from drifting.
- 139
GitHub
Three constant headers, one a pinned API version
- 128
Linear
GraphQL, cursor nested inside the response
- 59
Stripe
Form-encoded, bracketed query, DELETE with a body
- 43
Firecrawl
camelCase wire, some failures answer HTTP 200
- 35
Notion
100 blocks and two levels of children per write
- 25
Google Drive
PATCH takes a subset of the create body
- 23
Gmail
Mail goes out as base64url RFC 2822
- 22
Shopify
No fixed host, every price a nested MoneyBag
- 18
Slack
Rejected writes answer HTTP 200
- 17
Google Sheets
Cells are protobuf JSON, not plain values
- 13
Google Calendar
camelCase query, snake_case body
- 9
Granola
Four kinds of actor in one discriminated union
- 9
Tavily
Research is asynchronous: create, then poll
- 6
Google Forms
Different fields on create and update
- 3
Google Docs
One batch request, 33 alternative edit types
Need an API that isn’t here?
Point your coding agent at the pack-writing skill and it writes the pack.