LexQLexQ
Back to blog

AI Agent Action Approval: Limits a System Prompt Can't Enforce

An AI agent keeps to the refund limit in its system prompt only until a customer talks it into a different number. Before each tool call runs, check it with rules that return ALLOW, NEEDS_APPROVAL, or DENY, and use the prompt only for what the agent says.

Sanghyun Park·October 24, 202615 min read

An AI agent keeps to the refund limit or credit amount in its system prompt only until a customer talks it into a different number. Limits on tools that move money or data belong in rules that check each tool call outside the model, just before the tool runs. Those rules answer ALLOW, NEEDS_APPROVAL, or DENY for every call, and the prompt keeps the job only the model can do: deciding what the agent says.

A 50-person SaaS company runs an AI support agent for its project-management app. The agent can run four tools on its own: refund a charge, apply an account credit, send a message, and update a ticket. Its system prompt says refunds over $100 wait for a teammate's approval and goodwill credits stop at $50. A customer who was charged twice asks for $180 back, and the model emits a tool call for a $180 refund. The prompt says a teammate approves that refund first, but only the model enforces that instruction. If the call runs, no teammate ever sees the refund.

In another chat, a customer pastes in a note claiming the head of support already approved a $1,500 credit. No such approval exists, but the model believes the note and emits a tool call for the credit.

How action approval differs from agent guidelines

An agent guideline is an instruction you write for the agent outside its system prompt. Intercom calls these instructions Guidance in Fin, its customer service agent. Parlant, an open-source framework for customer-facing agents, calls them guidelines. Intercom's help center says that Fin applies Guidance while it generates an answer, and that Guidance is best for small changes to how Fin communicates. Parlant's docs say a model decides by default which guidelines apply to the conversation (Intercom and Parlant docs).

Like a system prompt, a guideline is an instruction the model reads together with the customer's messages. So a customer can talk the model out of a guideline by writing instructions into the conversation. That attack is called prompt injection, and the pasted note in the second chat is one.

Action approval stops it by checking each tool call before the tool runs:

QuestionAgent guideline or system promptAction approval
Who decides?The modelRules outside the model
When does it decide?While the model writes its replyAfter the model emits a tool call, before the tool runs
What does it look at?The whole conversation, the customer's text includedThe action type and amount in the tool call, plus values your systems fill in
What does it decide?What the agent says, and which tool it calls with what amountWhether that call runs: ALLOW, NEEDS_APPROVAL (held until a person approves), or DENY
A $1,500 credit with a pasted "approval"?Goes out if the model believes the noteStopped by a rule on the amount before the tool runs

OWASP, a nonprofit known for its Top 10 lists of security risks, ranks prompt injection first (LLM01:2025) in its 2025 Top 10 for LLM Applications. The entry's suggested defenses include least-privilege access (the model gets only the access its job needs) and human approval for privileged operations (OWASP). Action approval does both in one step, just before the tool runs.

A risk score can flag calls for review, but not a score the agent gives itself: an agent talked into a $1,500 credit believes it is right. A separate checker model can read the conversation and grade the proposed action from 0 to 1. Because it reads the same conversation, it can be fooled too. In the $1,500 chat it scores the credit 0.95. So use its score only to send low-scoring calls to a person. Leave the blocking to the amount limits and the allowlist (the actions the agent may call), which don't depend on any model.

Where each rule belongs

The $100 refund limit shouldn't live in both the prompt and the rules. The day someone changes only one copy, the agent tells customers one limit while the rules enforce another.

#Ask about the ruleIf yes, put it inIn this support agent
1Does breaking it once move money or send data out of the company?Rules checked before the tool runsRefunds over $100, personal data sent to a third party
2Will you move the number as you trust the agent more?Rules outside the agent's code, changed without a redeployRaising the approval threshold from $100 to $150
3Will someone ask later why the agent did something?Rules, which record a decision for every call"Why did the $80 credit go out as $50?"
4Is it about what the agent says rather than what a tool does?The system prompt or guidelinesApologize once, explain a held refund

A yes to any of questions 1 to 3 sends a rule outside the model. If only question 1 gets a yes, an if inside the tool's API will do, since it runs outside the model too. A yes to question 2 or 3 needs rules kept outside your code. Only question 4's rules, the ones about wording, go in the prompt or the guidelines.

A rule outside the model reads the action type and the amount from the tool call itself, because that is what will run. It ignores what the agent writes around the call, such as its claim that the head of support approved the credit, because no rule can check a note pasted into a chat.

What stays in the prompt

Here is the agent's prompt with the $100 approval threshold and the $50 credit cap taken out:

You handle support for a project-management app.
Apologize once, then fix the problem.
Some refunds and credits are held for review or capped by a check that runs
before the tool. When a tool call comes back held, tell the customer
a teammate will review it and reply by email. When it comes back
denied, say a teammate will take over. Never promise an amount before
the tool result comes back.

The limits live only in the rules, and the agent learns from the check's result that a call was held or denied.

On a platform with a guideline feature, every line of this prompt can become a guideline. The $100 and the $50 stay out of the guidelines too, for the same reason they left the prompt.

The check before the tool runs

LexQ, which publishes this blog, is a Decision Operations Platform: an application sends it a decision's inputs, and LexQ runs your rules on them and records every decision. Its AI Agent Action Approval template is a domain template: a set of input definitions and sample rules you apply from the console or the API. Applying it creates a policy group, the set of rules your application calls by ID. The group is versioned and deployed as a whole. The rules arrive as a draft you can try with a dry run. They decide the agent's calls only after you publish the version and deploy it. Publishing locks the version's rules. LexQ's other domain templates, built for industries such as e-commerce and fintech, check AI-produced values the same way. The e-commerce checkout pricing template caps an AI shopping assistant's discount, and the fintech template declines a large transfer an AI agent starts.

The template's rules read six inputs, which LexQ calls facts. Your agent's pre-tool hook, a function the agent framework calls just before each tool runs, fills them in:

  • agentActionType: the action type your hook maps from the tool the agent is about to call, for example, from issue_refund to ISSUE_REFUND
  • actionAmountUsd: the amount in the tool call's arguments, or 0 when no money moves
  • dailyAgentAmountUsd: what the agent moved for this customer in the last 24 hours, this call included, from your records
  • checkerConfidence: the score from the checker model you run, from 0 to 1
  • containsPii: whether a personal-data detector you run found personal data in the payload or message; send true if the detector is missing or fails
  • isThirdPartyRecipient: whether the recipient is someone other than your company or the person the data is about

The ten rules sit in one LexQ mutex group, so each call gets exactly one decision. When several match, the one with the highest priority decides. Here they are, highest priority first:

RuleConditionDecision
1Action type not on the allowlistDENY
2Personal data going to a third partyDENY
3A single action over $1,000DENY
4Any action with an amount, when the customer's 24-hour total is over $500NEEDS_APPROVAL
5A refund or purchase over $100NEEDS_APPROVAL
6Checker score under 0.7NEEDS_APPROVAL
7Credit cap: a credit over $50, with every input presentALLOW, with the amount cut to $50
8Default allow: an allowlisted action, with every input presentALLOW
9Default hold: an allowlisted actionNEEDS_APPROVAL
10Default deny: any callDENY

The default hold and the default deny match almost any call, but as the two lowest-priority rules, they decide only the calls that no rule above them matched. Rule 5 mentions purchases because the template is written for any agent, not only this one. Splitting a refund into $90 calls gets past rule 5, but each call still counts toward rule 4's 24-hour total.

Here is the $180 refund as a request to the group's execution URL:

curl -X POST https://api.lexq.io/api/v1/execution/groups/{groupId} \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "facts": {
      "agentActionType": "ISSUE_REFUND",
      "actionAmountUsd": 180,
      "dailyAgentAmountUsd": 180,
      "checkerConfidence": 0.88,
      "containsPii": false,
      "isThirdPartyRecipient": false
    }
  }'

The response, with the inputs and per-rule details collapsed:

{
  "result": "SUCCESS",
  "data": {
    "traceId": "...",
    "inputFacts": { ... },
    "mutatedFacts": {},
    "generatedVariables": {
      "agentDecision": "NEEDS_APPROVAL",
      "isBlocked": true,
      "blockReason": "AMOUNT_NEEDS_APPROVAL"
    },
    "executionTraces": [ ... ],
    "decisionTraces": [ ... ]
  }
}

generatedVariables holds the answer your hook reads. agentDecision is the decision, and for anything other than ALLOW, isBlocked is true and blockReason says why. mutatedFacts holds any fact a rule changed, and nothing changed here. traceId is the ID of this call's decision record. executionTraces holds each rule's condition check. decisionTraces lists all ten rules: rule 5 decided the call, rules 8 to 10 matched too but lost on priority, and the other six didn't match. When a support lead asks next week why the refund waited, that record has the answer.

The $1,500 credit from the chat with the pasted approval scored 0.95, clearing rule 6's 0.7 threshold. Rule 3, which denies any single action over $1,000, denied it anyway. agentDecision is DENY and blockReason is AMOUNT_OVER_HARD_LIMIT.

Rules can also change an action instead of stopping it. An $80 goodwill credit with nothing else wrong matches the credit cap. The decision is ALLOW, and mutatedFacts.actionAmountUsd comes back as 50, so the customer gets $50 instead of the $80 the model proposed.

If the checker model times out, the call arrives with checkerConfidence set to null. Then even a $45 refund that would otherwise go through waits for a person, with blockReason set to INPUT_MISSING. In this template a missing input never produces ALLOW, so the template allows nothing unless you run a checker model.

The domain templates page in the docs lists all ten rules, and you can run these calls in the playground.

Wiring the check into the agent

The check belongs in the agent framework's pre-tool hook, which every tool call passes through. In the Claude Agent SDK that is a PreToolUse hook. The SDK's permission docs say hooks run before every other permission step, and they recommend a PreToolUse hook for checks that must cover every tool call (Claude Agent SDK docs).

The hook turns the tool call into the six facts and calls this function. LEXQ_URL is the execution URL from the curl example, and AgentFacts is a type for the six facts. API_KEY is a LexQ API key of the default type, Execute only, so it can call the execution API but can't change rules:

// Call this from the agent's pre-tool hook, before the tool runs.
async function checkAction(facts: AgentFacts) {
  try {
    const res = await fetch(LEXQ_URL, {
      method: "POST",
      headers: {
        "x-api-key": API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ facts }),
      signal: AbortSignal.timeout(2000),
    });
    const body = await res.json();
    if (body.result !== "SUCCESS") throw new Error(body.errorCode);
    const { generatedVariables: out, mutatedFacts } = body.data;
    return {
      decision: out.agentDecision, // ALLOW, NEEDS_APPROVAL, or DENY
      reason: out.blockReason, // missing on ALLOW
      // The credit cap can lower the amount, so use LexQ's value.
      amountUsd: mutatedFacts.actionAmountUsd ?? facts.actionAmountUsd,
    };
  } catch {
    // No usable answer from LexQ: don't run the tool. A person decides instead.
    return { decision: "NEEDS_APPROVAL", reason: "LEXQ_UNAVAILABLE" };
  }
}

A Claude Agent SDK PreToolUse hook then answers with permissionDecision:

  • ALLOW: "allow", with an updatedInput that copies the tool's arguments and sets the amount to the amountUsd that checkAction returns. updatedInput replaces the tool's whole input, so it has to carry the other arguments too.
  • NEEDS_APPROVAL: "deny", so the tool doesn't run now. Put the call in your approval queue, and your application runs it once a teammate approves.
  • DENY: "deny", and hand the conversation to a person.

With "deny" the hook also returns a permissionDecisionReason. The SDK's hooks docs say this reason "tells the model why," and it is how the agent knows to say a teammate will review the refund.

If LexQ errors or doesn't answer within two seconds, the function returns a hold and the tool doesn't run. If the hook ran the tool on failure instead, an outage at LexQ would let refunds and credits run unchecked. LEXQ_UNAVAILABLE is a label this code makes up for that case, not a value LexQ returns.

LexQ only returns a decision. The queue, the reviewer's screen, and the message to the customer stay in your application. Here the rules check the agent. The reverse also works. Through LexQ's MCP server, an agent creates and deploys the rules with its own rule-editing key.

Changing a limit later

A month in, the team trusts the agent with bigger refunds and wants the approval threshold at $150. In LexQ, clone the live version and change the $100 condition in the new draft. Before you publish and deploy it, run Decision Replay on the draft. It reruns the calls LexQ stored over a date range and counts the decisions that would change, with each stored decision as the baseline. Refunds between $100 and $150 that waited only because of the $100 rule show up as changes to ALLOW. Any other change means another edit slipped into the draft or an older version decided some of the calls in that range. Fix the draft or narrow the range, and replay again.

The new version starts deciding with the first call after the deploy. The agent's code and prompt stay as they are, since the prompt holds no numbers. If refunds go wrong after the change, one rollback returns the group to the $100 version.

Each stored decision record keeps the deciding version's ID in policyVersionId, so last month's refunds still show they ran under the $100 rule. The pattern Counting changed decisions before deploy with Decision Replay walks through the replay step by step.

When you don't need LexQ

If every refund and credit the agent proposes already waits for a teammate's click, there is nothing for rules to stop yet. Add a check the day the first refund or credit runs on its own.

If you don't plan to change the refund and credit limits and nobody will ask why a refund or credit went out, an if inside each of the two tool APIs is enough. Those checks run outside the model too, so no customer can talk them into a bigger number. If the limits may change or someone may ask why, put each limit in a config value and log every decision from the start. That will carry you for a while.

Move to rules once several checks apply to the same call, as the ten in this template do. When checks interact like this, raising the approval threshold to $150 hands refunds between $100 and $150 to the lower-priority rules. Only a replay of past calls through all ten rules shows which of those refunds would then go out without approval and which would still wait for a person. Decision Replay does that before the change ships. Every live call also gets a record of which version of the rules was in force and which rule decided it, without any logging code on your side.

→ Run the AI Agent Action Approval samples in the playground

Ready to move decisions out of your deploy pipeline?

Free to start, no credit card. Send facts, get back a result and the reasoning.

Start Free