Skip to main content
Mode is the pack author’s lever. It is declared on a field, and it is fixed when the pack is written. A projection is the other half, and it belongs to whoever deploys the tool. You did not write documents_batch_update and cannot edit it. You are the one who knows that this agent may insert text and must never delete a range.
projection.py
The result is the same tool. Same URL, same credentials, same validators, same extra="forbid". Only the view narrows.

Why the tool is shaped this way

The Google Docs API has three endpoints, and one of them carries every write. documents.batchUpdate takes a list of requests, each setting exactly one of 33 kinds of edit. Charter models all 33, which is what makes the tool complete and also what makes it cost 8,183 tokens of schema before the model reads the task. Narrowing to three members takes that to 1,750, or 79% smaller. The permission matters more than the tokens. Google’s documents scope grants every one of those 33 edits as a single indivisible grant. There is no scope for “may edit text, may not delete content”. The union member is the capability, so pruning it is the only place that permission can be expressed.

keep and drop

keep selects within the sibling group it names. Keeping three members of the union drops the other 30 and leaves document_id and body.write_control alone, so the tool is still callable. drop removes one path outright:
drop.py
Dropping a field the API requires raises DeclarationError when you build the projection, not when the API answers 400.

pin

pin removes a field and sends a fixed value on every request. The field is neither visible to the model nor reachable by it:
pin.py
That is a search the agent cannot widen. A pinned value is merged into the model the runtime executes, so it travels the path a supplied value travels and is indistinguishable from one on the wire. It goes where its Path(), Query() or Body() marker says, picks up the Format transform, the key casing cascade and the percent-encoding, and a single body field still unwraps to become the body. A value that does not satisfy its field raises DeclarationError when you build the projection. Pin top-level fields. A nested path raises.

Naming a path

Tool.paths() lists what a projection can select, one level at a time:
paths.py
A selector is a dotted path, an unambiguous field name, or the model class a field is annotated with. All three of these select the same field:
selectors.py
Every one of the 33 union members is unique by its own name, so the short form works there. index names 19 different fields, and naming it raises an error listing the candidates rather than guessing.

What a path costs

A list of names does not say which branch is expensive, and on a large tool that is the only thing you need to know. search_issues in the Linear pack has one top-level field. Every projection worth writing lives underneath it. Pass by_cost=True to price the level instead of naming it, most expensive first:
by_cost.py
One recursive filter is 99% of a 47,026 token tool. Dropping it leaves 396:
narrow.py
The figure is measured, not estimated. Each path is pruned for real and the schema regenerated, so what by_cost reports is what drop produces. Sizes are schema_tokens(), serialised JSON characters over four. Pricing costs one schema generation per path, so only the level being returned is priced. Pass a prefix to drill, or depth=2 to price a level and its children in one call. Four properties to read the numbers by. Each is something drop really does, so the number is the warning:
  • Costs do not sum to the total. Where two fields share a $def, dropping either one alone leaves it in place, so both price cheap and dropping both is worth more than the sum of the two.
  • A negative cost means the drop makes the tool larger. Pruning inside a model several siblings share splits one $def into per-path copies. Dropping body.requests.insert_text.location.index takes documents_batch_update from 8,183 tokens to 8,435, for a tool that can do less.
  • A cost of zero means Mode already removed the path, so a projection naming it does nothing. events_insert prices event.i_cal_uid at 0 under mode="write"; events_import prices the same path at 103.
  • A required path is priced even though drop refuses it. documents_batch_update cannot lose body.requests, and the price is what says whether pin is worth reaching for.
A projection prices what is left of it, not what the tool it came from had.

What is enforced

A projection can only remove, and removal is checked by the schema rather than requested in a prompt:
enforced.py
The narrowed schema keeps the validators of the schema it narrowed, so the Request oneof rule still rejects a request that sets two edits at once.

Composition

Deriving from a projection narrows what is left:
compose.py

Reading the boundary

Both halves print in egress_map(), which reads the same declarations the runtime executes:
egress.txt
A restriction a reviewer cannot read is not a control. This is the artifact that answers what an agent can do, and it diffs in CI.

Where projections live

In your code, not in the pack. Charter ships the mechanism and no named projections, because a projection is a policy decision and the policy is yours. gdocs.documents_edit_text in a pack namespace would be Charter deciding what text editing means for every deployment that installs it. Keeping them in your repo also puts them in your pull requests, next to the rest of what your agent is allowed to do.