Exam Room · Advanced GenAI

Designing Safe Tool Schemas for an AgentCore Gateway

July 28, 2026 · 40 min read

Generative AI Development · part of The Exam Room

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, because the model chose them, and the model can be pushed around by whatever text landed in the conversation.

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 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 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 carry a full schema language, with enumerated values, formats, and numeric bounds that reject a bad call before your code runs. 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 decides how much validation has to live at the tool rather than in the contract. Check which you have rather than assuming. 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, decides 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 a model can be talked into supplying a different one.

What we’ll filter on

  1. Blast radius: what is the worst a single call to this tool can do, and can it be undone?
  2. Single-purpose: does the tool do one nameable thing, or take a free-form instruction?
  3. Constrained by the contract: how much can the schema rule out before the call reaches your code?
  4. Re-validated at the target: does the executor re-check every argument, including ownership against an identity the model did not supply?
  5. Least privilege: do execution and the outbound credential scope to this tool’s job alone?
  6. 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 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 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 and, in practice, improves accuracy because each tool does one thing. The old objection was that a long tool list bloats the prompt; the gateway answers it with semantic tool selection, which lets the agent search 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: a name, a description, a required inputSchema, and an optional outputSchema. The schema objects accept 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 the model inventing one.

{
  "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 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 say 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 that includes the constraints the Lambda tool definition cannot express: an enum of the four refund reasons the business accepts, a default, nested objects, arrays with item schemas.

The compositions are the gap. oneOf, anyOf, and allOf are not supported, complex parameter serialisation is not supported, and application/json is the content type to stay on.

Where a tool has values worth pinning down, this is the attachment that pins 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, or a transformedGatewayResponse, which short-circuits: the gateway replies with that content and the target is never invoked.

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, which is exactly the kind of value you 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. HTTP-shaped targets have the full range instead: an OpenAPI or MCP-server target can be configured through AgentCore Identity with two-legged client credentials, three-legged authorisation code, an API key, or on-behalf-of token exchange, where the inbound user token is swapped for a scoped token addressed to the downstream service carrying both the user’s identity and the agent’s. That difference decides where per-subscriber authorisation can be enforced, so it belongs in the design rather than in the wiring at the end.

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 read-only tool and a refund tool sharing one gateway share one ceiling.

A confirmation the code enforces

Classic had a per-function requireConfirmation flag, and it is not what you configure now. The managed harness takes inline function tools, which are tools that execute in your client code rather than on the gateway, and a confirmation gate is one of them: you write the function’s description as the confirmation policy. When the model decides the gate applies, the harness emits a toolUse for the inline function 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 with the tool-use response carrying the answer. The gate moved from a checkbox to a few lines you own, which means it holds only if you build it as a structural property of the flow rather than an instruction in the prompt.

Evaluation

Side by side

Design Blast radius contained Single-purpose Constrained by 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 constraining, 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

One tool call, five gates, a shrinking blast radius Model-chosen arguments untrusted, prompt-injectable Tool schema wrong types out; enums only on an OpenAPI target Request interceptor identity from the token; refuses the call outright Checks at the target bounds, allow-lists, ownership, already-refunded Least privilege execution role and outbound credential reach this tool only Confirmation inline function; the write waits for a human scoped effect Blast radius, narrowing at each gate anything the model could be talked into small, reversible Each gate assumes the ones before it failed; no single layer is trusted to hold on its own.
A manipulated call has to pass every gate. The schema rejects the malformed, the interceptor settles who is asking and can refuse, and the target rejects the out-of-bounds and the not-yours. The role and the outbound credential block anything outside the tool's remit, and the confirmation gate stops a write firing unreviewed.

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 and what semantic tool selection searches. 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 so a fifth never reaches your code. A Lambda tool definition cannot. There, the description saying “one of: damaged, missing, late, quality” is a hint to the model rather than a rule. Reach for OpenAPI where there are values worth pinning down, and keep Lambda targets for the tools whose parameters are genuinely just typed.

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 in a state that can be refunded, 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 a Lambda target; 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 needs to know 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 refuses 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 saying the refund amount exceeds the order total lets it ask the subscriber for the right figure; a silent error strands it. Legible failures are part of the safety design, because a tool that fails clearly is one the agent uses correctly on the second try instead of flailing at it.

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 rather than an oversight.

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 respond sensibly instead of insisting.

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; here the unit of design is one tool and the smallest capability it can be given.

What’s worth remembering

  1. Every tool you publish through a gateway is a capability you grant the model; design it around the worst a single call can do, not the best.
  2. The arguments are model-generated and therefore untrusted, and prompt injection rides in through them, so treat them like input from an unauthenticated source.
  3. Check what your attachment can actually express before relying on it: a Lambda tool definition carries types, descriptions, and a required list, while an OpenAPI target carries enums and the rest of the constraint vocabulary.
  4. The caller’s identity is not in the tool payload; settle it at the gateway with a request interceptor reading a validated token, and exchange that token on-behalf-of for the downstream call.
  5. Confirmation is code you own rather than a flag you set, so build it as a step the flow cannot skip instead of an instruction the model can be talked out of.

The help desk ships its reads unchanged and its writes as narrow, constrained, re-validating tools under scoped roles and scoped outbound credentials, with identity settled before the call lands, 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.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.