{"slug": "show-hn-charter-declare-agent-tools-in-pydantic-instead-of-implementing-them", "title": "Show HN: Charter – Declare agent tools in Pydantic instead of implementing them", "summary": "A developer released Charter, an open-source Python library that lets developers declare AI agent tools as Pydantic models instead of writing integration code, shipping with 549 tools across fifteen APIs on two dependencies, pydantic>=2.9,<3 and httpx>=0.27,<1. Charter runs in-process with no proxy, per-call pricing or telemetry, and trims API responses by default — in the project's Stripe example, a 661-byte response was reduced to 111 bytes before reaching the model's context window. The library is installable via pip install charter-ai and supports API-key and OAuth credential providers through its charter.auth module.", "body_md": "You define the schema, the model fills in the args, and Charter does the plumbing: the request, the auth, the wire format. The same declaration decides what the model can see and send. No glue code.\n\nA library, not a service. Runs in your process. No proxy, no per-call pricing, no telemetry.\n\n```\npip install charter-ai\n```\n\n[549 tools across fifteen APIs](https://github.com/r28ai/charter#coverage) ship with\nit, on two dependencies: `pydantic>=2.9,<3` and `httpx>=0.27,<1`. Packs never add more.\nThe upper bounds are so a fresh install cannot silently resolve to pydantic 3.0 the day\nit ships.\n\nTip\n\nNeed an API that isn't here? Point your coding agent at the\n[pack-writing skill](https://github.com/r28ai/charter/blob/main/skills/writing-charter-packs/SKILL.md) and it writes the pack.\n\nEvery pack is already declared. Point one at a credential and invoke it. Stripe takes\nan [API key](https://docs.stripe.com/keys):\n\n``` python\nimport asyncio\nimport logging\n\nfrom charter.packs import stripe\n\nlogging.basicConfig(format=\"%(message)s\")\nlogging.getLogger(\"charter\").setLevel(logging.INFO)\n\nstripe.configure(\"sk_test_...\")\n\nasync def main():\n    await stripe.customers_create.ainvoke({\"email\": \"ada@example.com\", \"name\": \"Ada Lovelace\"})\n\nasyncio.run(main())\ncustomers_create       POST v1/customers               200    782ms  ↑     33 B  ↓     661 B →     111 B  (83%)\n```\n\n**`↓ 661 B → 111 B` is the part to look at.** Stripe answered with twenty-three fields and\nthe model read six. The rest never entered the context window. Every pack trims by\ndefault, and `tool.derived(name=..., response_handler=...)` changes that on any tool.\n`pass_through` hands back the whole response.\n[Response handling](https://docs.r28.ai/charter/reference/response-handling). The line\nitself is one INFO record per call, with no sink to configure.\n\nTurn the level up to DEBUG and the record carries the URL, the headers and the body as sent. Credentials are masked and base64 truncated before they reach the record, so a log you paste into a bug report cannot leak a key:\n\n```\nHTTP → POST https://api.stripe.com/v1/customers\n      headers: {\"stripe-version\": \"2026-08-26.dahlia\", \"authorization\": \"Bearer sk_***\"}\n      body: {\"email\": \"ada@example.com\"}\n```\n\n**The pinned API version, the injected credential and the assembled body appear nowhere\nin the code above.** [Seeing the wire](https://docs.r28.ai/charter/running/observability#seeing-the-wire)\nhas the ten-line formatter that renders it.\n\nAn OAuth pack is the same call with a credential provider instead of a key.\n`EnvTokenProvider` comes from `charter.auth`:\n`gmail.configure(EnvTokenProvider(\"GOOGLE_ACCESS_TOKEN\"))`, then\n`await gmail.messages_list.ainvoke({\"q\": \"is:unread\", \"maxResults\": 5})`.\n\n`Path`, `Query`, `Body`, `Format`. That is the core, and there is no abstraction\nunderneath it. You read the API's reference page and write down what it says, field by\nfield, in the dumbest possible way, on a pydantic model of your own:\n\n``` python\nimport asyncio\nfrom typing import Annotated\n\nfrom pydantic import BaseModel\n\nfrom charter import Path, Query, api_key_tool_factory\n\nclass ListLineItems(BaseModel):\n    session: Annotated[str, Path()]\n    limit: Annotated[int, Query()] = 10\n\nstripe = api_key_tool_factory(\n    pack=\"mystripe\",\n    base_url=\"https://api.stripe.com/\",\n    api_key_headers={\"Authorization\": \"Bearer sk_test_...\"},\n)\n\nlist_line_items = stripe(\n    name=\"list_line_items\",\n    description=\"List the line items on a checkout session.\",\n    method=\"GET\",\n    url_template=\"v1/checkout/sessions/{session}/line_items\",\n    args_schema=ListLineItems,\n)\n\nasync def main():\n    await list_line_items.ainvoke({\"session\": \"cs_test_123\", \"limit\": 5})\n\nasyncio.run(main())\n```\n\nOrdinary pydantic, ordinary types. `Path()` interpolates into the URL template and\n`Query()` becomes a query parameter. Stripe authenticates with an API key, so this is\none you can paste and run.\n\n`Format` is where a wire format the model should never construct is declared once:\n\n``` python\nfrom typing import Annotated\n\nfrom pydantic import BaseModel\n\nfrom charter import Body, EmailContent, Format\n\nclass SendEmail(BaseModel):\n    raw: Annotated[EmailContent, Body(envelop=True), Format(\"rfc822_base64\")]\n```\n\n**The model never touches a wire format.** Not MIME headers, not base64 padding, not a\nGraphQL document. Every format handled by hand is a library, a spec, and a thing that\ndrifts when the API moves. Here `Format(\"rfc822_base64\")` is the whole of it: it builds\nthe RFC 2822 document and encodes it base64url, and `Body(envelop=True)` puts it under\n`raw`. No email builder, no Google SDK, nothing added to the two dependencies.\n[What that replaces](https://docs.r28.ai/charter/why-charter#what-the-hand-written-tool-carries).\n\nThere is no drift between the declaration and the vendor either, because it mirrors\n[Gmail's own reference page](https://docs.r28.ai/charter/why-charter#the-declaration-mirrors-the-reference-page)\nline for line, which is why a reviewer can check one against the other and\n[a coding agent can write one](https://docs.r28.ai/charter/start/coding-agents).\n\nThere is no per-endpoint code in Charter, generated or hidden. So when a tool call\nfails it was the model's arguments, or it was the API. It was never the tool's logic,\nbecause there is no tool logic. With that variable held still, you can finally tell a\nbetter model from a worse one, and a better prompt from a worse one. Bad arguments do\nnot reach the API either — [see below](#when-the-model-gets-it-wrong).\n\nTwo campaigns, on 15 and 16 September 2026, against live accounts over real API calls,\nwith no mocks and no recorded fixtures. Two arms over the same tasks, models, prompts\nand credentials, differing only in the tool surface: Charter's packs against one generic\nHTTP tool per provider, where the model supplies the method, path, query and body\nitself. That raw arm is the glue people write first, and its endpoint list is derived\nfrom the pack's own tools so nobody hand-picked what it could reach.\n[Full methodology](https://docs.r28.ai/charter/guarantees/measured-results).\n\n| Across 534 measured runs | raw HTTP tool | Charter | \n|---|---|---|\n| malformed GraphQL documents | 32 | **0** | \n| calls to endpoints never declared | 10 | **0** | \n| base64 the API rejected | 4 | **0** | \n\nDeclare the server, pick a strategy, done. An env key, a token you already hold, a full\nauthorization-code client with refresh and rotation, or one credential per end user:\nsame seam either way, and **no OAuth library**. You never install `google-auth` or a\nvendor SDK. Single-flighted refresh, rotation and renewal timing are handled, and you\nwill not think about them again.\n\n`gmail.configure(EnvTokenProvider(...))` above — `charter.auth.EnvTokenProvider` —\nwas the simplest of those. The hardest is\nthe same one line: `SubjectProvider` resolves a different credential per end user, per\ncall, through an authorization-code client you configure once, with refresh and rotation\nhandled.\n[Getting the grant](https://docs.r28.ai/charter/auth/oauth-flow) has it end to end.\n\nYou do not have to know the server's details.\n[`discover()`](https://docs.r28.ai/charter/auth/authorization-servers) reads them from\nRFC 8414 metadata, which is why an enterprise IdP nobody has heard of needs no special\nsupport. You do not have to know which scopes to ask for either.\n[`scopes_for()`](https://docs.r28.ai/charter/auth/oauth-flow) computes them from the\ntools you hand out:\n\n``` python\nfrom charter.auth import scopes_for\nfrom charter.packs import gmail\n\nscopes_for([gmail.messages_list])    # ['https://www.googleapis.com/auth/gmail.modify']\nscopes_for([gmail.threads_delete])   # ['https://mail.google.com/']\n```\n\nNote\n\nYour consent screen stops saying \"read, send, delete and manage all your email\" unless you hand out the tool that needs it. That is a signup-rate number before it is a security one.\n\n**[`pin`](https://docs.r28.ai/charter/tools/projections) fixes a value the model can neither see nor set.** Not a prompt instruction,\nnot a check afterwards: absent from the schema it fills in, present in the one the\nruntime executes, indistinguishable on the wire from a value you passed by hand. A\ncustomer id, a region, a year, a sandbox flag. One line where otherwise it is plumbing:\n\n``` python\nfrom charter import format_egress_map\nfrom charter.packs import gdrive\n\nsearch_documents = gdrive.files_list.derived(\n    name=\"search_documents\",\n    pin={\"q\": \"mimeType='application/vnd.google-apps.document'\"},\n)\n\nprint(format_egress_map([search_documents]))\nsearch_documents  (GET drive/v3/files)\n  visible to the model (10):\n    + page_size\n    + page_token\n    ...\n  withheld (5):\n    - q  [pinned] = \"mimeType='application/vnd.google-apps.document'\"\n```\n\n**[`Mode`](https://docs.r28.ai/charter/boundary/mode-system) makes one tool behave several ways.** The string is arbitrary, so one\nschema covers whatever you need it to:\n\n- **Versioning:**`Mode(\"v1\")` ,`Mode(\"v2\")`\n- **A/B testing:**`Mode(\"A\")` ,`Mode(\"B\")` ,`Mode(\"A, B\")`\n- **Plan tiers:**`Mode(\"pro\")` ,`Mode(\"max\")`\n- **Regional rules:**`Mode(\"uk\")` ,`Mode(\"fr\")` ,`Mode(\"us\")` ,`Mode(\"eu\")`\n- **Read vs write:**`Mode(\"read\")` ,`Mode(\"write\")`\n\n`ToolSession(tools, mode=plan_of(user))` puts a label in force across every tool it\nholds, replacing a `TOOLSETS = {\"free\": [...], \"pro\": [...]}` dict and the code that\nchooses between its entries. The label sits beside the one each tool was declared with\nrather than replacing it, so a pack's own `create`/` update` split survives and you do\nnot have to read a pack to predict what keying it to a tier will do. `static_body`,\n`static_query` and `static_headers` do the same at the factory, for a constant every\ntool in a pack must send and the model must never see.\n\nImportant\n\nA pack mirrors its API rather than abstracting it, so a tool is exactly as large as the endpoint behind it. That is the trade that keeps a declaration from drifting, and it is why some tools arrive enormous.\n\nLinear's `IssueFilter` carries every condition the API accepts, which renders as 187KB of\nschema. To address that, a [projection](https://docs.r28.ai/charter/tools/projections) does two things with one edit:\n\n- **Scope.** It narrows what the tool can do.\n- **Context.** It narrows how much of the context window the tool occupies. A schema is in\nthe prompt on every turn, before the model has read the task, so it is part of what the\nmodel decides with.\n\nWe hit this on Linear, running the benchmark. The fix was to cut the filter down to the conditions a triage agent actually uses. Thirty generations per cell, temperature 0, one task:\n\n| the filter the model was given | bytes | glm-5p3-flash | deepseek-v4p1 | nemotron-lightning | \n|---|---|---|---|---|\n| removed entirely | 1,131 | 0/30 | 0/30 | 0/30 | \n| the full mirror | 187,655 | 22/30 | **400 ×30** | **400 ×30** | \n| curated | 15,531 | 28/30 | **30/30** | 4/30 | \n\nDeleting the filter is the first row: a list tool that cannot narrow a list, so all three\nmodels page the whole team 250 issues at a time. A prompt does not reach this either. The\nsame model used the filter 21/21 times when the schema was accidentally flat and 0/40\nafter — the capability was never missing, the shape was. The `400` s are the extreme case.\nThe byte counts in that table are what the harness\nactually sent in September; the token counts in the code below are what the current\npackage produces, which is why they do not divide into each other exactly.\n\nTip\n\n**The recipe, for a tool that is bigger than the job you have for it.**\n\n1. `schema_tokens(tool)` — decide whether it is worth touching at all.\n2. `tool.paths()` , then`paths(under=..., by_cost=True)` — find the branch that is the cost.\n3. `tool.derived(name=..., keep={...})` — cut it, and name the result.\n4. `schema_tokens` and`paths()` again — confirm you got what you meant, and[`format_egress_map`](https://docs.r28.ai/charter/reference/observability) to see what the model can still reach.\n\nSteps 1 and 2. `by_cost=True` prices a level instead of naming it, each path really pruned\nand the schema regenerated, so the number is what cutting it will do:\n\n``` python\nfrom charter import schema_tokens\nfrom charter.packs import linear\n\nschema_tokens(linear.issues_list_full)            # 45072\nlinear.issues_list_full.paths()                   # ['variables']\nlinear.issues_list_full.paths(under=\"variables\")\n# ['first', 'after', 'filter', 'order_by', 'include_archived']\n\nlinear.issues_list_full.paths(under=\"variables\", by_cost=True)\n# [PathCost(path='filter',   tokens=44828),\n#  PathCost(path='order_by', tokens=52),\n#  PathCost(path='first',    tokens=45), ...]\n```\n\nOne field of five is 44,828 of the 45,072, and nothing about its name said so.\n\nStep 3 is the only one with judgement in it, and the question is about the job rather than the schema: which conditions does this agent narrow a list by? For triage, the issue's own fields plus one level into the four relations that identify work. That is the curated row above:\n\n``` python\nfrom charter import schema_tokens\nfrom charter.packs import linear\n\nF = \"variables.filter.\"\n\nissues_list_triage = linear.issues_list_full.derived(\n    name=\"issues_list_triage\",\n    keep={\n        F + \"id\", F + \"number\", F + \"title\", F + \"priority\",\n        F + \"due_date\", F + \"created_at\", F + \"updated_at\", F + \"completed_at\",\n        F + \"state.type\", F + \"state.name\",\n        F + \"assignee.email\", F + \"assignee.name\",\n        F + \"team.key\", F + \"team.name\",\n        F + \"labels.name\",\n    },\n)\n\nschema_tokens(issues_list_triage)    # 3458\n```\n\nWrite the full dotted path: `keep={\"labels\"}` matches more than 200 paths on this schema\nand raises rather than guessing. And name `team.key`, not `team` — keeping a relation\nkeeps its whole subtree, which drags the cycle back in and lands you at 46,313 tokens,\nlarger than what you started with.\n\nNote\n\n**For Linear you do not have to run this.** The pack ships narrowed: `linear.issues_list`\nis curated at 7,652 tokens, and sixteen more with it. Each keeps an undiminished `*_full`\ntwin, held out of `TOOLS`, for a caller who needs the complete filter.\n\n**The same edit is a permission.** A Google `documents` scope does all 33 kinds of edit\nas one indivisible grant, and `keep` says \"may edit text, may not delete content\" about\nit:\n\n``` python\nfrom charter import schema_tokens\nfrom charter.packs import gdocs\n\nedit_text = gdocs.documents_batch_update.derived(\n    name=\"documents_edit_text\",\n    keep={\"insert_text\", \"delete_content_range\", \"replace_all_text\"},\n)\n\nedit_text.paths(under=\"body.requests\")\n# ['replace_all_text', 'insert_text', 'delete_content_range']  — 33 down to 3\nschema_tokens(edit_text)    # 1553, from 7336\n```\n\nA projection can only ever remove, which is what makes the saving and the restriction one line of code, and what makes it safe to hand to whoever owns the deployment rather than the pack.\n\n**At the extreme, a schema is not expensive, it is refused.** `IssueFilter` refers back to\nitself, and on a schema that does:\n\nWarning\n\n`400 JSON Schema not supported: schema depth exceeds maximum limit of 50` — from the\nprovider, before the model saw anything, thirty times out of thirty. It takes every other\ntool in the request with it, so the turn makes no tool call. Recursion is what breaks it,\nnot size: the same models accept a larger Sheets tool that has no cycle.\n\n[The long version](https://docs.r28.ai/charter/optimization/context-window) covers the `$defs` arithmetic, what flattening clients do to\na cycle, and why deferral is the other half of this lever rather than a substitute.\n\nA schema says what goes out. A [response handler](https://docs.r28.ai/charter/optimization/context-window) says how much of what comes back the\nmodel ever sees, and one Gmail call can otherwise end a conversation on its own: a\nbase64 body, a dozen response-only fields, a block tree, avatar URLs eight to a user.\n\nAcross the same 534 runs the Charter arm handed the model roughly a quarter of the\nbytes the raw arm did, 7,382 against 36,383 per run in one campaign and 10,581\nagainst 39,785 in the other. In the second it had pulled *more* off the wire, not less.\nIt forwarded less, by the packs' own handlers, with nothing configured.\n\nA property enforced by construction is only auditable if something prints it. Two do, both generated from the declarations the runtime executes, so neither can drift:\n\n- **[`egress_map()`](https://docs.r28.ai/charter/boundary/egress-control)** answers what a security review actually asks. Snapshot it in CI\nand a change to what the model can see becomes a reviewable diff on a pull request.\n- **[`format_conflicts()`](https://docs.r28.ai/charter/reference/observability)** prints the rules an API keeps in prose. Google Calendar's`syncToken` refuses eight other parameters; that is a property of the schema here,\nchecked on every call.\n\nSlack answers a rejected request with `200 OK` and `{\"ok\": false}`. Every GraphQL API\nreturns 200 with an `errors` array. Linear returns `success: false`, Shopify returns\n`userErrors`. Every signal a runtime normally trusts says the write happened, and your\nagent tells the user the message sent.\n\n``` python\nfrom charter import Envelope\n\nEnvelope(errors_field=(\"errors\", \"data.*.userErrors\"))\n```\n\nOne line on the factory, enforced on every call including calls by tools added next\nyear. [The full measured record](https://docs.r28.ai/charter/guarantees/measured-results)\ncovers both campaigns, including where task success was a wash and the one template\nthat goes the other way.\n\nErrors go to whoever can act on them. That is what keeps a bad argument worth one turn.\nA camelCase key inside a nested object, a nested object serialised as a JSON string: the\nruntime [absorbs those](https://docs.r28.ai/charter/running/llm-input-auto-corrections),\nand nobody is told. A declaration the runtime cannot use comes to you, with a link. What\nis left is the model's to fix, and it fails before the request goes out, quoting what it\nsent:\n\n```\nValidation error:\n- **maxResults**: Input should be a valid integer, unable to parse string as an integer (got 'ten')\n- **timeMin**: Input should be a valid datetime or date, invalid character in year (got 'next tuesday')\n```\n\n**\"Invalid parameter\" tells a model what to stop doing, not what to do instead.** So the\nrule `format_conflicts()` prints for a reviewer above is the same one the model reads on\nthe turn it breaks it:\n\n```\nValidation error:\n- **(input)**: Value error, q, timeMax, timeMin cannot be combined with syncToken.\n  An incremental sync continues the query the token came from, so the filters have\n  to be the ones already in effect. Drop syncToken to run a fresh query, or drop\n  the others to continue the sync.\n```\n\nThree offenders in one message instead of three round trips, in the vendor's own\nspelling, with both exits named. All of that comes out of one\n`ConflictsWith(..., reason=...)` on the field. No documentation link either: a model pays\nfor the URL in context and cannot follow it.\n[Errors](https://docs.r28.ai/charter/running/tool-validation-error-handling).\n\n`Gloss` tells the model what the API's own description leaves out, without replacing\nit, which is where a Stripe field that needs a hint gets one. `ConflictsWith` declares\nwhich parameters an endpoint refuses together. Then\n[`Case`, `KeyCase`, `WireName`, `TransportOverride`, `partial_of` and `Pagination`](https://docs.r28.ai/charter/reference/markers).\n\n| Pack | Import | Tools | Auth | The awkward part | \n|---|---|---|---|---|\n| Gmail | `charter.packs.gmail` | 23 | OAuth bearer | Mail goes out as base64url RFC 2822 and comes back parsed | \n| Google Calendar | `charter.packs.gcalendar` | 13 | OAuth bearer | camelCase in the query, snake_case in the body | \n| Google Sheets | `charter.packs.gsheets` | 17 | OAuth bearer | Cells are protobuf JSON, not plain values | \n| Google Docs | `charter.packs.gdocs` | 3 | OAuth bearer | One batch request, thirty-three alternative edit types | \n| Google Drive | `charter.packs.gdrive` | 25 | OAuth bearer | PATCH takes a subset of the create body | \n| Google Forms | `charter.packs.gforms` | 6 | OAuth bearer | One resource, different fields on create and update | \n| Slack | `charter.packs.slack` | 18 | OAuth bearer | Rejected writes answer HTTP 200 | \n| GitHub | `charter.packs.github` | 139 | OAuth bearer | Three constant headers, one of them a pinned API version | \n| Stripe | `charter.packs.stripe` | 59 | API key | Form-encoded, bracketed query, DELETE with a body | \n| Linear | `charter.packs.linear` | 128 | API key | GraphQL, with the cursor nested inside the response | \n| Shopify | `charter.packs.shopify` | 22 | API key | No fixed host, and every price is a nested `MoneyBag` | \n| Notion | `charter.packs.notion` | 35 | OAuth bearer | 100 blocks and two levels of children per write | \n| Firecrawl | `charter.packs.firecrawl` | 43 | API key | camelCase wire, and some failures answer HTTP 200 | \n| Granola | `charter.packs.granola` | 9 | API key | Four kinds of actor in one discriminated union | \n| Tavily | `charter.packs.tavily` | 9 | API key | Research is asynchronous: create, then poll | \n\nEvery [pack](https://docs.r28.ai/charter/packs/overview) has its LLM schema built, its\negress map checked against that schema, and its OpenAI function definition validated in\nthe suite.\n\nThree shapes sit near this one, and none of them is it:\n\n- an **orchestrator** (LangChain, LangGraph, Google ADK) sits on top of the tool\nexecution layer. It doesn't deal with the underlying request, and isn't designed to;\n- a **catalogue** (Zapier, Composio, Arcade) runs the call for you, remotely, priced\nper call, and sells on catalogue size. Here you write the pack;\n- a **protocol** (MCP) governs what the model sees and structurally cannot reach the\nAPI side, because it never talks to the upstream API.\n\nCharter compiles to MCP and adapts to each of the others. None of them is a library you run yourself that decides what goes out.\n\nA declaration can be wrong in a way no per-tool test notices: the call returns 200, the\nsuite stays green, and the filter you declared was silently discarded on the way out. So\nnineteen properties that must hold for *every* pack are checked separately, without\nknowing anything about any particular API, and each one also runs against a pack broken\non purpose in the specific way the bug it guards against broke it. Most were written\nafter a bug rather than before one: four packs added in a single week produced six, five\nof them silent, including a factory-level `Pagination` that labelled twenty-two retrieve\nand write endpoints with a cursor parameter they do not accept. [Conformance](https://docs.r28.ai/charter/guarantees/conformance) has the\nlist.\n\nWhat none of it tells you is whether a schema still matches the vendor's live API. Catching that drift needs their published spec.\n\nCharter describes *one request*, and [declarative has edges](https://docs.r28.ai/charter/guarantees/limitations).\nPagination loops, multi-call compositions and retry policies are out of scope by design,\nbecause that is orchestration and it belongs in your agent. Multipart upload, request\nsigning, header-based pagination markers, dynamic GraphQL selection sets and streaming\nare not supported yet. A schema cannot change mid conversation, because it was\nserialised into a prompt the model is still reading; a surface that has to change means\na new session. And `Mode` is schema visibility, not authorization: it decides what a\ntool exposes, never who may call it.\n\n| **Start** | [Quickstart](https://docs.r28.ai/charter/start/quickstart) ·[Installation](https://docs.r28.ai/charter/start/installation) ·[Why Charter](https://docs.r28.ai/charter/why-charter) | \n| **Packs** | [Overview](https://docs.r28.ai/charter/packs/overview) ·[Write one with a coding agent](https://docs.r28.ai/charter/start/coding-agents) | \n| **The boundary** | [Egress control](https://docs.r28.ai/charter/boundary/egress-control) ·[Mode system](https://docs.r28.ai/charter/boundary/mode-system) ·[Quick reference](https://docs.r28.ai/charter/boundary/mode-quick-reference) | \n| **The wire** | [Wire contract](https://docs.r28.ai/charter/tools/wire-contract) ·[Envelopes](https://docs.r28.ai/charter/tools/envelopes) ·[Transforms](https://docs.r28.ai/charter/tools/transforms) ·[Key case](https://docs.r28.ai/charter/tools/key-case-cascade) | \n| **Credentials** | [Getting the grant](https://docs.r28.ai/charter/auth/oauth-flow) ·[Authorization servers](https://docs.r28.ai/charter/auth/authorization-servers) ·[API keys](https://docs.r28.ai/charter/auth/api-key-tool-factory) | \n| **Running it** | [What a call cost](https://docs.r28.ai/charter/running/observability) ·[Adapters and MCP](https://docs.r28.ai/charter/using/adapters) ·[Errors](https://docs.r28.ai/charter/running/tool-validation-error-handling) | \n| **Guarantees** | [Conformance](https://docs.r28.ai/charter/guarantees/conformance) ·[Measured results](https://docs.r28.ai/charter/guarantees/measured-results) ·[Limitations](https://docs.r28.ai/charter/guarantees/limitations) | \n| **Reference** | [API reference](https://docs.r28.ai/charter/reference/overview) ·[AGENTS.md](https://github.com/r28ai/charter/blob/main/AGENTS.md) | \n\n```\nuv venv\nuv pip install -e \".[dev,langchain,mcp]\"\nuv run pytest\nuv run ruff check src tests examples\nuv run pyright --pythonpath .venv/bin/python src\n```\n\nApache 2.0. See [LICENSE](https://github.com/r28ai/charter/blob/main/LICENSE) and [NOTICE](https://github.com/r28ai/charter/blob/main/NOTICE).\n\nCharter is built by [R28](https://r28.ai).", "url": "https://wpnews.pro/news/show-hn-charter-declare-agent-tools-in-pydantic-instead-of-implementing-them", "canonical_source": "https://github.com/r28ai/charter", "published_at": "2026-09-28 13:38:51+00:00", "updated_at": "2026-09-28 13:47:42.669322+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "agent-protocols", "ai-products"], "entities": ["Charter", "Pydantic", "Stripe", "httpx", "r28ai", "Gmail", "EnvTokenProvider", "charter-ai"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/show-hn-charter-declare-agent-tools-in-pydantic-instead-of-implementing-them", "markdown": "https://wpnews.pro/news/show-hn-charter-declare-agent-tools-in-pydantic-instead-of-implementing-them.md", "text": "https://wpnews.pro/news/show-hn-charter-declare-agent-tools-in-pydantic-instead-of-implementing-them.txt", "jsonld": "https://wpnews.pro/news/show-hn-charter-declare-agent-tools-in-pydantic-instead-of-implementing-them.jsonld"}}