Subchapter 3.4
references/grouping-heuristic.mdMarkdown8 KBView on GitHub
How to decide WHAT to register when a plugin already has a REST (or internal service) surface. The hard part of adopting the Abilities API is not the registration syntax — it’s picking the right domain-layer granularity so abilities map to user-meaningful actions instead of HTTP plumbing.
Scope note. This reference governs domain-layer decisions: how many abilities to register, where to put filters vs. where to introduce a new ability name. It does NOT govern projection-layer choices (flat-with-full-schemas vs single-tool facade vs nested-discovery vs semantic grouping in the consumer view). Those are separate decisions made after the domain layer is settled — see
domain-vs-projection.mdfor the layering. A domain layer chosen well is reusable across multiple projections; conflating the two means re-registering every time a consumer’s constraints change.
| Approach | Shape | Example | Verdict (domain-layer) |
|---|---|---|---|
| Action-bundle | One ability bundles many sub-operations behind an action string. | my_plugin_account with action: "get" | "update" | "delete". | Avoid. Hides the ability surface from the agent’s tool-list and defeats the Abilities API’s introspection model — agents can’t see what a bundle can do until they invoke it. |
| REST-atomization | One ability per HTTP method per resource (typically 5 per resource: list, get, create, update, delete). | orders-list, orders-get, orders-create, orders-update, orders-delete. | Avoid as the registration shape. Couples ability names to HTTP plumbing rather than user intent — and forces re-registration if the projection layer ever needs to compress the surface. |
| Semantic-intent | One ability per real-world question or state transition. Filter parameters in input_schema collapse N variants into 1. | One feedback/get-responses ability with status, is_unread, search, and date-range filters in input_schema — replaces what would be 8+ atomized variants. | Recommended for the domain layer. |
is_unread filter. It does NOT map to 8 abilities (get-unread-responses, get-spam-responses, get-trashed-responses, …). Ability names are use-case contracts — see ./domain-vs-projection.md.input_schema is designed for rich inputs. Enum constraints, date-time formats, and required-field validation do the variant-splitting job that atomization would delegate to the ability name.Draft the question in plain English. That question is the ability. The filter parameters go in input_schema.
get-<resource> ability with status: { type: "string", enum: [...] }.A write ability should do exactly one thing the agent can reason about in isolation and explain to a user. Different state transitions → different abilities (different consequences, different annotations, different permission implications).
update-resource that branches internally on an action enum.submit-evidence and close-resource as separate abilities.Ask: “if the backing added a new filter value, would that create a new ability?” If not, the filter belongs in input_schema, not in the ability name.
When enumerating the backing surface, specifically look for zero-argument aggregate or “overview” endpoints — “what’s my balance?”, “what’s my next payout?”, “what’s my form response count?”. These answer the highest-frequency user questions with zero input and zero room for agent error. Flag them even if they weren’t in the original plan.
Every ability’s label + description should fit in an agent’s tool-selection prompt. If you can’t describe the ability in one sentence without “and”, that’s usually a sign it’s two abilities.
Consider a generic feedback or form-response plugin. Its admin screens expose: a list with 8+ filters, a detail view, bulk status changes (spam / trash / publish), read/unread toggles, and a count-by-status dashboard summary.
REST-atomization would ship ~6 abilities (list, get, delete, update, bulk-update, count). Semantic-intent registers three:
feedback/get-responses — list/search, with status, is_unread, search, before, after, parent in input_schema.feedback/update-response — one write that covers status changes AND read-state toggles on a single response (semantically “modify a response”).feedback/get-status-counts — the dashboard summary ability. Zero-arg-friendly (only optional filters).Why three works: a user asking “show me spam responses from last week” uses one ability. An agent updating one response to spam uses one ability. The dashboard-style “how many unread?” uses one ability. The entire product surface fits in three tool-list entries.
A hypothetical myplugin-tickets plugin manages a support-ticket queue. Its REST endpoint GET /myplugin/v1/tickets accepts status, priority, assigned_to, tag, date_before, date_after, search. Status values include new, triaged, in_progress, waiting_customer, waiting_internal, resolved, closed, reopened — eight in total.
get-tickets-new, get-tickets-resolved, get-tickets-closed, …).myplugin-tickets/get-tickets with status: { type: "string", enum: ["new", "triaged", "in_progress", "waiting_customer", "waiting_internal", "resolved", "closed", "reopened"] }.The user question “which tickets are waiting on the customer?” becomes one ability invocation with status: "waiting_customer". The agent doesn’t scan a list of 8 near-identical tool names; it scans one, and the enum documents what values are valid.
The same plugin also registers a zero-arg myplugin-tickets/get-queue-summary ability (open count + average response time + oldest unresolved) because “how is the queue today?” is the highest-frequency support-team question and the backing endpoint takes no arguments. That one ability has outsized value for one line of registration code — rule 4 in action.
Two cases where one-ability-per-operation IS appropriate on the read side, despite the recommendation against REST-atomization for reads:
list-<resource> and search-<resource> require different capabilities or different confirmation flows, splitting is honest.readonly: true; split the read-only part into its own ability.For writes, this escape hatch isn’t needed: rule 2 (“one state
transition per ability”) already establishes per-operation granularity
as the default. Splitting submit-evidence and close-resource into
separate abilities isn’t an exception — it’s rule 2 in action.
domain-vs-projection.md — granularity governs domain-layer decisions; that reference covers the projection layer where token-efficiency tradeoffs and consumer-shape choices live.shared-core-service.md — implementation mechanism for keeping abilities, REST handlers, CLI commands, and UI in lockstep on the domain layer.