Agents
Learn why read tools answer in-loop while change tools only mint approval cards, and how the build refuses an execute tool on a conserved permission.
The admin's AI tool kit is split by what a tool can do.
Read tools run inside the model loop and answer directly. Tools that would change something never fire from the model — they mint a proposal and a server-signed approval token, and the change happens only when an operator approves it in the admin.
The split exists because permission checks live on the server-action boundary, and agent tools call the services and CRUD layer beneath it. A raw tool that calls CRUD directly bypasses every permission. The tool kit re-attaches the gate at the tool layer, so any agent inherits least privilege, mandatory confirmation and propose-only money safety by construction.
The three modes
Every tool declares a permission, a mode, a parameter schema, and a run.
The mode decides what happens when the model calls it:
- read —
runexecutes inside the model loop and returns data. No mutation. - propose —
rundoes not execute. The call returns a pending action plus a signed approval token, and writes anAgentRunproposal row. - execute —
rundoes not execute either. It behaves exactly like propose at call time, and fires only fromexecuteApprovedAgentActionafter an operator approves.
The difference between propose and execute is not who can trigger them from the model — neither can. It is what the underlying action is allowed to be. Execute covers content actions; propose is the only mode a conserved domain is allowed to use at all.
The operator's click is the authorization. There is no separate confirmation the model can satisfy on its own, and no token the model can mint for itself. There is no confirm flag either — a boolean the model controls could never be proof of human approval.
The model is told this in its own instructions: a change-making tool returns needs_approval, and it must never claim an action is done from that result.
callAgentTool is the single gate. The in-admin chat loop and the operator MCP endpoint both land there, so "who may, and what happens on a change" has exactly one implementation.
A tool whose permission the acting user lacks is still exposed to the model, but returns forbidden when called. That is deliberate — the assistant can then explain why it cannot act, instead of behaving as though the capability does not exist.
Conserved quantities are propose-only by construction
Money, refunds and stock cannot be given an execute tool.
This is not a policy a reviewer has to remember — declaring an execute tool on a permission in the forbidden set throws when the tool map is built, so the failure happens at startup rather than in production.
The forbidden set is:
processFinancials
dangerZone
refunds:edit
refunds:delete
collabs:editThe validation runs for every tool in the registry, including tools the operator has switched off. A closed tool with an illegal declaration is still a developer error, and hiding it until someone re-enables the tool would just delay the throw.
An admin:mcp key reaches this surface
The operator MCP endpoint exposes the same tool kit to an outside assistant. Its change tools still only mint approval cards, so the assistant cannot move money — but the key should be treated like an admin credential. See API Keys.
The approval token
The token is HMAC-signed with a server secret the model never sees, and bound to three things:
tool
paramsHash
userIdPlus an expiry. The TTL is five minutes — long enough to read the card and click, short enough to limit replay.
Binding to a hash of the exact parameters is what stops a token being redeemed against a different change. Approving a proposal for one amount does not yield a token that can be redeemed against another.
verifyApprovalToken compares signatures with timingSafeEqual and rejects with a named reason:
malformed
bad signature
unparseable
expired
tool mismatch
user mismatch
params changedMinting and recording happen in one call. The proposal row exists from the moment the model proposes, so an approval nobody acts on still leaves a trace rather than vanishing.
What approval actually runs
executeApprovedAgentAction re-checks everything before anything happens:
- The tool exists in the registry.
- The tool is still available — the operator may have disabled it or lowered the autonomy ceiling after the proposal was made, and a still-valid token must not reopen a closed hole.
- The token verifies against the tool, the params hash and the acting user.
- The operator still holds the tool's own permission.
- The parameters re-parse against the tool's schema.
- The token is claimed.
- Only then does
runexecute.
The claim in step 6 is a compare-and-set UPDATE that moves the proposal row from proposed to executed. Of N concurrent redemptions of the same token — a replay, a double-click, a race with dismiss — exactly one sees a row count of 1 and the rest are rejected.
Claiming before the side effect is what closes the check-then-act window. Once claimed, the token is spent even if run throws.
That ordering is why the claim is not best-effort. If the claim cannot be recorded, single-use cannot be guaranteed, so the action is refused rather than run unclaimed.
Dismissal is the same mechanism in the other direction: a compare-and-set from proposed to dismissed. Losing that race to an approval is an error the operator should see, not a silent no-op.
Autonomy ceiling
ai_max_autonomy defaults to read.
Raising it widens what the model may attempt without an operator, within the limits above — it cannot raise a propose-only tool into an executing one, because that boundary is enforced where the tools are built, not by the setting.
A second setting, ai_disabled_tools, switches individual capabilities off.
Both are operator settings, so holes open and close at runtime without a deploy. A tool that is disabled, or whose mode sits above the ceiling, is omitted from the tool map entirely — the model never sees a capability it cannot use.
A task-scope allowlist can narrow the set further for a particular thread, and can only narrow it. The permission gate and the operator's capability config still apply on top.
Proposal-minting server actions outside the chat loop check the same config before generating anything, so a disabled capability never burns tokens producing an approval card that is guaranteed to fail at redemption.
The ledger
AgentRun records proposals that were approved, dismissed and expired.
An expired proposal is evidence that the assistant suggested something nobody acted on, which is exactly what you want when auditing what an assistant has been doing.
Expiry is derived, not stamped. No cron sweeps the table: a proposal that outlived the token TTL can no longer be redeemed, so the row reads as expired the moment the TTL passes. The state and the enforcement share one constant, which means the ledger cannot disagree with what the system will actually accept.
The full lifecycle is:
proposed → executed → verified
proposed → dismissed
proposed → expiredThe verify loop
functions/agent.verify.ts handles verification as a background function.
It snapshots a baseline measure when the action executes, re-measures after seven days, and compares the two numerically. The verdict is improved, unchanged, or unmeasured.
The measures are per tool and deliberately modest. A proposed refund is measured by whether a refund request on that order was actually approved. A drafted block is measured by whether it ended up placed in a page layout, because a block is live exactly when a layout references it.
A tool with no measure defined does not get a flattering default. It records no measure defined and a verdict of unmeasured — never a silent pass.
Scheduling the loop is best-effort. A failure to schedule verification must never fail an action that has already been applied.
Where a new tool goes
Declare it read when it only returns data. That is the only mode that runs in the loop, and the only one that needs no operator.
Declare it propose when the effect is conserved — money, refunds, stock. On a permission in the forbidden set the build refuses an execute declaration outright, so that boundary does not depend on anyone remembering it.
Declare it execute for content changes that an operator should still see before they land. It still mints an approval card; the mode says what kind of change it is, not who may fire it.
Give it a measure in the verify loop if there is an honest way to tell afterwards whether it helped.
The question worth asking about any new tool is not "can the model call this safely?" It is:
If this fires when nobody meant it to, what is unrecoverable — and is that domain propose-only?
Related
AI
Learn which three AI providers are wired, how the failover chain works, and which AI features the repository does and does not ship.
MCP
Learn how to connect AI assistants to your store over MCP — eight public shopper tools at /api/mcp, and the admin's gated tool kit at /api/mcp/admin behind an admin:mcp key.
Permissions
Learn how Litestore resolves an admin role into effective permissions, how grantedPermissions and deniedPermissions override it, and which procedure wraps a server action versus a page.