Agent tools you declare instead of implement.

You define the schema. Charter does the request, the auth and the wire format.

pip install charter

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.

tools/gmail.py
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,
)
on the wire
# 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 givenbytesglm-5p3-flashdeepseek-v4p1nemotron-lightning
removed entirely1,1310/300/300/30
the full mirror187,65522/30400 ×30400 ×30
curated15,53128/3030/304/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 runsraw HTTP toolcharter
malformed GraphQL documents320
calls to endpoints never declared100
base64 the API rejected40
response bytes forwarded per run36,3837,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.

  • GitHub
    139

    Three constant headers, one a pinned API version

  • Linear
    128

    GraphQL, cursor nested inside the response

  • Stripe
    59

    Form-encoded, bracketed query, DELETE with a body

  • Firecrawl
    43

    camelCase wire, some failures answer HTTP 200

  • Notion
    35

    100 blocks and two levels of children per write

  • Google Drive
    25

    PATCH takes a subset of the create body

  • Gmail
    23

    Mail goes out as base64url RFC 2822

  • Shopify
    22

    No fixed host, every price a nested MoneyBag

  • Slack
    18

    Rejected writes answer HTTP 200

  • Google Sheets
    17

    Cells are protobuf JSON, not plain values

  • Google Calendar
    13

    camelCase query, snake_case body

  • Granola
    9

    Four kinds of actor in one discriminated union

  • Tavily
    9

    Research is asynchronous: create, then poll

  • Google Forms
    6

    Different fields on create and update

  • Google Docs
    3

    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.