The AgentCore Web Search API fixes something that always bugged me about web search on Amazon Bedrock AgentCore: until now, you could only reach the search through an AgentCore Gateway, which means an MCP endpoint with its own target, its own role and its own authentication setup. For an agent that already works over MCP, that’s fine. For a single Lambda function that just needs to look something up, it always felt like too much infrastructure to me.
On November 10, 2026, AWS changed that. The new API is generally available, and you call it with a single SDK command, with no gateway at all. In this article I’ll show you what the new API can do, where its limits are, and how to build a small research agent step by step with Lambda, Bedrock Converse and AWS CDK that backs every claim with a source.
In short
With
InvokeWebSearch, your own code gets search results with a text extract, URL and date through a plain IAM call, without MCP and without a gateway. For custom agent loops and data pipelines, that’s the simpler path. The biggest stumbling block is mundane: the Lambda runtime ships an older SDK version, so you need to bundle the SDK yourself.
What you will learn in this article
- What the AgentCore Web Search API returns and what it costs
- When to use the API and when to use the Gateway connector
- An agent loop with Bedrock Converse, a search budget and a source list
- A CDK stack with minimal IAM permissions for web search
- The typical mistakes on your first call and how to avoid them
What is the AgentCore Web Search API?
The AgentCore Web Search API is a single call named InvokeWebSearch on the AgentCore data plane endpoint. You send a search query and get back a list of hits sorted by relevance. Each hit contains a text extract from the page and, where known, the page title, the URL and the publication time. Behind it sits a web index that Amazon runs itself. It’s the same index you could already use through the web search tool in the gateway.
For day-to-day work, the surroundings matter more than the search itself. You don’t have to create anything: no gateway, no target, no resource. Permissions run entirely through IAM, so through the same roles your Lambda function uses for DynamoDB or S3. You can also call the API from your VPC via AWS PrivateLink and record the calls as CloudTrail data events. For teams that have to answer compliance questions, that’s more than a detail.
Two filters make the API really useful for agents. With the domain filter, you allow up to 100 domains that results may come from, or exclude up to 100 domains. A root domain includes its subdomains, so aws.amazon.com also covers docs.aws.amazon.com. With the date filter, you limit hits to a publication window, for example the last 30 days.
AgentCore Web Search API or Gateway connector?
The first question I asked myself: do I even need the gateway anymore? The answer depends on who consumes the search results. If your own code calls the search, say a hand-written agent loop or a batch job, the API is the more direct route. If your agents discover their tools over MCP, the Gateway connector remains the right choice. These are the main differences according to the AWS documentation:
| Aspect | InvokeWebSearch API | Web search connector in the gateway |
|---|---|---|
| Call | Your code via AWS SDK or CLI | Agent calls an MCP tool through the gateway |
| Authentication | IAM only (SigV4) | IAM or JSON Web Token (JWT) |
| Domain lists | Per request | Also fixed on the target, invisible to the agent |
| Search text | Field searchQuery | Field query |
| Default number of hits | up to 25 | up to 10 |
| Time range | publishedAfter / publishedBefore | from / to |
| Cost | $7 per 1,000 queries | $7 per 1,000 queries plus gateway charges |
One point from the table deserves a second look. With the gateway, you can attach domain lists to the target, and the agent never sees them. With the API, the filter travels in every single request. So if a language model chooses the parameters, the domain list belongs in your code, not in the tool definition the model fills in. That’s exactly how I build it below.
No gateway: the Lambda function calls InvokeWebSearch directly and returns the answer and its sources.
If you’d rather get a finished answer with citations instead of a list of hits, take a look at my article on AgentCore Web Answer. And if the model should search on its own without you writing a loop, Bedrock Web Search with a domain filter is an alternative. The new API is the lowest-level building block of this family: raw hits, full control.
What does the AgentCore Web Search API cost?
AWS charges $7 per 1,000 search queries, so 0.7 cents per call. That’s the same price as through the gateway, just without the gateway charges on top. It sounds like little, but it adds up faster than you’d think, because an agent often searches several times per question.
Let’s run an example. An internal research assistant answers 400 questions a day and searches twice per question on average. That’s 800 queries a day, so $5.60 per day or roughly $168 over 30 days, just for search. On top of that come the tokens for the language model, which are often the bigger item because of the long text extracts in the context. Lambda costs hardly matter next to that; you can estimate them quickly with my Lambda cost calculator.
That’s why my most important advice is this: give the agent a fixed search budget per question. In the sample code it’s three searches. Once the budget is used up, the model gets an error message as the tool result and has to answer with the sources it already has. That way, an endless loop can never cost more than 2.1 cents in search fees per question.
What are the limits of the AgentCore Web Search API?
Before you get started, you should know the ground rules. Some of them have direct consequences for your architecture.
No Frankfurt region yet
The API is available in US East (N. Virginia and Ohio), US West (Oregon), Sydney, Tokyo and Ireland. Frankfurt is missing at launch. If you want to stay in Europe, you end up in eu-west-1. If the rest of your application runs in eu-central-1, you can still simply create the client with the Ireland region. Check first, though, whether that fits your data residency rules.
Quotas and field limits
By default, 10 requests per second per account and region are allowed. You request an increase through AWS Support. The search query must be 1 to 200 characters long, and maxResults ranges from 1 to 25. There is no pagination: you get at most maxResults hits in one response, sometimes fewer. The API answers unknown fields in the request with a ValidationException.
Showing sources is mandatory
The terms of use for the web search tool also apply to the API. They include keeping and displaying the source attributions and links that come with the results. An agent that condenses search results into a nice answer and drops the sources is therefore doing more than ignoring good practice. That’s why the function in the example always returns a numbered source list.
No personal data in search queries
The search query leaves your application and goes to a search index. If a model writes queries freely from user input, names or customer numbers end up there quickly. I would explicitly forbid personal data in search queries in the system prompt and spot-check the queries in the logs.
Build a research agent with Lambda and CDK step by step
Now it gets practical. The example is the fictional online bookshop “Seitenwind”. Its development team wants a small research assistant that answers questions like “Does Lambda support Node.js 24 yet?” and only uses vendor sites such as aws.amazon.com, nodejs.org or typescriptlang.org. This time I build the infrastructure with AWS CDK; I showed the gateway route in the Web Answer article with the Serverless Framework.
Prerequisites
- An AWS account and configured credentials for the AWS CLI
- Node.js 22 or newer and npm
- A bootstrapped CDK in
eu-west-1(npx cdk bootstrap) - Access to an Anthropic model in Bedrock, in this example Claude Haiku 4.5 via the EU inference profile. Check in the Bedrock console that the model is enabled for your account.
Step 1: Create the project
Create an empty directory and install the two SDK clients plus the tools for CDK and tests. A current version of @aws-sdk/client-bedrock-agentcore matters, because older versions don’t know InvokeWebSearch yet.
mkdir seitenwind-research && cd seitenwind-research npm init -y npm install @aws-sdk/client-bedrock-agentcore @aws-sdk/client-bedrock-runtime npm install -D aws-cdk-lib constructs aws-cdk typescript @types/node esbuild tsx vitest aws-sdk-client-mock mkdir src lib bin test You also need a tsconfig.json with "strict": true and a cdk.json with {"app": "npx tsx bin/app.ts"}. You’ll find both files in full in the example repository; the link is at the end of the article.
Step 2: Search client with validation
The first building block wraps the API call. It checks the API limits before a request goes out and normalizes domains. That’s necessary because models and humans like to write https://docs.aws.amazon.com/lambda/ where the API expects a bare domain. It also shortens the text extracts so the model’s context doesn’t explode.
// src/web-search.ts import { BedrockAgentCoreClient, InvokeWebSearchCommand, type InvokeWebSearchCommandInput, } from "@aws-sdk/client-bedrock-agentcore"; // The AWS managed search tool. There is currently no other ID. export const WEB_SEARCH_ID = "web-search.v1"; export interface SearchOptions { query: string; maxResults?: number; includeDomains?: string[]; excludeDomains?: string[]; publishedAfter?: Date; publishedBefore?: Date; } export interface Source { title: string; url?: string; publishedAt?: string; excerpt: string; } // The API expects a bare domain: no scheme, no path, no port. export function normalizeDomain(value: string): string { const host = value .trim() .toLowerCase() .replace(/^[a-z][a-z0-9+.-]*:\/\//, "") .split(/[/?#]/)[0] .replace(/:\d+$/, ""); if (host.length > 253 || !/^([a-z0-9-]+\.)+[a-z]{2,}$/.test(host)) { throw new Error(`Invalid domain: ${value}`); } return host; } function domainList(values?: string[]): string[] | undefined { if (!values || values.length === 0) return undefined; const list = [...new Set(values.map(normalizeDomain))]; if (list.length > 100) throw new Error("At most 100 domains per list."); return list; } export function buildSearchInput(opts: SearchOptions): InvokeWebSearchCommandInput { const query = opts.query.trim(); if (query.length < 1 || query.length > 200) { throw new Error("The search query must be 1 to 200 characters long."); } const maxResults = opts.maxResults ?? 5; if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 25) { throw new Error("maxResults must be between 1 and 25."); } const { publishedAfter, publishedBefore } = opts; if (publishedAfter && publishedBefore && publishedAfter > publishedBefore) { throw new Error("publishedAfter is later than publishedBefore."); } const include = domainList(opts.includeDomains); const exclude = domainList(opts.excludeDomains); const input: InvokeWebSearchCommandInput = { webSearchId: WEB_SEARCH_ID, searchQuery: query, maxResults, }; if (include || exclude || publishedAfter || publishedBefore) { input.filters = { ...(include || exclude ? { domainFilter: { include, exclude } } : {}), ...(publishedAfter || publishedBefore ? { publishedDateFilter: { publishedAfter, publishedBefore } } : {}), }; } return input; } function shorten(text: string, max: number): string { return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`; } export async function searchWeb( client: BedrockAgentCoreClient, opts: SearchOptions, ): Promise<Source[]> { const output = await client.send(new InvokeWebSearchCommand(buildSearchInput(opts))); return (output.results ?? []).map((result) => ({ title: result.title ?? result.url ?? "Untitled", url: result.url, publishedAt: result.publishedAt?.toISOString(), excerpt: shorten(result.text ?? "", 1200), })); } Note that buildSearchInput only sends the filter when one is actually set. That keeps requests lean and makes the function easy to test, because it’s a pure function without an AWS call.
Step 3: Agent loop with Bedrock Converse
An agent loop (the cycle in which a model requests tools and gets their results back) is manageable with the Bedrock Converse API. The model gets a tool called web_search. When it requests a search, the response ends with the stop reason tool_use. Your code runs the search, appends the result to the history as a toolResult and calls the model again until it delivers a final answer.
🎬 Agent loop with the AgentCore Web Search API – this animated diagram only runs on my site: watch it on sascha-ms.de
Two details matter to me here. The model may only choose the search query and, optionally, a time range in days; the code sets the domain list. And sources are numbered across all searches so that references like [1] and [2] in the answer match the returned source list.
// src/agent.ts import { BedrockRuntimeClient, ConverseCommand, type ContentBlock, type Message, type Tool, } from "@aws-sdk/client-bedrock-runtime"; import type { SearchOptions, Source } from "./web-search"; const DAY_MS = 24 * 60 * 60 * 1000; const SYSTEM_PROMPT = [ "You are a research assistant for a software team.", "Use the web_search tool for anything that may have changed recently.", "Answer only from the search results and cite every claim as [n],", "where n is the position of the URL in the list of sources.", "If the results do not answer the question, say so.", ].join(" "); const webSearchTool: Tool = { toolSpec: { name: "web_search", description: "Searches trusted websites and returns text extracts with URL and date.", inputSchema: { json: { type: "object", properties: { query: { type: "string", description: "Short search query, at most 200 characters" }, recentDays: { type: "integer", minimum: 1, maximum: 365, description: "Only pages from the last N days (optional)", }, }, required: ["query"], }, }, }, }; export interface AgentDeps { runtime: BedrockRuntimeClient; search: (opts: SearchOptions) => Promise<Source[]>; modelId: string; trustedDomains: string[]; maxSearches?: number; now?: () => Date; } export interface AgentAnswer { answer: string; sources: Source[]; searches: number; } export async function answerWithSources(question: string, deps: AgentDeps): Promise<AgentAnswer> { const maxSearches = deps.maxSearches ?? 3; const now = deps.now ?? (() => new Date()); const messages: Message[] = [{ role: "user", content: [{ text: question }] }]; const sources = new Map<string, Source>(); let searches = 0; // Hard upper bound on model calls so that a looping model cannot run up costs. for (let call = 0; call < maxSearches + 2; call++) { const response = await deps.runtime.send( new ConverseCommand({ modelId: deps.modelId, system: [{ text: SYSTEM_PROMPT }], messages, toolConfig: { tools: [webSearchTool] }, inferenceConfig: { maxTokens: 1500 }, }), ); const message = response.output?.message; if (!message) throw new Error("The model returned no message."); messages.push(message); if (response.stopReason !== "tool_use") { const answer = (message.content ?? []).map((block) => block.text ?? "").join("").trim(); return { answer, sources: [...sources.values()], searches }; } const toolResults: ContentBlock[] = []; for (const block of message.content ?? []) { if (!block.toolUse) continue; const toolUseId = block.toolUse.toolUseId; if (searches >= maxSearches) { toolResults.push({ toolResult: { toolUseId, status: "error", content: [{ text: "Search budget used up. Answer with the sources you already have." }], }, }); continue; } const args = (block.toolUse.input ?? {}) as { query?: unknown; recentDays?: unknown }; try { searches++; const found = await deps.search({ query: String(args.query ?? ""), maxResults: 5, includeDomains: deps.trustedDomains, publishedAfter: typeof args.recentDays === "number" ? new Date(now().getTime() - args.recentDays * DAY_MS) : undefined, }); for (const source of found) { if (source.url && !sources.has(source.url)) sources.set(source.url, source); } const numbered = found.map((source) => ({ n: source.url ? [...sources.keys()].indexOf(source.url) + 1 : null, title: source.title, url: source.url ?? <...
