The situation
The assistant behind the subscriber help desk started with read-only tools: an action group whose functions looked up an account, checked a delivery schedule, and fetched a knowledge-base article. Nothing it called could change anything, so a wrong call was at worst a wrong answer.
Now the team wants it to act. The backlog asks for tools that issue a refund, pause a subscription, and change a delivery address, so a subscriber can resolve a problem in the chat rather than waiting for a human. Each of those writes to a system of record. The moment a tool can move money or change an account, the arguments the model puts into that call stop being a display concern and become an authorisation concern, because the model chose them, and the model can be pushed around by whatever text landed in the conversation.
An action group defines the callable tools, either through a function schema listing each function and its parameters, or through an OpenAPI schema describing the operations, and each action group is backed by a Lambda function or handed back to the application through return of control. What the team has to decide is how to shape those tools, their schemas, their backing, and their permissions, so the assistant is capable enough to be useful and constrained enough that a manipulated request cannot do real harm.
What actually matters
The first thing to name is blast radius. Every tool you add to an action group is a capability you are granting the model, and the useful measure of a tool is not what it does on a good day but the worst a single call can do on a bad one. A tool called refundOrder that takes an order id and refunds that order has a small, nameable blast radius. A tool called runAccountAction that takes an action name and a free-form payload has an enormous one, because you cannot look at the schema and say what it can and cannot do. The design goal is to keep each tool small enough that the worst case is tolerable and, where money or state moves, reversible.
The second is that the arguments are model-generated and therefore untrusted. The foundation model fills in the parameters, and the model reads the whole conversation, including whatever a subscriber typed and whatever text came back from a retrieval step. That is exactly the surface a prompt-injection attempt rides in on: a crafted message that talks the model into calling the refund tool with someone else’s order id, or the address-change tool with an attacker’s address. The parameters that reach your Lambda are, for security purposes, input from an untrusted source, and they deserve the same suspicion you would give a web form.
The third is granularity and typing. A narrow, single-purpose tool with strongly typed parameters is easier for the model to call correctly and far easier for you to validate, because the schema itself rules out whole classes of nonsense. An OpenAPI schema can pin a parameter to an enum, a format, or a numeric range; a function schema types each parameter and marks it required. Neither replaces server-side checks, but a tight contract means fewer bad calls reach the code and the ones that do are easier to reject.
The fourth is where the tool actually runs, and under what authority. A Lambda-backed action group executes under the Lambda function’s own execution role, and that role, not the agent, decides what the tool can touch. Scope it to exactly the resources this one tool needs and a call coaxed into asking for more simply fails at the IAM boundary. Return of control moves execution into your application instead, which is the right choice when you would rather not grant Bedrock permission to invoke the tool at all, or when the tool must run under the calling user’s own credentials and context.
The fifth is side effects. A read tool can be retried freely; a write tool cannot. Side-effecting tools should be idempotent, so a retry or a duplicated call does not refund twice, and the ones that matter should wait for confirmation rather than firing blind. Bedrock supports this directly: a function can require user confirmation, so the agent surfaces the intended call and its parameters and does not execute until the caller confirms. That turns the riskiest tools into a human-in-the-loop step instead of an autonomous one.
Underneath all of it: identity should not be a model parameter. If the tool needs to know which subscriber is acting, do not let the model supply the subscriber id, because the model can be talked into supplying a different one. Pass it through session attributes that your application sets from the authenticated session, so the trusted identity travels beside the call and the model never gets to choose it.
What we’ll filter on
- Blast radius: what is the worst a single call to this tool can do, and can it be undone?
- Single-purpose: does the tool do one nameable thing, or take a free-form instruction?
- Strongly typed and constrained: are the parameters typed, enumerated, and bounded in the schema?
- Validated server-side: does the executor re-check every argument, given the model supplied them?
- Least privilege: does execution run under a role or context scoped to this tool alone?
- Side-effect discipline: is a write idempotent, and does it wait for confirmation before firing?
The tool-design landscape
-
One broad tool. A single function that takes an action name and a free-form payload, or an id and an arbitrary command, and does whatever it is told. It is tempting because it is quick to build and the model can, in theory, do anything with it. That is also the problem: the schema tells you nothing about what it can do, you cannot scope its permissions to anything narrower than everything it might be asked to do, and a single injected instruction can steer it anywhere. This is the shape to design away from.
-
Narrow, single-purpose tools. One function per nameable action:
getSubscription,pauseSubscription,refundOrder,changeDeliveryAddress. Each has a small parameter list, a clear description, and a blast radius you can state in a sentence. The model picks among many small tools rather than driving one large one, which both constrains the damage and, in practice, improves the model’s accuracy because each tool does one thing. This is the default you want. -
The schema mechanism. A function schema defines each function with a description and typed parameters, string, number, integer, boolean, or array, each marked required or optional. An OpenAPI schema describes the same tools as API operations and lets you express richer constraints in the contract: enums for a fixed set of reasons, formats for dates and identifiers, minimum and maximum for amounts. Richer typing means more bad calls bounce off the schema before your code runs, so prefer OpenAPI when a tool has values worth constraining; either way the contract is a first line, not the only line.
-
Where execution lives. A Lambda-backed executor runs your validation and business logic under the Lambda’s execution role, which is the natural place to enforce least privilege: give the refund tool a role that can call the refund API and read the order it names, and nothing else. Return of control instead hands the tool name and parameters back to your application in the
InvokeAgentresponse, and your code runs it and returns the result on the next call. Return of control fits when you do not want to grant Bedrock invoke permission on the tool, or when the action must run under the end user’s own credentials rather than a shared function role. -
Confirmation gates. A function can be configured to require user confirmation, so before it runs the agent returns the intended call and its parameters and waits for the caller to approve. For a write that moves money or changes an account, this converts an autonomous action into a reviewed one, and it is cheap: you set it per function on the ones that warrant it and leave the harmless reads ungated.
-
Trusted context through session attributes. Session and prompt session attributes are set by your application and passed through to the executor alongside the model-chosen parameters. Put the authenticated subscriber id there, scope every tool to act only within that identity, and the model no longer has any say over whose account it is touching, because the id it would need to change is not one of its parameters.
Side by side
| Design | Blast radius contained | Single-purpose | Constraints in schema | Re-validated server-side | Least-privilege execution | Side-effect safe |
|---|---|---|---|---|---|---|
| One broad command tool | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Narrow read tool, function schema + Lambda | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ (read-only) |
| Narrow read tool, OpenAPI schema + Lambda | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (read-only) |
| Narrow write tool, Lambda + scoped role | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ (fires blind) |
| Narrow write tool, return of control | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ (fires blind) |
| Confirmation-gated write tool | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Reading it for the help desk: the reads are already fine as narrow, typed, Lambda-backed tools. The new writes need the same narrowness and typing, a re-validating executor under a scoped role, and, for the ones that move money or change an account, a confirmation gate on top so the riskiest calls are reviewed rather than autonomous.
Defence in depth for one tool call
The picks in depth
Narrow, single-purpose tools, typed as tightly as the schema allows. Split the work into getSubscription, pauseSubscription, refundOrder, and changeDeliveryAddress rather than one manageAccount. Give each a short, honest description so the model calls the right one, and type every parameter. Where the tool has values worth pinning down, reach for an OpenAPI schema so you can declare an enum for the refund reason, a format for the order id, and a maximum for the amount; the function schema is fine for the simple reads where there is nothing richer to say than the type. The payoff is twofold: fewer malformed calls reach your code, and the tools the model is choosing among each mean one clear thing.
A re-validating executor under a scoped role. Treat everything the model passed as suspect, because it is. In the Lambda, re-check every argument against the real world: does this order exist, does it belong to the subscriber in the session, is the amount within the actual order total, is the reason one you accept. The schema is a filter, not a guarantee, and prompt injection lives in the gap between what the contract allows and what is actually legitimate. Then give the function a role that can do only this tool’s job, so even a call that slips past your checks cannot reach a resource the tool was never meant to touch. Least privilege is the layer that holds when validation has a bug.
Identity from the session, never from the model. The refund tool needs to know whose order it is refunding, and that subscriber id must come from the authenticated session your application controls, carried in session attributes, not from a parameter the model fills in. If the model can supply the id, an injected instruction can change it, and you have built account takeover into the tool. With the id pinned outside the model’s reach, every tool scopes its checks to that identity, and a request to touch someone else’s account has nowhere to put the other id.
Idempotency and confirmation for the writes. Make each write idempotent so a retry, a duplicated model call, or a resubmitted confirmation does not refund twice; an idempotency key derived from the order and the request is the usual way. Then turn on user confirmation for the functions that move money or change an account, so the agent surfaces the intended call and its parameters and holds until the caller approves. The reads stay ungated because there is nothing to review. The result is that the assistant can act on the safe things freely and pauses on exactly the things a human would want to see first.
Errors the agent can recover from. When the executor rejects a call, return a clear, specific message rather than a bare failure, because the agent can read it and try again correctly. Bedrock gives you a response state for this: return a reprompt so the message goes back to the model to adjust, or a failure when the call should stop. A rejection that says the amount exceeds the order total lets the model ask the subscriber for the right figure; a silent error just strands it. Clear errors are part of the safety design, because a tool that fails legibly is one the agent uses correctly on the second try instead of flailing.
A worked example: designing the refund tool
The team writes refundOrder as one narrow function. Its OpenAPI schema declares two parameters: orderId, a string with a format the id must match, and reason, an enum of the handful of reasons the business accepts. There is no amount parameter at all, because the refund is always the order total and letting the model choose an amount would only widen the blast radius; the executor looks the total up. The subscriber id is not a parameter either; it rides in the session attributes that the chat front end sets from the logged-in session.
The Lambda behind it runs under a role that can call the refund API and read orders, nothing more. On each call it re-validates: the order exists, it belongs to the subscriber in the session, it is in a state that can be refunded, and it has not been refunded already. That last check keys the operation on the order so a repeated call is a no-op rather than a second refund. If any check fails, the function returns a reprompt explaining which one, so the agent can respond sensibly instead of insisting.
The function requires confirmation. When the model decides to call it, the agent returns the intended refund and its order to the front end, which shows the subscriber a plain confirmation, and nothing moves until they approve. A subscriber who was talked into nothing sees a refund they asked for and confirms it; an injected instruction that tried to refund a stranger’s order fails at the ownership check long before that, and even if it had not, it would surface as a confirmation nobody asked for. Read tools like getSubscription carry none of this ceremony, because a wrong read is a wrong sentence, not a wrong transaction.
The same tool could instead run through return of control, with the front end executing the refund under the subscriber’s own credentials rather than a shared Lambda role. The team keeps the Lambda for now because the scoped role and server-side checks already give them what they need, but they note return of control as the move if a tool ever has to act strictly as the end user. The choice sits alongside the larger one of how many agents and how much orchestration the help desk should carry, covered in orchestrating multiple Bedrock agents; here the unit of design is smaller, a single tool and the smallest capability it can be given.
What’s worth remembering
- Every tool in an action group is a capability you grant the model; design it around the worst a single call can do, not the best.
- The parameters are model-generated and therefore untrusted, and prompt injection rides in through them, so treat them like input from an unauthenticated source.
- Prefer many narrow, single-purpose tools over one broad tool; a
refundOrderyou can describe in a sentence beats amanageAccountyou cannot. - Type parameters as tightly as the schema allows, and use an OpenAPI schema when a value deserves an enum, a format, or a bound.
- The schema is a filter, not a guarantee; re-validate every argument server-side against the real world, including ownership.
- Run each tool under a least-privilege role, or return control to your application, so a coaxed call cannot reach beyond the tool’s remit.
- Keep identity out of the model’s hands: pass the authenticated subscriber id through session attributes, never as a parameter the model fills in.
- Make side-effecting tools idempotent, and require confirmation on the ones that move money or change an account so they are reviewed, not autonomous.
- Return clear, specific errors, using reprompt or failure, so the agent can recover on the next try instead of flailing.
The help desk ships its reads unchanged and its writes as narrow, typed, re-validating tools under scoped roles, with confirmation on the refund and the account changes and idempotency behind all of them. The assistant ends up able to do the things a subscriber actually needs, and unable, even when someone tries, to do the things nobody authorised.