[Small Tip] Tried Calling AgentCore Gateway Inference Targets from Strands Agents

[Small Tip] Tried Calling AgentCore Gateway Inference Targets from Strands Agents

Have you ever wanted to call AgentCore Gateway inference targets from Strands Agents? I actually tried it out!
2026.10.26

This page has been translated by machine translation. View original

Introduction

Hello, I'm Kamino from the consulting department, and I love ramen.

Have you ever wanted to use AgentCore Gateway's inference targets from Strands Agents? I suddenly had that urge one day!

While Strands Agents uses the Converse API by default, inference targets don't support the Converse API, so you can't use them as-is. But if you've consolidated your LLMs into the Gateway, you'd want to call AI agents you build from there too, right...!

So this time, as a small tip, I'll try calling from AnthropicModel and OpenAIResponsesModel.

By the way, content related to inference targets was introduced in previous articles, so please refer to them as needed!

https://dev.classmethod.jp/articles/agentcore-gateway-inference-target/

https://dev.classmethod.jp/articles/agentcore-gateway-model-fallback/

Let's get started!

Configuration

The configuration this time looks like this.

Strands Agents' AnthropicModel and OpenAIResponsesModel send requests with SigV4 signatures to the Gateway's inference endpoint, and the Gateway calls with a service role. Claude Haiku 5.5 runs on Bedrock Runtime, GPT-5.6 Luna runs on Bedrock Mantle. BedrockModel using Converse cannot go through the Gateway

We inject SigV4 signing processing into the HTTP client used by Strands' model classes and send it to the Gateway's inference endpoint. The Gateway is configured with two targets, Bedrock Runtime and Bedrock Mantle, and calls them with a service role.

Haiku 5.5 is called from the Runtime target, and GPT-5.6 Luna from the Mantle target.

The official documentation also states that inference targets can be used directly from the OpenAI SDK and Anthropic SDK. Since these two model classes in Strands use each SDK internally, it should work as long as we align the connection destination and authentication!

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-targets-inference.html

Prerequisites

I tried this in the following environment.

Item Content
Region us-east-1 (US East - N. Virginia)
Python / Package Management 3.13 / uv
strands-agents 1.62.0
anthropic / openai 0.125.0 / 2.54.0
boto3 1.43.118
Gateway AgentCore Gateway (AWS_IAM authentication)
Target Inference connector target (Bedrock Runtime, Bedrock Mantle)
Models Claude Haiku 5.5 (us.anthropic.claude-haiku-5-5), GPT-5.6 Luna (openai.gpt-5.6-luna)

Let's set up the verification environment in advance!

The IAM user or role executing the script should be permitted bedrock-agentcore:InvokeGateway on the target Gateway.

Preparation of the verification environment

Create a working project.

Environment setup
uv init --python 3.13 strands-gw-inference
cd strands-gw-inference
uv add 'strands-agents[anthropic,openai]==1.62.0' 'boto3>=1.43.118' httpx

Attach Bedrock and Mantle invocation permissions to the Gateway's service role. The Bedrock Runtime connector also uses bedrock:List* to retrieve the model list.

setup_iam.sh
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

aws iam create-role \
  --role-name strands-gw-inference-role \
  --assume-role-policy-document "{
    \"Version\": \"2012-10-17\",
    \"Statement\": [{
      \"Effect\": \"Allow\",
      \"Principal\": {\"Service\": \"bedrock-agentcore.amazonaws.com\"},
      \"Action\": \"sts:AssumeRole\",
      \"Condition\": {\"StringEquals\": {\"aws:SourceAccount\": \"${ACCOUNT_ID}\"}}
    }]
  }"

aws iam put-role-policy \
  --role-name strands-gw-inference-role \
  --policy-name inference \
  --policy-document "{
    \"Version\": \"2012-10-17\",
    \"Statement\": [
      {
        \"Effect\": \"Allow\",
        \"Action\": [\"bedrock:InvokeModel\", \"bedrock:InvokeModelWithResponseStream\", \"bedrock:List*\"],
        \"Resource\": \"*\"
      },
      {
        \"Effect\": \"Allow\",
        \"Action\": \"bedrock-mantle:*\",
        \"Resource\": \"arn:aws:bedrock-mantle:*:${ACCOUNT_ID}:project/*\"
      }
    ]
  }"

Create the Gateway and connector targets for Bedrock Runtime and Bedrock Mantle. Use the URL displayed at the end in subsequent scripts.

setup.py
import time

import boto3

account_id = boto3.client("sts").get_caller_identity()["Account"]
client = boto3.client("bedrock-agentcore-control", region_name="us-east-1")

gateway = client.create_gateway(
    name="strands-inference-gateway",
    roleArn=f"arn:aws:iam::{account_id}:role/strands-gw-inference-role",
    protocolType="MCP",
    authorizerType="AWS_IAM",
)
gateway_id = gateway["gatewayId"]
while client.get_gateway(gatewayIdentifier=gateway_id)["status"] != "READY":
    time.sleep(5)

# Configure connector targets for Bedrock Runtime and Bedrock Mantle
for name in ("bedrock-runtime", "bedrock-mantle"):
    target = client.create_gateway_target(
        gatewayIdentifier=gateway_id,
        name=name,
        credentialProviderConfigurations=[{"credentialProviderType": "GATEWAY_IAM_ROLE"}],
        targetConfiguration={"inference": {"connector": {"source": {"connectorId": name}}}},
    )
    while (
        client.get_gateway_target(gatewayIdentifier=gateway_id, targetId=target["targetId"])["status"]
        != "READY"
    ):
        time.sleep(5)

print(gateway["gatewayUrl"].removesuffix("/mcp"))
Execution
bash setup_iam.sh
uv run setup.py
export GATEWAY_URL=<URL displayed by setup.py>

The environment is ready, so let's try it!

HTTP Client That Attaches SigV4 Signatures

Both the Anthropic SDK and OpenAI SDK communicate via httpx, so we'll create signing processing as httpx Auth. We'll also prepare a KeepOpenAsyncClient that doesn't close the HTTP client even when the SDK's client is closed, and use this to create the signed HTTP client.

I'll explain the reason for not closing later!

sigv4_auth.py
import boto3
import httpx
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

class GatewaySigV4Auth(httpx.Auth):
    """httpx Auth that attaches SigV4 signatures to requests to the Gateway"""

    requires_request_body = True

    def __init__(self, region: str = "us-east-1"):
        self.region = region
        self.session = boto3.Session()

    def auth_flow(self, request: httpx.Request):
        # Remove headers for API keys added by the SDK since they're not used by the Gateway
        for name in ("authorization", "x-api-key"):
            request.headers.pop(name, None)

        aws_request = AWSRequest(
            method=request.method,
            url=str(request.url),
            data=request.content,
            headers={"content-type": request.headers.get("content-type", "application/json")},
        )
        credentials = self.session.get_credentials().get_frozen_credentials()
        SigV4Auth(credentials, "bedrock-agentcore", self.region).add_auth(aws_request)
        request.headers.update(dict(aws_request.headers))
        yield request

class KeepOpenAsyncClient(httpx.AsyncClient):
    """Continues to use the HTTP client without closing it even when the SDK's client is closed"""

    async def aclose(self) -> None:
        # Do nothing even if the SDK tries to close
        pass

    async def close_for_real(self) -> None:
        # Call this yourself when you're done to close
        await super().aclose()

def signed_http_client() -> KeepOpenAsyncClient:
    return KeepOpenAsyncClient(auth=GatewaySigV4Auth())

Since both SDKs will error if no API key is specified, we pass a dummy value. The Anthropic SDK sends this dummy value in the x-api-key header, and the OpenAI SDK puts it in the Authorization header.

Since the Gateway authentication uses the Authorization header with the SigV4 signature, we remove these headers added by the SDK before signing.

Let's try calling from AnthropicModel first!

Calling from AnthropicModel

For the agent, let's give it a dummy tool that returns weather and assume we're asking about the weather.

tools.py
from strands import tool

@tool
def get_weather(city: str) -> str:
    """Returns the weather for the specified city"""
    return f"{city} is sunny with a temperature of 18 degrees"

For AnthropicModel, pass the connection destination and signed HTTP client to client_args. Since the Anthropic SDK appends /v1/messages after base_url, specify base_url up to /inference in the Gateway.

Haiku 5.5 is called with us.anthropic.claude-haiku-5-5.

agent_claude.py
import os

from strands import Agent
from strands.models.anthropic import AnthropicModel

from sigv4_auth import signed_http_client
from tools import get_weather

GATEWAY_URL = os.environ["GATEWAY_URL"]

model = AnthropicModel(
    client_args={
        "base_url": f"{GATEWAY_URL}/inference",
        "api_key": "unused",
        "http_client": signed_http_client(),
    },
    model_id="us.anthropic.claude-haiku-5-5",
    max_tokens=1024,
)

agent = Agent(model=model, tools=[get_weather])
agent("Tell me the weather in Tokyo and Osaka")
Execution
uv run agent_claude.py
Execution result
Tool #1: get_weather

Tool #2: get_weather
The weather in both Tokyo and Osaka is sunny with a temperature of 18 degrees.

It called the tool twice and then responded! Since Strands receives responses via streaming, streaming also works through the Gateway!

Next, let's try an OpenAI model too!

Calling from OpenAIResponsesModel

GPT-5.6 Luna is called using the Responses API.

In Strands, we use OpenAIResponsesModel. Since the OpenAI SDK appends /responses after base_url, set base_url to /inference/v1.

agent_gpt.py
import asyncio
import os

from strands import Agent
from strands.models.openai_responses import OpenAIResponsesModel

from sigv4_auth import signed_http_client
from tools import get_weather

GATEWAY_URL = os.environ["GATEWAY_URL"]

async def main():
    http_client = signed_http_client()
    model = OpenAIResponsesModel(
        client_args={
            "base_url": f"{GATEWAY_URL}/inference/v1",
            "api_key": "unused",
            "http_client": http_client,
        },
        model_id="openai.gpt-5.6-luna",
    )
    agent = Agent(model=model, tools=[get_weather])
    try:
        await agent.invoke_async("Tell me the weather in Tokyo and Osaka")
    finally:
        # Close the HTTP client when finished with the agent
        await http_client.close_for_real()

asyncio.run(main())
Execution
uv run agent_gpt.py
Execution result
Tool #1: get_weather

Tool #2: get_weather
- Tokyo: Sunny, 18°C
- Osaka: Sunny, 18°C

This one also called the tool twice and responded!

Supplementary Note: Why Not Close the HTTP Client

Let me dig a little deeper into why it's not being closed here.

If you pass a regular httpx.AsyncClient with just the signature attached, it fails...

agent_gpt_closed.py
agent_gpt_closed.py
import os

import httpx
from strands import Agent
from strands.models.openai_responses import OpenAIResponsesModel

from sigv4_auth import GatewaySigV4Auth
from tools import get_weather

GATEWAY_URL = os.environ["GATEWAY_URL"]

# For comparison: pass a regular httpx.AsyncClient with just the signature attached
model = OpenAIResponsesModel(
    client_args={
        "base_url": f"{GATEWAY_URL}/inference/v1",
        "api_key": "unused",
        "http_client": httpx.AsyncClient(auth=GatewaySigV4Auth()),
    },
    model_id="openai.gpt-5.6-luna",
)

agent = Agent(model=model, tools=[get_weather])
agent("Tell me the weather in Tokyo and Osaka")
Execution result (excerpt)
Tool #1: get_weather
Tool #2: get_weather
    raise RuntimeError("Cannot send a request, as the client has been closed.")
RuntimeError: Cannot send a request, as the client has been closed.

It progressed to calling the tools, but the second request to return the tool results errored with "client has been closed."

The cause lies in how OpenAIResponsesModel handles client_args. In Strands Agents 1.62.0 used this time, client_args creates an OpenAI client for each request and closes it when done.

strands/models/openai_responses.py (excerpt)
async with openai.AsyncOpenAI(**self._resolve_client_args()) as client:
    try:
        response = await client.responses.create(**request)

A new OpenAI client is created each time, but the HTTP client placed in client_args is the same one passed each time. When the OpenAI client is closed, the HTTP client inside it is also closed, so the second request was failing because a closed HTTP client was being passed.

When a regular HTTP client is passed to OpenAIResponsesModel, it's closed after the first request, and the second request to return tool results fails. When an HTTP client that does nothing in aclose() is passed, it's not closed and the second request also succeeds

Since OpenAIResponsesModel has no argument to pass an OpenAI client directly, I made the HTTP client not close!

KeepOpenAsyncClient does nothing in aclose(), so the same HTTP client can continue to be used for the second and subsequent requests. Instead, since it can no longer be closed via the SDK, I separately prepared close_for_real() for closing at the end.

Note that AnthropicModel creates a client once at model creation and continues using it, so it works even without the non-closing design.

When Using Multiple Models Together

If you're consolidating models in the Gateway, you'd want to consolidate the way you write things on the Strands side too...!!

Since we have two targets, Runtime and Mantle, this time I tried to figure out the best way to organize them to make use of both.

In Strands, prepare a function to create models for each format, with the signature and Gateway entry point in common. The agent calls by logical name. In the Gateway rules, chat-claude is sent to Bedrock Runtime's Haiku 5.5; if that fails, it switches to Mantle's Haiku 4.5. chat-gpt is sent to Mantle's GPT-5.6 Luna; if that fails, it switches to Runtime's Luna

Prepare Functions to Create Models for Each Format

Since Claude uses the Anthropic Messages format and GPT-5.6 Luna uses the Responses format, I separated the functions that create models on the Strands side by format. Since the Gateway doesn't convert the request body format, you need to call in the format appropriate for the model.

gateway_models.py
import os

from strands.models.anthropic import AnthropicModel
from strands.models.openai_responses import OpenAIResponsesModel

from sigv4_auth import signed_http_client

GATEWAY_URL = os.environ["GATEWAY_URL"]

def claude_model(model_id: str = "chat-claude") -> AnthropicModel:
    """Model called in Anthropic Messages format"""
    return AnthropicModel(
        client_args={"base_url": f"{GATEWAY_URL}/inference", "api_key": "unused", "http_client": signed_http_client()},
        model_id=model_id,
        max_tokens=1024,
    )

def gpt_model(model_id: str = "chat-gpt") -> OpenAIResponsesModel:
    """Model called in OpenAI Responses format"""
    return OpenAIResponsesModel(
        client_args={"base_url": f"{GATEWAY_URL}/inference/v1", "api_key": "unused", "http_client": signed_http_client()},
        model_id=model_id,
    )

The functions are designed to pass the logical names of the rules I'll explain next.

Leave Logical Names and Fallback to Gateway Rules

By designing things so that agent code uses logical names like chat-claude instead of specific model IDs, you can make changes to destinations only on the Gateway side.

This time, I created two logical names for the Claude and GPT families, and set up switching destinations for each. The switching destinations are models that can be called in the same format.

  • chat-claude sends to Runtime's Haiku 5.5; if that fails, switches to Mantle's Haiku 4.5
  • chat-gpt sends to Mantle's Luna; if that fails, switches to Runtime's Luna

Make the time to wait for the first response (firstByteTimeoutMilliseconds) configurable via argument.

setup_rules.py
import os
import sys
import time

import boto3

client = boto3.client("bedrock-agentcore-control", region_name="us-east-1")
gateway_id = os.environ["GATEWAY_URL"].removeprefix("https://").split(".")[0]
timeout_ms = int(sys.argv[1]) if len(sys.argv) > 1 else 30000

# Determine primary and fallback destinations (models that can be called in the same format) for each logical name
RULES = {
    "chat-claude": (
        {"targetName": "bedrock-runtime", "model": "us.anthropic.claude-haiku-5-5"},
        {"targetName": "bedrock-mantle", "model": "anthropic.claude-haiku-4-5"},
    ),
    "chat-gpt": (
        {"targetName": "bedrock-mantle", "model": "openai.gpt-5.6-luna"},
        {"targetName": "bedrock-runtime", "model": "us.openai.gpt-5.6-luna"},
    ),
}

existing = {
    r["conditions"][0]["matchModels"]["anyOf"][0]["value"]: r["ruleId"]
    for r in client.list_gateway_rules(gatewayIdentifier=gateway_id)["gatewayRules"]
}

for priority, (name, (primary, secondary)) in enumerate(RULES.items(), start=10):
    params = dict(
        gatewayIdentifier=gateway_id,
        priority=priority,
        conditions=[{"matchModels": {"anyOf": [{"value": name, "operator": "StringEquals"}]}}],
        actions=[{
            "routeToTarget": {
                "staticRoute": {
                    **primary,
                    "fallback": {"firstByteTimeoutMilliseconds": timeout_ms, "targetFallback": [secondary]},
                }
            }
        }],
    )
    if name in existing:
        rule_id = existing[name]
        client.update_gateway_rule(ruleId=rule_id, **params)
    else:
        rule_id = client.create_gateway_rule(**params)["ruleId"]
    while client.get_gateway_rule(gatewayIdentifier=gateway_id, ruleId=rule_id)["status"] != "ACTIVE":
        time.sleep(3)
    print(name, "ACTIVE", timeout_ms)

By checking the model in the response, you can see which target's model answered. I set the wait time to 50 milliseconds to intentionally cause the primary side to time out and confirmed the switching.

check_rules.py
check_rules.py
import asyncio
import os

from anthropic import AsyncAnthropic
from openai import AsyncOpenAI

from sigv4_auth import signed_http_client

URL = os.environ["GATEWAY_URL"]
anthropic = AsyncAnthropic(base_url=f"{URL}/inference", api_key="unused", http_client=signed_http_client())
openai = AsyncOpenAI(base_url=f"{URL}/inference/v1", api_key="unused", http_client=signed_http_client())

async def main():
    # Checking the model in the response shows which target's model answered
    r = await anthropic.messages.create(model="chat-claude", max_tokens=50, messages=[{"role": "user", "content": "Hello"}])
    print("chat-claude ->", r.model)
    r = await openai.responses.create(model="chat-gpt", input="Hello")
    print("chat-gpt    ->", r.model)

asyncio.run(main())
Execution
uv run setup_rules.py && uv run check_rules.py
uv run setup_rules.py 50 && uv run check_rules.py
Execution result
chat-claude ACTIVE 30000
chat-gpt ACTIVE 30000
chat-claude -> claude-haiku-5-5
chat-gpt    -> openai.gpt-5.6-luna
chat-claude ACTIVE 50
chat-gpt ACTIVE 50
chat-claude -> claude-haiku-4-5-20251001
chat-gpt    -> us.openai.gpt-5.6-luna

Normally, Runtime's Haiku 5.5 and Mantle's Luna answer, and at 50 milliseconds it switched to Mantle's Haiku 4.5 and Runtime's Luna! The fallback is also active on the Responses API route.

Let's also run an agent with a logical name.

agent_logical.py
from strands import Agent

from gateway_models import claude_model, gpt_model
from tools import get_weather

# Pass only logical names to agents
for name, model in (("chat-claude", claude_model()), ("chat-gpt", gpt_model())):
    agent = Agent(model=model, tools=[get_weather], callback_handler=None)
    result = agent("Tell me the weather in Tokyo and Osaka")
    calls = result.metrics.tool_metrics["get_weather"].call_count
    print(f"== {name} (tool calls: {calls})")
    print(str(result).strip(), "\n")
Execution
uv run setup_rules.py && uv run agent_logical.py
Execution result (excerpt)
== chat-claude (tool calls: 2)
The weather in both Tokyo and Osaka is sunny with a temperature of 18 degrees.

== chat-gpt (tool calls: 2)
- Tokyo: Sunny, 18°C
- Osaka: Sunny, 18°C

Let's also run it with the wait time set to 50 milliseconds, in a state where it has switched to the fallback.

Execution
uv run setup_rules.py 50 && uv run agent_logical.py
Execution result (excerpt)
== chat-claude (tool calls: 2)
Here is the weather information for Tokyo and Osaka:
**Tokyo**
- Weather: Sunny
- Temperature: 18 degrees
**Osaka**
- Weather: Sunny
- Temperature: 18 degrees
Both cities are sunny with the same temperature of 18 degrees. It seems like comfortable weather continues.

== chat-gpt (tool calls: 2)
- Tokyo: Sunny, 18°C
- Osaka: Sunny, 18°C

Both called the tool twice and responded! It's great that the fallback was working fine and we could decide the destination and switching in the Gateway without changing anything on the agent side.

If you're using multiple models from Strands through the Gateway, it seems good to keep these patterns in mind...!

  • Unify authentication and the Gateway entry point, and prepare model creation functions for each API format
  • Pass logical names to agents, and determine destinations and fallbacks in Gateway rules
  • Make fallback destinations models that can be called in the same format

This is just one approach, so depending on how you use it, there might be even better ways to organize things, and I think it would be fun to experiment!

Cleanup

Delete the rules, targets, Gateway, and IAM role.

cleanup.py
import os
import time

import boto3

client = boto3.client("bedrock-agentcore-control", region_name="us-east-1")
gateway_id = os.environ["GATEWAY_URL"].removeprefix("https://").split(".")[0]

for rule in client.list_gateway_rules(gatewayIdentifier=gateway_id)["gatewayRules"]:
    client.delete_gateway_rule(gatewayIdentifier=gateway_id, ruleId=rule["ruleId"])
for target in client.list_gateway_targets(gatewayIdentifier=gateway_id)["items"]:
    client.delete_gateway_target(gatewayIdentifier=gateway_id, targetId=target["targetId"])
while client.list_gateway_targets(gatewayIdentifier=gateway_id)["items"]:
    time.sleep(5)
client.delete_gateway(gatewayIdentifier=gateway_id)
print("deleted", gateway_id)
Execution
uv run cleanup.py
aws iam delete-role-policy --role-name strands-gw-inference-role --policy-name inference
aws iam delete-role --role-name strands-gw-inference-role

Bonus: JWT Authentication Case

I also tried using JWT authentication for the Gateway inbound. I use an access token issued by Cognito machine-to-machine authentication (client credentials). The inference target configuration is the same as with IAM.

With IAM authentication, Strands HTTP client sends requests with SigV4 signature, and with JWT authentication, the token obtained from Cognito is sent in the Authorization Bearer header. The Gateway verifies the token in the Bearer header

Preparing the verification environment (JWT)

The IAM role uses the one created with setup_iam.sh in the main section. If you deleted it during cleanup, please recreate it. Create a Cognito user pool and a JWT-authenticated Gateway, and save the connection information to jwt_env.json.

setup_jwt.py
import json
import time
import uuid

import boto3

region = "us-east-1"
account_id = boto3.client("sts").get_caller_identity()["Account"]
cognito = boto3.client("cognito-idp", region_name=region)
client = boto3.client("bedrock-agentcore-control", region_name=region)

# Cognito: configuration to issue tokens with client credentials
pool = cognito.create_user_pool(PoolName="strands-gw-jwt-pool")["UserPool"]
pool_id = pool["Id"]
domain = f"strands-gw-jwt-{uuid.uuid4().hex[:8]}"
cognito.create_user_pool_domain(Domain=domain, UserPoolId=pool_id)
cognito.create_resource_server(
    UserPoolId=pool_id, Identifier="gateway", Name="gateway",
    Scopes=[{"ScopeName": "invoke", "ScopeDescription": "invoke gateway"}],
)
app = cognito.create_user_pool_client(
    UserPoolId=pool_id, ClientName="strands-agent", GenerateSecret=True,
    AllowedOAuthFlows=["client_credentials"], AllowedOAuthScopes=["gateway/invoke"],
    AllowedOAuthFlowsUserPoolClient=True,
)["UserPoolClient"]

gateway = client.create_gateway(
    name="strands-inference-gateway-jwt",
    roleArn=f"arn:aws:iam::{account_id}:role/strands-gw-inference-role",
    protocolType="MCP",
    authorizerType="CUSTOM_JWT",
    authorizerConfiguration={
        "customJWTAuthorizer": {
            "discoveryUrl": f"https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration",
            "allowedClients": [app["ClientId"]],
        }
    },
)
gateway_id = gateway["gatewayId"]
while client.get_gateway(gatewayIdentifier=gateway_id)["status"] != "READY":
    time.sleep(5)

for name in ("bedrock-runtime", "bedrock-mantle"):
    target = client.create_gateway_target(
        gatewayIdentifier=gateway_id,
        name=name,
        credentialProviderConfigurations=[{"credentialProviderType": "GATEWAY_IAM_ROLE"}],
        targetConfiguration={"inference": {"connector": {"source": {"connectorId": name}}}},
    )
    while client.get_gateway_target(gatewayIdentifier=gateway_id, targetId=target["targetId"])["status"] != "READY":
        time.sleep(5)

json.dump({
    "gateway_url": gateway["gatewayUrl"].removesuffix("/mcp"),
    "gateway_id": gateway_id,
    "pool_id": pool_id,
    "domain": domain,
    "client_id": app["ClientId"],
    "client_secret": app["ClientSecret"],
}, open("jwt_env.json", "w"))
print("ok", gateway_id)
Run
uv run setup_jwt.py

Here is the function to obtain a token.

jwt_token.py
import json

import httpx

env = json.load(open("jwt_env.json"))

def get_token() -> str:
    res = httpx.post(
        f"https://{env['domain']}.auth.us-east-1.amazoncognito.com/oauth2/token",
        data={"grant_type": "client_credentials", "scope": "gateway/invoke"},
        auth=(env["client_id"], env["client_secret"]),
    )
    res.raise_for_status()
    return res.json()["access_token"]

GATEWAY_URL = env["gateway_url"]

The Gateway's JWT authentication uses the token in the Authorization: Bearer header.

The Anthropic SDK sends api_key in the x-api-key header, so the token should be passed to auth_token. The OpenAI SDK sends api_key as Bearer, so it can be passed directly to api_key.

agent_claude_jwt.py
from strands import Agent
from strands.models.anthropic import AnthropicModel

from jwt_token import GATEWAY_URL, get_token
from tools import get_weather

model = AnthropicModel(
    client_args={
        "base_url": f"{GATEWAY_URL}/inference",
        "auth_token": get_token(),  # Sent as Authorization: Bearer
    },
    model_id="us.anthropic.claude-haiku-5-5",
    max_tokens=1024,
)

agent = Agent(model=model, tools=[get_weather])
agent("Tell me the weather in Tokyo and Osaka")
agent_gpt_jwt.py
from strands import Agent
from strands.models.openai_responses import OpenAIResponsesModel

from jwt_token import GATEWAY_URL, get_token
from tools import get_weather

model = OpenAIResponsesModel(
    client_args={
        "base_url": f"{GATEWAY_URL}/inference/v1",
        "api_key": get_token(),  # Sent as Authorization: Bearer
    },
    model_id="openai.gpt-5.6-luna",
)

agent = Agent(model=model, tools=[get_weather])
agent("Tell me the weather in Tokyo and Osaka")
Run
uv run agent_claude_jwt.py
uv run agent_gpt_jwt.py

Both called the tool twice and proceeded to the answer! Since there is no need to pass the HTTP client yourself, OpenAIResponsesModel also works without an unclosed HTTP client.

Here is a rough summary of the differences between IAM authentication and JWT authentication.

IAM Authentication JWT Authentication
Strands-side code SigV4-signed httpx Auth required Just pass the token
AnthropicModel http_client in client_args auth_token in client_args
OpenAIResponsesModel Pass an unclosed HTTP client Works as-is with client_args
Additional requirements AWS credentials and execution role suffice IdP and token retrieval processing

JWT is simpler!

However, since the token is only passed once at model creation time, for long-running agents you will need to consider processes such as recreating it before the expiration (Cognito access tokens expire after 1 hour by default).

Cleanup (JWT)
cleanup_jwt.py
import json
import time

import boto3

env = json.load(open("jwt_env.json"))
client = boto3.client("bedrock-agentcore-control", region_name="us-east-1")
gateway_id = env["gateway_id"]
for target in client.list_gateway_targets(gatewayIdentifier=gateway_id)["items"]:
    client.delete_gateway_target(gatewayIdentifier=gateway_id, targetId=target["targetId"])
while client.list_gateway_targets(gatewayIdentifier=gateway_id)["items"]:
    time.sleep(5)
client.delete_gateway(gatewayIdentifier=gateway_id)

cognito = boto3.client("cognito-idp", region_name="us-east-1")
cognito.delete_user_pool_domain(Domain=env["domain"], UserPoolId=env["pool_id"])
cognito.delete_user_pool(UserPoolId=env["pool_id"])
Run
uv run cleanup_jwt.py
rm jwt_env.json
aws iam delete-role-policy --role-name strands-gw-inference-role --policy-name inference
aws iam delete-role --role-name strands-gw-inference-role

Conclusion

I was successfully able to call the AgentCore Gateway inference target from Strands Agents! There are some quirks, but it's manageable... I think native integration in this area would make it much easier to use!

Also, when consolidating multiple models, my impression was that it would be easier to handle by separating the function for creating models by API format and routing them with Gateway rules!

I hope this article is helpful in some way. Thank you very much for reading to the end!

Share this article

AWSのお困り事はクラスメソッドへ