Capabilities
The static grant that says what an agent exists to do, and why it is separate from the tool registry.
A capability is a named thing an agent is allowed to do: refund.create,
order.read, database.query, email.send. It is the coarsest layer of
authority and the one you set when you register the agent.
Capabilities answer "what is this agent for?". Policy answers "under what conditions, right now?". Keeping those separate is what lets you tighten conditions without re-provisioning agents.
#Naming
resource.action, lower case, dotted. The convention is not enforced by a
validator but it is worth keeping, because capability names end up in policy
rules, in delegation constraints, in passports and in audit queries, and a
consistent namespace is the difference between a readable policy and a guessing
game.
| Good | Why |
|---|---|
refund.create |
Clear resource, clear action |
order.read |
Reads and writes are separable |
database.query |
Narrower than database.* |
email.send |
Obvious blast radius |
| Avoid | Why |
|---|---|
admin |
Grants everything and explains nothing |
refunds |
Is that read, create, or cancel? |
doStuff |
A rule that mentions this is unreviewable |
#Granting
At registration:
bashcurl -X POST https://veefore.example.com/v1/agents \
-H "Authorization: Bearer $VEEFORE_API_KEY" \
-H 'content-type: application/json' \
-H 'Idempotency-Key: register-finance-agent' \
-d '{
"name": "finance-agent",
"workspace_id": "wsp_01JAV…",
"environment_id": "env_01JAV…",
"capabilities": ["refund.create", "order.read"],
"public_key": "MCowBQYDK2VwAyEA…"
}'
or with the CLI:
bashveefore init --name finance-agent \
--capability refund.create \
--capability order.read
Changing them later needs agent:manage_capabilities, and the change is an
audit event. Capability grants are not something that drifts quietly.
#Capability and tool are different things
This is the part worth slowing down on, because it is the most common source of confusion.
A capability is an abstract permission held by the agent. A tool is a concrete, registered, callable thing with an endpoint, a destination allowlist and a risk rating.
A tool declares which capability it requires. An agent can call a tool only when both are true:
- The agent holds the capability the tool requires, and
- The agent is explicitly authorized for that specific tool.
bash# Register the tool — grants nothing to anyone
POST /v1/tools { "key": "refund_customer", "required_capability": "refund.create", … }
# Authorize this agent for this tool — the second, separate step
POST /v1/tools/{toolId}/agents { "agent_id": "agt_01JAV…" }
Why two steps instead of one
If holding refund.create automatically granted access to every tool that
declares it, then registering a new refund tool would silently widen what every
existing finance agent could reach. A tool registration would become a privilege
escalation.
With the steps separated, adding a tool grants nobody anything until a human authorizes an agent for it. The same reasoning drives MCP tool discovery, where a newly discovered tool is unauthorized until approved — see MCP.
The two refusals are distinct on purpose, so you can tell which half is missing:
CAPABILITY_NOT_GRANTED— the agent does not hold the capability.TOOL_NOT_AUTHORIZED— it holds the capability but is not authorized for this tool.
#What the agent sees
At handshake the gateway tells the agent exactly what it may call:
tsconst info = await agent.handshake();
console.log(info.capabilities);
// ['refund.create', 'order.read']
console.log(info.authorizedTools);
// [{ key: 'refund_customer', name: 'Refund a customer order',
// capability: 'refund.create', status: 'ENABLED' }]
Declaring your intended tools up front turns a mid-task failure into a startup warning:
tsconst agent = protect(myAgent, {
declaredTools: ['refund_customer', 'order_lookup'],
eagerHandshake: true,
});
Anything declared but not authorized is reported at handshake. The handshake is advisory, not a cache — every action is still decided independently.
#Capabilities in policy
json{
"id": "rule-refund-requires-capability",
"name": "Refunds need the refund capability",
"priority": 800,
"enabled": true,
"when": {
"kind": "group",
"id": "g-refund-capability",
"operator": "and",
"children": [
{
"kind": "condition",
"id": "c-capability-is-refund",
"field": "capability",
"operator": "eq",
"value": "refund.create"
},
{
"kind": "condition",
"id": "c-agent-lacks-refund",
"field": "agent.capabilities",
"operator": "not_contains",
"value": "refund.create"
}
]
},
"then": { "decision": "BLOCK", "reason": "Agent does not hold refund.create." }
}
In practice you rarely need that rule — stage 5 of the pipeline already enforces it. It is useful when you want a different reason recorded, or when you want to block a capability an agent technically holds under specific conditions.
The genuinely useful pattern is testing the capability of the current request:
json{ "field": "capability", "operator": "in", "value": ["refund.create", "payout.initiate"] }