Your agent's access to customer accounts, enforced per field, diffed in review.
Answer "what can your agent access?" straight from the code that runs.
Quickstart · Examples · Documentation · Coding agents · Packs
Charter
Charter is an open-source Python library for agents that act in your customers' accounts through APIs. Each tool is a Pydantic model that mirrors the API field by field, with your rules on top, and every call is checked against it before it's sent. Your app says whose account each call is for, and the model can't change it. The same declarations list everything each tool can reach, for code review and for your customer's security team. It works for any HTTP API, public or private.
A library, not a service. Runs in your process. No proxy, no per-call pricing, no telemetry.
Installation
pip install charter-ai
Requires Python 3.10+. The only dependencies are pydantic and httpx.
Quickstart
An agent acting for one customer looks up two orders. A rule like "only this customer's
orders" needs to know whose account the call is for, and that has to come from your app, not
from the model. Mark the field FromContext() and pass its value per call:
from typing import Annotated
from pydantic import BaseModel, model_validator
from charter import FromContext, Path, ToolValidationError, api_key_tool_factory, format_egress_map
OWNERS = {"ord_1": "cus_42", "ord_2": "cus_7"} # from your database
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) -> "GetOrder":
if OWNERS.get(self.order_id) != self.customer_id:
raise ValueError("That order belongs to another customer.")
return self
shop = api_key_tool_factory(
pack="shop",
base_url="https://httpbin.org/anything/", # stands in for your API
api_key_headers={"Authorization": "Bearer sk_test_123"},
)
get_order = shop(
name="orders_get",
description="Get one of the customer's orders.",
method="GET",
url_template="v1/orders/{order_id}",
args_schema=GetOrder,
)
for order_id in ("ord_1", "ord_2"):
try:
get_order.invoke({"order_id": order_id}, context={"customer_id": "cus_42"})
print(order_id, "sent")
except ToolValidationError as refused:
print(order_id, "refused:", refused.errors[0]["msg"])
print(format_egress_map([get_order]))
ord_1 sent
ord_2 refused: Value error, That order belongs to another customer.
shop_orders_get (GET v1/orders/{order_id})
visible to the model (1):
+ order_id
withheld (1):
- customer_id [context] <- context['customer_id']
The model fills in order_id and nothing else. A model talked into naming another customer's
order is refused before anything is sent, and so is a call your app made without saying whose
it is. 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. 14_act_for_one_customer.py
runs it, and per-call context
has the rest.
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. Charter builds the request from the model, and a unit test can check every rule without anything reaching the API.
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from charter import Body, ToolValidationError, api_key_tool_factory
class Refund(BaseModel):
charge: Annotated[str, Body()]
amount: Annotated[int, Body()] = Field(
gt=0, le=10_000, # yours: at most $100
)
reason: Annotated[
Literal["duplicate", "requested_by_customer"], # yours: no "fraudulent"
Body(),
]
payments = api_key_tool_factory(
pack="payments",
base_url="https://httpbin.org/anything/", # stands in for a payments API
api_key_headers={"Authorization": "Bearer sk_test_123"},
body_format="form",
)
refund = payments(
name="refund",
description="Refund a charge.",
method="POST",
url_template="v1/refunds",
args_schema=Refund,
)
refund.invoke({"charge": "ch_3MmlLr", "amount": 1500, "reason": "duplicate"}) # sent
try:
refund.invoke({"charge": "ch_3MmlLr", "amount": 15000, "reason": "fraudulent"}) # refused
except ToolValidationError as refused:
print(refused)
The first call goes out (httpbin echoes it back):
POST https://httpbin.org/anything/v1/refunds
Authorization: Bearer sk_test_123
charge=ch_3MmlLr&amount=1500&reason=duplicate
The model fills in three fields. The key, the URL and the limits stay yours.
The second call is refused before it leaves your process, and the model gets this back, naming each field and what it sent:
Validation error:
- **amount**: Input should be less than or equal to 10000 (got 15000)
- **reason**: Input should be 'duplicate' or 'requested_by_customer' (got 'fraudulent')
It can fix the call or stop; Charter never improvises.
What else Charter does
| What Charter does | Measured | |
|---|---|---|
| Sees | Cuts a tool down to the fields the job needs | Google Docs' write tool has 33 kinds of edit; cut to the 3 a job needs, its schema goes from 7,336 tokens to 1,553 (details) |
| Sends | Builds wire formats like MIME, base64 and GraphQL, so the model fills in plain fields | Over 534 runs, a raw HTTP tool sent 32 malformed GraphQL documents and tried 10 calls to endpoints never declared; Charter did neither (details). Of six calls that break the vendor's own documented rules, Charter refused all six before sending; the APIs' own or most-starred MCP servers let four of five through (details) |
| Gets back | Trims responses, and raises when a 200 is really a failure |
GitHub sends 346.6 KB, the model gets 10.7 KB (details) |
Who it's for
| If you're... | Charter gives you |
|---|---|
| Facing a security review | What the agent can see and send, enforced in code and diffed in review: whose account a call acts in, set by your app per call (FromContext), values it can't see or change (pin), fields per pricing tier or region (Mode), OAuth scopes worked out from the tools you hand out (scopes_for), and a list of everything each tool can reach (egress_map) |
| A SaaS adding an agent that works in your users' Gmail, Stripe or Notion | Per-user OAuth with your app's name on the consent screen, and ready-made tools |
| On Composio or Zapier | Tools you own, and your users' tokens and data stay on your servers (how it compares) |
| Writing tool glue by hand | Catches the bugs hand-written glue lets through: a bad argument is refused before it's sent, and an Envelope raises when a 200 is really a failure |
| Running a small or open model | Tools cut down to the fields the job needs (derived), and a one-line hint on the exact field it keeps getting wrong (Gloss) |
Examples
examples/basics/ has fourteen
short scripts, numbered in reading order, and none of them needs a key or an account.
| Example | Shows |
|---|---|
03_errors_for_the_model.py |
A bad argument refused before sending, with a fix the model can apply |
08_many_tools.py |
687 tools in one session, starting from 5,009 tokens |
11_one_tool_per_plan.py |
One tool with a different field set per plan |
14_act_for_one_customer.py |
Your app says whose account each call is for: another customer's order is refused |
use_a_pack.py |
A shipped tool, with your GitHub token: GitHub sends 346.6 KB, the model gets 10.7 KB |
openai_loop.py |
Tools in Charter's own agent loop, with any OpenAI-compatible model (needs a model key) |
connect/ |
Your own account and your users', by OAuth or API key |
Shape a tool
A tool's schema is in the prompt on every turn, so its size and shape change what the model does. We tested this on Linear's issue list, thirty runs per cell at temperature 0:
| 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 API's full filter | 187,655 | 22/30 | 400 ×30 | 400 ×30 |
| curated | 15,531 | 28/30 | 30/30 | 4/30 |
400 ×30 means the provider rejected the request all 30 times, before the model saw it: the full
filter is recursive and nests deeper than the provider accepts (schema depth exceeds maximum limit of 50).
paths(by_cost=True) shows where a tool's tokens go:
from charter import schema_tokens
from charter.packs import linear
schema_tokens(linear.issues_list_full) # 45072
linear.issues_list_full.paths(under="variables", by_cost=True)
# [PathCost(path='filter', tokens=44828),
# PathCost(path='order_by', tokens=52), ...]
derived(keep=...) trims it to what a triage agent needs:
from charter import schema_tokens
from charter.packs import linear
F = "variables.filter."
issues_list_triage = linear.issues_list_full.derived(
name="issues_list_triage",
keep={
F + "id", F + "number", F + "title", F + "priority",
F + "due_date", F + "created_at", F + "updated_at", F + "completed_at",
F + "state.type", F + "state.name",
F + "assignee.email", F + "assignee.name",
F + "team.key", F + "team.name",
F + "labels.name",
},
)
schema_tokens(issues_list_triage) # 3458
Keep team.key, not team: a whole relation brings the recursive filter back and lands you at 46,313 tokens.
The shipped linear.issues_list is curated at 7,652 tokens.
See also 06_cut_a_tool.py, and
09_rules_on_fields.py for hints on a single field
instead of in the system prompt.
Control what goes out
Format builds wire formats so the model fills in plain fields. Here the model writes
to, subject and body, and Charter sends base64url RFC 2822 under raw:
from typing import Annotated
from pydantic import BaseModel
from charter import Body, EmailContent, Format
class SendEmail(BaseModel):
raw: Annotated[EmailContent, Body(envelop=True), Format("rfc822_base64")]
We measured this over 534 runs on live accounts, against giving the model a generic HTTP tool instead (methodology):
| 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 |
scopes_for works out the OAuth scopes to request from the tools you hand out:
from charter.auth import scopes_for
from charter.packs import gmail
scopes_for([gmail.messages_list]) # ['https://www.googleapis.com/auth/gmail.readonly']
scopes_for([gmail.threads_delete]) # ['https://mail.google.com/']
More controls:
pinsets a value the model can't see or change (10_pin_a_value.py)Modegives a tool different fields per plan or region (11_one_tool_per_plan.py)egress_maplists everything the model can reach, for code review (13_review_the_boundary.py)
Control what comes back
Packs trim their responses by default, and you can do the same on your own tools with
response_handler (04_trim_responses.py). Some APIs answer a failure with
200, like Slack's {"ok": false} or a GraphQL errors list. An Envelope turns those
into errors:
from charter import Envelope
Envelope(errors_field=("errors", "data.*.userErrors"))
Use with your framework
from charter import ToolSession
from charter.adapters.openai import to_openai_tools
from charter.packs import github
session = ToolSession(github.TOOLS)
tools = to_openai_tools(session) # function definitions for any chat-completions API
charter.adapters.openai.run()runs the tool-calling loop with any OpenAI-compatible client (openai_loop.py)CharterMiddlewareadds a session to a LangChain agent (frameworks/)charter-mcp --pack gmailserves a pack over MCP
Ready-made tools
687 tools across seventeen APIs ship with Charter, from Gmail and Slack to Stripe and Linear (full list). You can extend them with your own tools, request a pack, and contributions are welcome.
How Charter compares
Identity and access layers for agents (Keycard, Arcade, Permit, WorkOS and others) decide
which token, scope or tool an agent gets. Charter works one level down, inside each call:
which fields the model can set, which values it sends, and whose account the call is for.
gmail.readonly lets an agent read every email; Charter decides which query it may send.
The two stack: the token layer narrows the grant, and Charter narrows each call made with it.
Hosted tool platforms like Composio run each call on their own servers. Charter runs in your process, so your users' tokens and data never pass through a third party, and a call costs what the API does. In our benchmark, Composio added about 0.7 s per call.
Guardrail frameworks like NeMo Guardrails, Guardrails AI and Parlant work on the conversation: what goes into the prompt, or the text going in and out, often checked by another model. Charter checks the call itself, in code, so a rule holds the same on every model.
It's not an agent framework either. LangChain, the OpenAI SDK or an MCP client runs the loop, and Charter is the tools it calls.
Limitations
Charter describes one request; loops, retries and multi-step flows belong in your agent.
Multipart upload, request signing and streaming are not supported yet. A session applies
one Mode label at a time, and Mode controls what a tool exposes, not who may call it.
See limitations.
Documentation
| Start | Quickstart · Installation · Why Charter |
| Packs | Overview · Write one with a coding agent |
| The boundary | Egress control · Mode system · Quick reference |
| The wire | Wire contract · Envelopes · Transforms · Key case |
| Credentials | Getting the grant · Authorization servers · API keys |
| Running it | What a call cost · Adapters and MCP · Errors |
| Guarantees | Conformance · Measured results · Limitations |
| Reference | API reference · AGENTS.md |
Contributing
uv venv
uv pip install -e ".[dev,langchain,mcp]"
uv run pytest
uv run ruff check src tests examples
uv run pyright --project . --pythonpath .venv/bin/python src
See CONTRIBUTING.md for how
to contribute, and AGENTS.md for
the codebase rules.
License
Apache 2.0. See LICENSE and NOTICE.
Charter is built by R28.
Metadata
Release files for charter-ai 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| charter_ai-0.6.0.tar.gz | 1.3 MB | With provenance | Details for charter_ai-0.6.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| charter_ai-0.6.0-py3-none-any.whl | Python 3 | none | any | Details for charter_ai-0.6.0-py3-none-any.whl |
Total release size: 2.3 MB
Release files / charter_ai-0.6.0.tar.gz
| Download URL | charter_ai-0.6.0.tar.gz |
|---|---|
| Size | 1.3 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
31dfc5f04de0d9604c675978be42cc9f1fc0df00738d3f8c6ad5281e881919d1
|
|
BLAKE2b-256 checksum How to use checksums |
c827870b6406cb704f4601426aeefbe4df0ae4b35f20210c36b0eae5a5e9d66c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.
Transparency logRelease files / charter_ai-0.6.0-py3-none-any.whl
| Download URL | charter_ai-0.6.0-py3-none-any.whl |
|---|---|
| Size | 994.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
aeb00b72143fd02e034433ba96c3cc9c5399119d036c1c438a4fd57fef25ca4c
|
|
BLAKE2b-256 checksum How to use checksums |
22969da8ac4dd6c9564a59a89ee31747085912186a886046ca08a4ed09636ddc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.
Transparency logSecurity for charter-ai 0.6.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for charter_ai-0.6.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.