Skip to main content
Two layers decide what an agent can do, and they are not equally strong.
  1. The tools you hand the model. An agent can call what its tools call and nothing else. A projection narrows a tool further, down to the fields it may set. This is where you decide.
  2. What the credential is granted. The provider checks it on every call, whatever the tool asks for. It is the backstop for a tool, or a prompt, that asks for more than you meant.
Each tool declares what it needs from a grant, so the second layer can be read off the first: hand out five tools, ask for exactly what those five need.

Ask for exactly what the tools need

permissions_scopes_for.py
Where a provider accepts several scopes for one call, the tool lists them narrowest first as an AnyOf, and scopes_for picks the fewest, narrowest set that covers every tool. Gmail’s readers accept gmail.readonly or gmail.modify, the relabeller needs gmail.modify, so an agent that does both asks for gmail.modify alone. Which scope covers which is never declared: it is read off the tools’ own lists, which are the provider’s. A tool that declares no scopes needs none, and scopes_for over it is []. A tool you write for your own API works the same whether or not you declare any.

Kinds of credential

Some providers issue more than one kind of credential, and the names a tool needs differ by kind. A GitHub OAuth app asks for repo on the consent screen; a GitHub App is configured with issues=write where it is created. Each pack declares its kinds as constants, every tool says what it needs under each, and the question names one:
permissions_kinds.py
A kind’s granted says where its names go. "consent" names travel in the authorization request, so scopes_for is the list to pass to authorize(). "configured" names are ticked where the app or key is made, so scopes_for is the checklist for that screen. A kind can also be named by its string, kind="app", and a misspelling is refused with the kinds that exist; importing the constant cannot be misspelt. A tool that cannot run under a kind says so, with Unavailable, and scopes_for refuses rather than build a grant that cannot run it:
Every tool in a pack answers for every kind the pack declares. That is one of the conformance checks, so a tool added next year cannot ship silent about one.

A grant you already hold

missing_scopes says what to add before handing out a new tool, so the user is asked once, for the difference:
permissions_missing.py

When the provider refuses anyway

A grant that does not cover a call raises InsufficientScope, whichever provider said it and however it said it. needed is what to add, narrowest first; the token is kept, because a refresh returns the same grant.
permissions_refused.py
Where the provider names the scopes it would accept, accepted holds them all and needed is the narrowest of them the tool lists. Where it names none, accepted is empty and needed is the tool’s own declaration. GitHub answers a private repository the token cannot see with the same 404 as one that does not exist. A classic token’s 404 counts as a refusal only when the token holds none of the scopes GitHub accepts for the call; otherwise it is GitHub’s own 404.

Your own API

Declare scopes on your tools the way the packs do, and they behave the same:
permissions_own_api.py
An API that answers with the standard WWW-Authenticate: Bearer error="insufficient_scope" challenge needs no ScopeRefusal: Charter reads that header on every response. For an API with more than one kind of credential, declare Kind constants and pass scopes={KIND: [...], ...}.

Narrowing a tool you hand out

tool.derived(scopes=[...]) makes a copy that asks for less, for an agent you know reaches less. A Drive agent that only ever opens files your app created can ask for drive.file; a GitHub agent that only works on public repositories can ask for public_repo:
permissions_narrow.py

What a declaration cannot say

  • Scopes that reach part of what a tool can name are not offered. drive.file, public_repo, gmail.metadata for a searcher, Shopify’s fulfillment orders at other kinds of location, the pull_requests permission on a GitHub issue endpoint: each would pass the consent screen and then fail on part of the input. Narrow with derived(scopes=...) when you know the part.
  • What depends on the arguments is not mandated. GitHub wants workflow for a write that touches .github/workflows. A Firecrawl key can be restricted to output formats, which is a property of each call. A Granola key with only personal access lists only personal notes, with no error for the rest.
  • Some access is approved, not consented to. Shopify approves read_all_orders (orders older than 60 days) and protected customer data per public app, in the Partner Dashboard; until it does, older orders are simply absent, and an unapproved customer field is null beside an error the pack raises. Google’s restricted scopes need a security assessment before a published app can request them. Neither appears on a consent screen. See Shopify and Google.
  • Some APIs have no permissions at all. Tavily documents none for a key: every endpoint takes it, and the optional project id only groups usage.
  • Microsoft’s application permissions are a different shape. An app-only token cannot call /me, which is every Outlook route, so the pack declares delegated permissions only.