Litestore · Developer guide
Platform services

Agents

This page helps you add tools to the admin's AI assistant so it can read data freely but only change things after an operator approves.

The admin's AI assistant uses a tool kit, and Litestore sorts each tool by what it is allowed to do. Read this page before you add a tool, so you pick the right mode for it.

Read tools run inside the model loop and answer straight away. Tools that would change something never run when the model calls them. Instead, they create a proposal and an approval token signed by the server. The change only happens when an operator approves it in the admin.

We built it this way because permission checks happen at the server-action boundary, while agent tools call the services and CRUD layer underneath it. A raw tool that called CRUD directly would skip every permission check. The tool kit puts the check back at the tool layer. Any agent therefore gets the least privilege it needs, has to ask for confirmation and can only propose money changes, without extra work.

The three modes

Every tool declares a permission, a mode, a parameter schema and a run function.

The mode decides what happens when the model calls the tool:

  • read: run executes inside the model loop and returns data. It changes nothing.
  • propose: run doesn't execute. The call returns a pending action with a signed approval token and writes an AgentRun proposal row.
  • execute: run doesn't execute either. At call time it behaves exactly like propose, and it only fires from executeApprovedAgentAction after an operator approves.

The model can't trigger propose or execute tools by itself, so that isn't what separates them. The difference is what kind of action the tool is allowed to perform. Execute is for content changes. Propose is the only mode a protected domain may use at all.

The operator's click is the authorization. The model can't satisfy a confirmation step on its own, and it can't create a token for itself. There is no confirm flag either, because a boolean the model controls could never prove that a person approved.

The model's own instructions tell it this. A tool that changes something returns needs_approval, and the model must never claim the action is done based on that result.

callAgentTool is the single gate. The chat loop in the admin and the operator MCP endpoint both call it, so exactly one piece of code decides who may use a tool and what happens when it changes something.

If the acting user lacks a tool's permission, the model still sees the tool, but calling it returns forbidden. We did this on purpose. The assistant can then explain why it can't act, instead of behaving as if the tool doesn't exist.

Money, refunds and stock can only be proposed

You can't give money, refunds or stock an execute tool.

Nobody has to remember this rule during review. If a tool declares execute on a permission in the forbidden set, Litestore throws an error while building the tool map. The failure shows up at startup instead of in production.

The forbidden set is:

processFinancials
dangerZone
refunds:edit
refunds:delete
collabs:edit

Litestore runs this check on every tool in the registry, including tools the operator has turned off. A disabled tool with an illegal declaration is still a developer mistake, and hiding it until someone turns the tool back on would only postpone the error.

An admin:mcp key reaches this surface

The operator MCP endpoint gives an outside assistant the same tool kit. Its change tools still only create approval cards, so the assistant can't move money. You should still treat the key like an admin credential. See API Keys.

The approval token

The server signs the token with HMAC using a secret the model never sees. The token is bound to three things:

tool
paramsHash
userId

It also has an expiry. The token lives for five minutes, which gives the operator time to read the card and click while limiting the window for replay.

Because the token is bound to a hash of the exact parameters, nobody can redeem it for a different change. If you approve a proposal for one amount, the token can't be used for another amount.

verifyApprovalToken compares signatures with timingSafeEqual. When it rejects a token, it gives one of these reasons:

malformed
bad signature
unparseable
expired
tool mismatch
user mismatch
params changed

Creating the token and recording the proposal happen in one call. The proposal row exists from the moment the model proposes, so a proposal that nobody acts on still leaves a record.

What approval actually runs

executeApprovedAgentAction checks everything again before anything happens:

  1. The tool exists in the registry.
  2. The tool is still available. The operator may have disabled it or lowered the autonomy ceiling after the proposal was made, and a token that is still valid must not reopen a closed gap.
  3. The token verifies against the tool, the params hash and the acting user.
  4. The operator still holds the tool's own permission.
  5. The parameters parse again against the tool's schema.
  6. The token is claimed.
  7. Only then does run execute.

The claim in step 6 is a compare-and-set UPDATE that moves the proposal row from proposed to executed. When several requests try to redeem the same token at once, whether through a replay, a double-click or a race with dismiss, exactly one of them sees a row count of 1. Litestore rejects the rest.

Claiming the token before the side effect closes the gap between checking and acting. Once the token is claimed, it is spent, even if run throws.

Because of this order, the claim can't be best-effort. If Litestore can't record the claim, it can't guarantee single use, so it refuses the action instead of running it unclaimed.

Dismissing a proposal uses the same mechanism in the other direction, with a compare-and-set from proposed to dismissed. If an approval wins that race, the operator sees an error instead of nothing happening.

Autonomy ceiling

ai_max_autonomy defaults to read.

Raising it lets the model attempt more without an operator, within the limits above. It can't turn a propose-only tool into one that executes, because Litestore enforces that boundary where it builds the tools, not through this setting.

A second setting, ai_disabled_tools, turns individual tools off.

Operators control both settings, so they can open and close capabilities at runtime without a deploy. If a tool is disabled, or its mode is above the ceiling, Litestore leaves it out of the tool map entirely. The model never sees a tool it can't use.

A thread can also have a task-scope allowlist. The allowlist can only remove tools, never add them, and the permission gate and the operator's tool settings still apply on top.

Server actions that create proposals outside the chat loop check the same settings before they generate anything. A disabled tool never spends tokens producing an approval card that would fail when redeemed.

The ledger

AgentRun records proposals that were approved, dismissed and expired.

An expired proposal shows that the assistant suggested something nobody acted on. That is exactly what you want to see when you audit what an assistant has been doing.

Litestore works out expiry instead of writing it down. No cron job sweeps the table. Once a proposal is older than the token's lifetime, nobody can redeem it, so the row reads as expired as soon as that time passes. The displayed state and the enforcement use one constant, so the ledger always matches what the system will accept.

A proposal moves through these states:

proposed  →  executed  →  verified
proposed  →  dismissed
proposed  →  expired

The verify loop

functions/agent.verify.ts runs verification as a background function.

When an action executes, it records a baseline measurement. Seven days later it measures again and compares the two numbers. The verdict is improved, unchanged or unmeasured.

Each tool has its own measure, and we kept them modest. For a proposed refund, the function checks whether a refund request on that order was actually approved. For a drafted block, it checks whether the block ended up in a page layout, because a block is live exactly when a layout references it.

If a tool has no measure, it doesn't get a flattering default. The function records no measure defined and the verdict unmeasured. It never passes silently.

Scheduling the loop is best-effort. If scheduling the verification fails, the action that already ran must not fail with it.

Where a new tool goes

Declare the tool as read when it only returns data. Read is the only mode that runs in the loop and the only one that needs no operator.

Declare it as propose when it touches money, refunds or stock. If its permission is in the forbidden set, the build refuses an execute declaration outright, so this boundary doesn't depend on anyone remembering it.

Declare it as execute for content changes that an operator should still see before they go live. It still creates an approval card. The mode describes the kind of change, not who may trigger it.

If there is an honest way to tell afterwards whether the tool helped, give it a measure in the verify loop.

When you design a new tool, don't ask whether the model can call it safely. Ask this instead:

If this runs when nobody meant it to, what can't be undone, and is that domain propose-only?

On this page