the problem
Customers want the refund, the address change or the fixed order settled in one conversation. An agent that can do that holds tools that move money and change accounts, which changes what “it works” means.
The business needs four things from such an agent:
- Limits that hold even when the model is wrong or manipulated. A refund cap in the system prompt is only a request.
- Identity checked before account changes.
- No double refunds. Models retry, and so does infrastructure. Neither should pay out twice.
- A clean handoff when the agent should stop. The person who picks up should start from what the agent already learned.
Around those sit the non-functionals we design for from the start:
- Latency per turn. Every tool round-trip adds to the pause.
- Cost per conversation. Support runs at volume, so small per-turn waste multiplies.
- PII in transcripts. Customers type card fragments and addresses into chat, and whatever we store or hand off will contain them.
- Auditability. When a refund goes out, someone will ask who allowed it.
the design
- The customer writes in through chat or the web, and your app authenticates them as it already does.
- Your app calls the agent on AgentCore Runtime with the customer’s JWT, which Runtime validates through inbound auth.
- Bedrock Guardrails screens the customer’s input before the model acts on it.
- The agent reads and writes AgentCore Memory, scoped to this customer and this session.
- The agent calls tools through AgentCore Gateway, where Policy in AgentCore evaluates every call against Cedar rules before it goes further.
- The Gateway invokes the tool Lambdas, and the refund Lambda re-checks the verification store and the limit before it moves any money.
- Guardrails screens the agent’s final output before it reaches the customer.
- When the agent should stop, it writes a structured summary to the case system through the case-write tool, which screens it with
ApplyGuardrailbefore storing it. - The case system routes the case to a human agent, passing only a reference ID.
One agent, a small set of tools, and three independent places that can say no.
components
| component | AWS service | responsibility |
|---|---|---|
| Your app | your existing front end and auth | Authenticates the customer and passes a JWT to the agent. Never forwards a claim the customer could set. |
| Agent | Amazon Bedrock AgentCore Runtime | Hosts the agent (Strands or the Claude Agent SDK). Validates the inbound JWT. Holds no authority of its own. |
| Guardrails | Amazon Bedrock Guardrails | Screens customer input and final output. The case-write tool also calls ApplyGuardrail on the handoff summary, so the sensitive-information filter redacts the PII types and patterns you configure before it is stored. |
| Memory | AgentCore Memory | Keeps session events and a restricted set of long-term records, namespaced per customer, with a set expiry. |
| Tool gateway | AgentCore Gateway | Exposes the Lambdas as MCP tools, validates the caller, and invokes the targets with its IAM role. |
| Policy | Policy in AgentCore (Cedar) | Default-deny rules attached to the Gateway. Evaluates every tool call, including its arguments. |
| Tools | AWS Lambda | Order lookup, identity verification, refund, account change and case write. The refund and change tools re-check everything themselves. |
| Verification store | Amazon DynamoDB | Session-keyed “identity verified” state, written only by the verification tool, read by the refund and change tools. |
| Case system | your ticketing or CRM system | Receives the full structured summary when the agent escalates, after the case-write tool has screened it with ApplyGuardrail. |
| Human agent | your contact centre, e.g. Amazon Connect | Picks up the case with a reference ID and reads the summary from the case system. |
A note on identity. The customer authenticates to your app, and Runtime and Gateway validate that JWT through inbound auth, which supports a custom JWT authoriser with claim checks, or IAM. The Gateway calls our own Lambdas with its IAM role, so no user credentials travel to them. AgentCore Identity, the outbound token vault, is only added when a tool calls a third-party API on the customer’s behalf, for example a SaaS CRM that needs the customer’s delegated OAuth token.
decisions
Where tool authorisation lives
A prompt is a request, not a control.
The pre-tool hook runs inside the agent and gives fast, readable refusals the model can explain to the customer. Cedar policy at the Gateway is default-deny and runs outside the agent process, so a compromised or confused agent can’t talk its way past it. The refund Lambda re-checks the limit and the verification state because it is the source of truth, and a buggy or manipulated agent is exactly what we’re guarding against.
The trade-off is that the rules live in three places, and three copies drift. Keep the limit in one configuration value that the hook, the policy build and the Lambda all read.
Where 'identity verified' lives
Verification usually happens mid-conversation, for example a one-time code sent to the phone on file. It can’t be in the original login token, because it hadn’t happened when that token was issued. And it can never be in the conversation, because the model must never be able to assert it.
So the verification tool writes a session-keyed record to DynamoDB, and nothing else can write it. The refund and change tools read it directly.
A step-up token, issued after verification and carrying a verified claim, is a valid alternative. If you want Policy to condition on that claim, test that your policy engine can read it before relying on it.
How refunds stay exactly-once
Model retries and at-least-once invocation both repeat calls, and neither is rare enough to ignore when money moves.
The refund Lambda derives an idempotency key from the order ID plus a request ID and records it with a conditional write and a status of pending before paying out, then marks it completed with the result. A repeat against a completed key returns the original result instead of a second refund. A repeat against a pending key does not pay out: it reports the refund as in progress, and a record left pending after a failed payout is reconciled against the payment system before anyone retries it.
Human approval for every refund would also prevent doubles, but gives up the reason to build the agent. Instead, a refund above the limit escalates rather than executing.
What memory keeps
AgentCore Memory keeps short-term memory as raw session events and long-term memory as records extracted by strategies.
- Unrestricted long-term extraction will capture payment and ID details, so we use the summarization and user-preference strategies rather than free semantic extraction.
- Namespace per customer, using the actor and session scoping Memory provides, so one customer’s records never reach another’s session.
- Short-term expiry is configurable from 7 to 365 days, with a default of 90, so choose it deliberately against your retention policy rather than inheriting the default.
- Wire deletion requests to
BatchDeleteMemoryRecordsso a customer’s erasure request actually reaches the agent’s memory.
What happens when a control is unavailable
A timeout in the policy engine, the hook or the verification store must never become a yes. If any of them can’t answer, the action is denied and the conversation escalates to a person with the reason attached.
The Claude Agent SDK already behaves this way: when a PreToolUse hook times out, the tool does not run. In Strands you write that behaviour yourself, by wrapping the check so that any exception cancels the tool call instead of letting it proceed.
What the human receives
The person picking up needs the customer’s intent, what the agent did, what it was blocked from doing and what is still open, not twenty turns of chat.
The agent writes a structured summary to the case system with these fields: reason (an enum such as limit exceeded, verification failed or customer request), customer_ref, verified, intent, actions_taken, blocked_actions, open_questions, sentiment and transcript_ref. It holds references, not PII.
Contact-centre attributes are small and flat. For example, Amazon Connect’s StartChatContact takes Attributes as a flat string map and a ClientToken for idempotency, so we pass the case reference there, not the summary.
The summary reaches the case system as a tool input, and the Guardrails sensitive-information filter doesn’t evaluate tool inputs or results when it is attached to the model. So the case-write Lambda calls ApplyGuardrail on the summary payload before storing it, and the sensitive-information filter redacts the PII types and patterns you configure at that point.
safety and governance
Guardrails screen the customer’s input and the agent’s final output. With Strands, pass guardrail_id and guardrail_version to BedrockModel and the guardrail applies inline. With the Claude Agent SDK, call ApplyGuardrail in the Runtime entrypoint on the input before the agent runs and on the final output before it returns. ApplyGuardrail screens text without invoking a model.
The prompt-attack filter is built for what the customer types. It is not a reliable defence against instructions hidden in tool results, such as order notes or ticket text, so nothing in this design lets model output authorise an action.
That is why authorisation is deterministic and layered. The first layer is a pre-tool hook in the agent. In Strands it is a BeforeToolCallEvent callback that cancels the call with a message the model can relay:
from strands.hooks import HookProvider, HookRegistry, BeforeToolCallEvent
class RefundGate(HookProvider):
def __init__(self, session_id): self.session_id = session_id
def register_hooks(self, r: HookRegistry): r.add_callback(BeforeToolCallEvent, self.check)
def check(self, e: BeforeToolCallEvent):
name, args = e.tool_use["name"], e.tool_use["input"]
if name.endswith("___issue_refund") and (not verified(self.session_id) or args["amount"] > LIMIT):
e.cancel_tool = "Refund not permitted: escalate to a human."
Here your entrypoint captures the current conversation’s session ID from the Runtime request and constructs the hook with it, as RefundGate(session_id); verified looks that session up in the verification store; and LIMIT comes from the shared configuration value. Make it fail closed: if the lookup raises, set cancel_tool rather than letting the exception pass.
The Claude Agent SDK equivalent is a PreToolUse hook that returns permissionDecision: "deny". Registered through ClaudeAgentOptions(hooks=...) with a HookMatcher, the hook’s output has this shape:
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Refund not permitted: escalate to a human."}}
The second layer is Policy in AgentCore. It is attached to the Gateway, evaluates every tool call, is default-deny, and can condition on tool arguments through context.input. Alongside the permit rules for each tool, a forbid rule caps refunds:
forbid(principal, action == AgentCore::Action::"Refunds___issue_refund", resource)
when {
context.input.amount > 200 // placeholder limit: set from your refund policy
};
Tool names follow the Gateway’s <target>___<tool> convention, with three underscores, so the refund tool on a target called Refunds is Refunds___issue_refund. Take the exact action names from the Gateway’s generated schema rather than typing them by hand. In the Claude Agent SDK the same tool surfaces as mcp__<serverKey>__Refunds___issue_refund, which is what your hook matcher sees.
Check Region availability for Policy in AgentCore. If you deploy with CDK, its policy constructs are still in the alpha package, so pin that dependency.
The third layer is the refund Lambda itself, which re-reads the verification store, re-checks the limit and enforces the idempotency key.
evals
We run AgentCore Evaluations over the agent’s traces: its built-in evaluators, plus ground-truth and custom evaluators for what is specific to support, such as whether the agent resolved the request and escalated when it should have. With the Claude Agent SDK, add the openinference-instrumentation-claude-agent-sdk dependency so the agent emits OpenInference traces; the ADOT setup on Runtime then picks them up for Evaluations. Once that is in place, the same eval set can run against either framework.
On top of that sits a fixed adversarial set that every release runs against:
- a refund request above the limit;
- a refund request before identity verification;
- an injection placed in order notes, telling the agent to issue a refund or reveal data;
- a request for another customer’s order.
The release gate is zero policy bypasses on that set, plus task-success and escalation-appropriateness scores no worse than the previous release.
The set grows from production: every real escalation that turns out to be wrong becomes a new case.
cost and latency
Cost and latency come from the same place: how many model turns and tool round-trips a conversation takes. The levers:
- Model tier per turn. A Haiku-class model handles routing and simple turns such as order-status lookups. A Sonnet-class model takes over when the agent must plan multi-step tool use, such as verifying identity, checking an order and issuing a partial refund.
- Prompt caching. The system prompt and tool definitions are the same on every turn, so cache them.
- Short memory retrieval. Pull the few records that matter for this customer, not everything Memory holds.
- Fewer tool round-trips. Clear tool descriptions and tools that return what the agent needs in one call cut turns more than any other change.
To estimate, work from traces rather than guesses. The cost of a conversation is roughly the turns per conversation multiplied by the input and output tokens per turn, multiplied by the model rate, plus the Lambda and Gateway calls. Measure the first two numbers from a pilot’s traces, then price them against current published rates for your model and Region.
operating it
The failure modes we plan runbooks for:
- The Policy engine is unavailable. Fail closed: tool calls are denied and conversations escalate.
- The verification store is down. Refunds and account changes are denied, lookups still work, and the agent says a person will follow up.
- A Lambda times out. The agent says the action didn’t complete and escalates rather than retrying blindly. The idempotency key makes any later retry safe.
- A spike in escalations. Compare against the last release first.
- A spike in blocked actions. This may be an attack, or a bad prompt release that makes the agent attempt things it shouldn’t.
Trace every tool call with its policy decision, the hook result and the Lambda’s outcome, keyed to the conversation. That trace is the audit record.
The dashboards that matter:
- escalation rate;
- blocked-action rate, by tool and by rule;
- refund totals per hour.
Each has a runbook trigger. A sharp change in escalation or blocked-action rate pages the agent’s owner. Refund totals above an agreed hourly ceiling page the refund-policy owner, who can lower the limit in the shared configuration value. The hook and the Lambda pick it up directly; the Cedar policy holds the limit as a literal, so regenerate and update the policy from the same value in the same change.
when not to use this
- Read-only FAQ or order-status assistants. If the agent never changes anything, retrieval with no write tools is simpler and safer.
- Flows where every action already needs approval. If a person must sign off each refund anyway, build a drafting assistant that prepares the action for them.
- A team with no one to own the policies. The limits, the Cedar rules and the escalation reasons need an owner who changes them when the business changes.
on the Claude API directly
The same shape works with the Claude Agent SDK outside AWS. What changes is who provides each control:
PreToolUsehooks do the fast check, and deny with a reason the model can relay;- your own tool service enforces the limits, the verification state and idempotency, and is the source of truth;
ApplyGuardrailis replaced by your own screening of input, output and the handoff summary;- the escalation path is unchanged: a structured summary to the case system and a reference to the contact centre.
Without a Gateway policy engine, the tool service carries more of the weight. The model proposes and the system decides.