The situation
The assistant behind the subscriber help desk started read-only. Three tools sat behind an AgentCore gateway: look up an account, check a delivery schedule, fetch 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.
The gateway is where those tools are defined. It takes Lambda functions, OpenAPI documents, Smithy models, and existing MCP servers, publishes them to the agent as MCP tools behind a single endpoint, and handles the protocol translation and the outbound credentials on the way. Anyone arriving from Bedrock Agents Classic will recognise the shape, because this was an action group there, declared as a function schema or an OpenAPI schema and backed by a Lambda. Classic closed to new customers on 30 July 2026 and the term went with it. The tools are gateway targets now, and every design question survived the rename: how to shape each tool, what its schema can actually express, where it runs, whose identity it runs under, and what stops a write before it fires.
What actually matters
The first thing to name is blast radius. Every tool you publish 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. Keep each tool small enough that the worst case is tolerable and, where money or state moves, reversible, because a write cannot be un-fired the way a bad read can be ignored.
The arguments are model-generated and therefore untrusted. The model fills in the parameters, and it reads the whole conversation, including whatever a subscriber typed and whatever text came back from a retrieval step. That is the surface a prompt-injection attempt rides in on: a crafted message that steers the refund tool onto someone else’s order id, or the address-change tool onto an attacker’s address. The parameters that reach your code are, for security purposes, input from an untrusted source, and they deserve the same suspicion you would give a web form.
How much the contract can rule out varies with how the tool is attached, and it is easy to assume more than you got. Some attachments let the document name the acceptable values, so the schema says what the tool will take. Others accept only types, descriptions, nesting, and a required list, which means a parameter you thought of as “one of four reasons” arrives as an arbitrary string. That difference sets how much validation has to live at the tool rather than in the contract. A constraint you believe is enforced and is not is worse than one you knew you had to write yourself.
Then there is authority, and identity is the harder half of it. The code behind a tool runs under its own execution role, and that role, not the agent, bounds what the tool can touch; the credential the gateway presents to a downstream API is a separate grant again. What does not arrive on its own is the identity of the person in the chat. The payload a tool receives is the arguments the model chose plus routing metadata about which gateway and which tool were invoked, and nothing that says who is asking. If a tool needs to act within one subscriber’s account, that identity has to be arranged deliberately, because the alternative is letting the model supply it, and an injected instruction can change what it supplies.
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?
- Values named in the contract: can the schema name the acceptable values, or only the type and whether the field is required?
- Re-validated at the target: does the executor re-check every argument, including ownership against an identity the model did not supply?
- Least privilege: do execution and the outbound credential scope to this tool’s job alone?
- Gated before firing: is the write idempotent, and does it hold for confirmation?
The landscape
One broad tool
A single tool that takes an action name and a free-form payload, or an id and an arbitrary command, and runs whatever it is given. It is tempting because it is quick to build and can, in theory, do anything.
That is also the trouble: 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 one injected instruction can steer it anywhere.
This is the shape to design away from.
Many narrow, single-purpose tools
One tool 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 constrains the damage. The old objection was that a long tool list bloats the prompt; a gateway created with semantic search of tools answers it: the agent queries the catalogue for the tools that fit the task instead of carrying all of them in context.
Lambda targets, and the limits of what their schema says
A Lambda target is declared with a ToolDefinition, where name, description and inputSchema are required and outputSchema is optional. The top-level inputSchema must be an object, and each property accepts type (one of string, number, integer, boolean, object, array), a description, properties, required, and items for arrays. There is no enum, no format, no minimum or maximum, no pattern. A refund reason is a string, and the contract will not stop a fifth value reaching your code.
{
"name": "refundOrder",
"description": "Refund a single order in full. The refund amount is the order total.",
"inputSchema": {
"type": "object",
"properties": {
"orderId": { "type": "string", "description": "The order to refund" },
"reason": { "type": "string", "description": "One of: damaged, missing, late, quality" }
},
"required": ["orderId", "reason"]
}
}
Two details bite in the handler. The event is a flat map of the inputSchema properties to their values, not a wrapped envelope. The tool name arrives in the client context as bedrockAgentCoreToolName, prefixed with the target name and a triple-underscore delimiter, so refundOrder reaches you as subscriberTools___refundOrder and the prefix has to come off before you dispatch on it.
OpenAPI targets, where the contract can name more
An OpenAPI 3.0 or 3.1 document attached as a target has each operation published as a tool, with operationId becoming the tool name, so every operation you want exposed needs one. The parameter schemas carry through, and the document has somewhere to put a value set that the Lambda tool definition has no field for: an enum of the four refund reasons the business accepts lands in the schema the model is shown rather than in a line of prose it may or may not follow. Nested objects and arrays with item schemas carry through too, though a Lambda tool definition expresses those perfectly well.
What the enum does not get you is a documented rejection. Arguments that do not conform to a tool’s input schema come back as a ValidationError with a 400, but the schema features AWS lists as supported for an OpenAPI target stop at basic types, required fields, nested objects and arrays with item specifications. enum is on neither the supported nor the unsupported list, so treat it as a hint to the model and check the value at the target anyway.
The compositions are the gap. oneOf, anyOf, and allOf are not supported, nor are serialisers for path, query, header and cookie parameters, and application/json is the only content type fully supported.
Where a tool has values worth naming, this is the attachment that names them.
Request interceptors
A gateway can carry one REQUEST interceptor, a Lambda that runs before the target is called, and one RESPONSE interceptor after. The request interceptor receives the parsed JSON-RPC body, so for a tools/call it can read the tool name and the arguments the model chose. It can return a transformedGatewayRequest with a rewritten body, which is how an argument gets injected or overwritten. It can also return a transformedGatewayResponse, which short-circuits: the gateway replies with that content and the target is never invoked, though a configured RESPONSE interceptor still runs.
That is the deny path, and it is where tool-level, operation-level, and parameter-level access checks live. Two conditions attach. The caller’s bearer token is only visible if the interceptor is configured with passRequestHeaders, a value your function must not log. And the gateway may retry an interceptor on failure or timeout, so the function has to be idempotent.
Execution authority, and how much of it depends on the target type
A Lambda target runs your code under the Lambda’s own execution role, which is the natural place for least privilege: give the refund tool a role that can call the refund API and read the order it names, and nothing else. What reaches that Lambda is a separate question. The gateway invokes it with the gateway service role, and for a Lambda target that is the only option there is. No OAuth, no API key, no forwarding of the caller’s token. OpenAPI and MCP-server targets have the full range instead: either can be configured through AgentCore Identity with two-legged client credentials, three-legged authorisation code, an API key, or on-behalf-of token exchange. That last one swaps the inbound user token for a scoped token addressed to the downstream service, carrying both the user’s identity and the agent’s. That difference sets where per-subscriber authorisation can be enforced.
The shared ceiling nobody notices
The gateway service role is shared by every target configured to use it, and its permissions are the upper bound on what any authorised caller can reach through that gateway. A tool whose own execution role is scoped tightly still sits behind a service role that may not be, and the tighter role does not save you if the ceiling is generous. AWS’s own advice is to keep the service role to the minimum across all targets, put targets with different sensitivity behind separate gateways with separate roles, and use the policy engine to control which callers can invoke which targets.
A confirmation the code enforces
Classic had a per-function requireConfirmation flag, set to ENABLED or DISABLED, and it is not what you configure now. The managed harness takes inline function tools, which run in your own code rather than on the harness, and a confirmation gate is one of them: you write the function’s description as the confirmation policy. When the agent calls that function, the harness emits a toolUse and the stream ends with stopReason set to tool_use. Your front end asks the human, then invokes the harness again on the same session id. The harness holds the authoritative assistant toolUse and the pending execution, so the follow-up carries only the matching toolResult; a resent toolUse is ignored and the original execution resumes from session state. Where the inline tool was passed as an invoke-time override rather than configured on the harness, pass it again so the pending call survives. The gate is a few lines you own rather than a checkbox.
Evaluation
Side by side
| Design | Blast radius contained | Single-purpose | Values named in contract | Re-validated at target | Least-privilege execution | Gated before firing |
|---|---|---|---|---|---|---|
| One broad command tool | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Narrow read tool, Lambda target | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ (read-only) |
| Narrow read tool, OpenAPI target | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (read-only) |
| Narrow write tool, Lambda target, scoped role | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ (fires blind) |
| Narrow write tool, OpenAPI target, scoped credential | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ (fires blind) |
| Write tool behind a request interceptor | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ (fires blind) |
| Write tool with an inline confirmation function | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Reading it for the help desk: the reads are already fine as narrow tools on either attachment. The new writes want the OpenAPI target wherever a value is worth naming, and a re-validating executor under a scoped role and a scoped outbound credential. On top of that they want an interceptor to settle identity before the call lands, and an inline confirmation function on the ones that move money or change an account.
Defence in depth for one tool call
The solution
Narrow, single-purpose tools, typed as tightly as the attachment allows. Split the work into getSubscription, pauseSubscription, refundOrder, and changeDeliveryAddress rather than one manageAccount, and give each a short, accurate description, because the description is what the model chooses on. Then pick the attachment by how much the contract needs to say. A refund reason is a fixed set of four values, and an OpenAPI operation can declare that set as an enum in the schema the model is shown. A Lambda tool definition has no field for it, so there the four values live in a description saying “one of: damaged, missing, late, quality”, which the model reads as advice. Neither is a rule you can lean on, since enum is absent from the schema features AWS lists as supported for an OpenAPI target. Reach for OpenAPI where there are values worth naming, keep Lambda targets for the tools whose parameters are genuinely just typed, and enforce the set in code either way.
A re-validating executor under a scoped role. Treat everything the model passed as suspect, because it is. Strip the target-name prefix off the tool name. Then re-check every argument against the real world: does this order exist, does it belong to the subscriber in this session, is it refundable, is the reason one you accept. The contract is a filter, not a guarantee, and it is a weaker filter than you may think on either attachment; prompt injection lives in the gap between what the schema allows and what is actually legitimate. Then give the function a role that can do only this tool’s job, and configure its outbound credential the same way, so 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 arranged deliberately, never left to the model. The refund tool has to be told whose order it is refunding, and that subscriber id must not be a parameter, because a parameter is something an injected instruction can change. Inbound authorisation validates the caller’s token at the gateway; a request interceptor reads the claim and writes the subscriber id into the arguments before the target is called, or rejects the call by returning a response instead of forwarding it. Configure the interceptor with passRequestHeaders only because it needs the token, and make sure it never logs the header. Where the tool calls a downstream API, exchange the inbound token on-behalf-of so the request arrives carrying the user’s identity and the agent’s, and the far end enforces access rather than trusting a claim in a payload.
Idempotency and a confirmation you build. 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, and it matters more now that the gateway may retry an interceptor and the harness may be re-invoked on the same session. Then put the money-moving and account-changing tools behind an inline confirmation function, so the harness stops, your front end shows the subscriber what is about to happen, and nothing runs until they answer. The reads stay ungated because there is nothing to review. Build the gate so the flow cannot reach the write without passing through it, rather than instructing the model to ask first, because an instruction is the layer injection attacks first.
Errors the agent can recover from. When a check fails, return a tool error with a clear, specific message rather than a bare failure, because the model reads the result and can adjust. A rejection naming the order as already refunded lets it tell the subscriber why.
Worked example
The team writes refundOrder as one operation on an OpenAPI target. operationId is refundOrder, which becomes the tool name. It declares two parameters: orderId, a string, and reason, an enum of the four reasons the business accepts. There is no amount parameter, because the refund is always the order total and letting the model choose a figure would only widen the blast radius; the executor looks the total up. There is no subscriberId parameter either, and that absence is deliberate.
The gateway’s request interceptor supplies it. Inbound authorisation has already validated the chat front end’s token, and the interceptor reads the subscriber claim out of it and writes subscriberId into the tool arguments before the call is forwarded. The same function checks that this caller is allowed to reach a write tool at all, and where they are not, it returns a transformedGatewayResponse carrying an authorisation error, so the target is never invoked. It is idempotent, because the gateway will retry it on a timeout, and it does not log the header it reads the token from.
Behind the target, the refund service runs under a credential scoped to refunds and order reads and nothing else. On each call it re-validates: the order exists, it belongs to the subscriber the interceptor supplied, it is in a refundable state, and it has not been refunded already. That last check keys on the order, so a repeated call is a no-op rather than a second refund. Any failure comes back as a tool error naming which check failed, so the agent can report the reason rather than repeating the call.
The write waits for a person. The harness carries an inline confirmation function whose description tells the model to call it before any refund. When it does, the stream stops with a tool_use, the front end shows the subscriber the order and the amount in plain words, and their answer goes back to the harness on the same session id. A subscriber who asked for a refund sees one and approves it. An injected instruction aiming at a stranger’s order fails at the ownership check long before this, and had it not, it would have surfaced as a confirmation nobody asked for. Read tools like getSubscription carry none of this ceremony, because a wrong read is a wrong sentence rather than a wrong transaction. How many agents share these tools, and how much orchestration sits above them, is the larger question covered in orchestrating multiple agents; the unit of design here is one tool.
What’s worth remembering
- Design for the worst call. Each gateway tool grants the model a capability; narrow, single-purpose tools keep the blast radius small and writes reversible.
- Model arguments are untrusted input. Prompt injection rides in through them, so re-validate every argument at the target, including ownership.
- Lambda schemas constrain shape, not values. No enum, format, bounds or pattern; an OpenAPI target can name a value set, but your code enforces it.
- Identity never comes from the model. The payload carries no caller identity; a request interceptor writes it from the validated token.
- Build confirmation as code. An inline confirmation function stops the stream before the write; a prompt instruction can be talked out of it.
- One gateway, one ceiling. Every target shares the gateway service role’s permissions, so keep it minimal and put sensitive targets behind separate gateways.