Your agent’s accessto customer accounts,enforced per field,diffed in review.

Answer “what can your agent access?” straight from the code that runs.

Get startedBrowse docs
pip install charter-ai

Act for one customer.

Only your app knows whose account a call is for, so the model never gets to say. A field marked FromContext() is filled in by your app on every call and absent from what the model sees, so “only this customer’s orders” holds even when the model names someone else.

orders.py

class GetOrder(BaseModel):
order_id: Annotated[str, Path()]
customer_id: Annotated[str, FromContext()]

↑ your app’s, per call

 
@model_validator(mode="after")
def _their_own(self):
if OWNERS.get(self.order_id) != self.customer_id:

↑ only their own

raise ValueError("another customer's order")
return self

acting for cus_42

ord_1 · sent

ord_2 · refused, another customer’s order

egress_map, committed beside the code

shop_orders_get  (GET v1/orders/{order_id})
  visible to the model (1):
    + order_id
  withheld (1):
    - customer_id  [context] <- context['customer_id']

The map comes from the same declarations the runtime executes. Commit it, and a change to what your agent can reach is a diff in review: the list your customer’s security team asks for. How the map works.

run it →

Your rules, on any field.

Each tool mirrors the API field by field, so your coding agent can check it against the API’s docs. Your own rules go on the same fields: a limit, an allowed list, or any check you can write in Python.

refund.py

class Refund(BaseModel):
charge: Annotated[str, Body()]

↑ Stripe’s field

amount: Annotated[int, Body()] = Field(
gt=0, le=10_000,

↑ max $100

)
reason: Annotated[
Literal["duplicate", "requested_by_customer"],

↑ never
“fraudulent”

Body(),
]

the model’s call

{
    "charge": "ch_3MmlLr",
    "amount": 15000,
    "reason": "fraudulent",
}
Charter
Stripe APInever reached
tells the model why ↓

what the model reads back

- amount: Input should be less than or equal to 10000 (got 15000)

- reason: Input should be 'duplicate' or 'requested_by_customer'(got 'fraudulent')

it fixes the call, or stops

Charter describes one request: pagination, retries and multi-call compositions belong in your agent.

Works with
any API.

687 tools & 17 APIs
already declared

Any other HTTP API, including your own internal ones, is declared the same way.

Open source

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.

removed entirely1,131 bytes
glm-5p3-flash0/30
deepseek-v4p10/30
nemotron-lightning0/30
the API's full filter187,655 bytes
glm-5p3-flash22/30
deepseek-v4p1400 ×30
nemotron-lightning400 ×30
curated15,531 bytes
glm-5p3-flash28/30
deepseek-v4p130/30
nemotron-lightning4/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.

The blind spots, counted.

534 runs on live accounts, no mocks. Two arms over the same tasks, models and prompts, differing only in the tool surface.

malformed GraphQL documents
raw HTTP tool 32charter 0
calls to endpoints never declared
raw HTTP tool 10charter 0
base64 the API rejected
raw HTTP tool 4charter 0
response bytes forwarded per run
raw HTTP tool 36,383charter 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 →

Declare your own.

Point your coding agent at the skill, and it declares the tools from the API’s docs.