A tool is a named, permission-gated capability an agent can invoke. Tools live in one shared registry with two consumers — the copilot’s run loop and the MCP endpoint — so a tool added for either is available to both.
The contract mentions no HTTP, no JSON-RPC and no MCP. It is transport-neutral on purpose.
Contributing
Implement the provider port and register it from your module:
@Injectable()
export class ReviewToolProvider implements ToolProvider {
tools(): ToolDefinition[] {
return [
{
name: 'review_list',
description: 'List reviews for a content entry.',
surfaces: ['copilot'],
effect: 'read',
permissions: [PERMISSIONS.REVIEWS_READ],
inputSchema: { … },
handler: async (args, ctx) => this.reviews.list(args, ctx)
}
];
}
}The registry is injected @Optional(), so a plugin contributing tools still
works in a deployment where neither AI surface is enabled.
Because the handler lives in your plugin, it can call your own services directly rather than going back out through HTTP — which is what stops a tool re-implementing the rules its own service already enforces.
Decide surfaces deliberately
Omitting surfaces means both
A tool that does not declare surfaces is offered to the copilot and to the
MCP endpoint. So an internal administrative tool written for the in-product
assistant becomes callable by any external agent holding a bearer token.
Adding a tool anywhere means deciding who it is for. Write the field.
| Surface | Caller |
|---|---|
copilot | A signed-in user, in the product, with a permission prompt in front of writes |
mcp | A credential an operator minted, with no interactive user |
The shipped admin_* tools are copilot-only for exactly this reason.
Effects
| Effect | Means |
|---|---|
read | Returns data. Never prompts. |
propose | Computes a change and hands it back. Prompts before running. |
apply | Performs a change. Prompts before running. |
The copilot’s content write tools are all propose. The tool is a pure
function of the model’s arguments plus the current entry — it computes a
change and does not write it. The run engine records the change and then applies
it through the owning plugin’s applier.
That split is what makes a prompt-injected “just save it” have nowhere to land: the tool cannot write, whatever it is told.
Writing a good write tool
The shipped ones do four things worth copying.
Read the live record at propose time, so the change carries a real before-and-after rather than a guess.
Drop fields that would not change. More than tidiness: writing an entry its own current values still appends a version and takes a live entry back to draft. A model re-sending twenty unchanged records would unpublish twenty live pages.
Reject unknown field names. Unlike a read’s projection, where a mis-remembered name is harmless, here the cost is a person approving a change they believe writes a field that does not exist.
Say what the change reaches. If a write propagates — a shared field on a localized type reaches every sibling — put that in the change summary. That summary is the card’s title, the audit row, and the receipt the model sees, so one string reaches the person, the log and the model.
Authorization
ToolRegistry.call is the single place a tool call is authorised, against live
grants. It is the analogue of @RequirePermissions on an HTTP route.
Do not check permissions in your handler and assume that is the boundary — and do not skip declaring them because your handler checks. Declare them; the registry enforces them; and the capability profile uses the same declaration to decide whether to offer the tool at all.
Batch, do not loop
If a request naturally applies to several records, ship one tool that takes a list rather than letting the model call a single-record tool repeatedly.
Eight calls is eight steps against a bounded run and eight receipts for one instruction, none of which says what the other seven were. One call is one change with one receipt.
Apply the items one at a time and stop at the first failure, reporting how many landed: a change is one row with one status, so carrying on grows the number of records written under a receipt that then reports failure.