DEV Community

Cover image for AgentCore Web Search API: web search without a gateway
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Edited on Originally published at sascha-ms.de

AgentCore Web Search API: web search without a gateway

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.

Diagram: Lambda calls InvokeWebSearch directly with IAM and builds an answer with a source list from the results

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
Enter fullscreen mode Exit fullscreen mode

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),
  }));
}
Enter fullscreen mode Exit fullscreen mode

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 ?? null,
          publishedAt: source.publishedAt ?? null,
          excerpt: source.excerpt,
        }));
        toolResults.push({ toolResult: { toolUseId, content: [{ json: { results: numbered } }] } });
      } catch (error) {
        toolResults.push({
          toolResult: {
            toolUseId,
            status: "error",
            content: [{ text: `Search failed: ${(error as Error).message}` }],
          },
        });
      }
    }
    messages.push({ role: "user", content: toolResults });
  }
  throw new Error("The model did not produce a final answer.");
}
Enter fullscreen mode Exit fullscreen mode

Even after the budget is used up, the code answers every tool request, just with an error status. That’s more than cosmetic: Converse requires a matching toolResult for every toolUse block, otherwise the next call fails with a validation error.

Step 4: The Lambda handler

The handler is deliberately thin. It creates both SDK clients outside the handler function so warm invocations reuse them, and it reads the model and domains from environment variables. For the search client, I raise the number of attempts to four so short throttling caused by the quota is retried automatically with backoff.

// src/handler.ts
import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore";
import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime";
import { answerWithSources, type AgentAnswer } from "./agent";
import { searchWeb } from "./web-search";

// Created once per execution environment and reused across invocations.
const agentCore = new BedrockAgentCoreClient({ maxAttempts: 4 });
const runtime = new BedrockRuntimeClient({});

const modelId = process.env.MODEL_ID ?? "";
const trustedDomains = (process.env.TRUSTED_DOMAINS ?? "")
  .split(",")
  .map((domain) => domain.trim())
  .filter(Boolean);

export const handler = async (event: { question?: string }): Promise<AgentAnswer> => {
  const question = event.question?.trim();
  if (!question || question.length > 1000) {
    throw new Error("Please send a question with 1 to 1000 characters.");
  }
  const result = await answerWithSources(question, {
    runtime,
    modelId,
    trustedDomains,
    search: (opts) => searchWeb(agentCore, opts),
  });
  console.log(JSON.stringify({ searches: result.searches, sources: result.sources.length }));
  return result;
};
Enter fullscreen mode Exit fullscreen mode

Step 5: CDK stack with minimal permissions

The stack holds the real lessons. The search permission refers to the AWS managed tool, and its ARN contains the literal aws in the account field instead of your account ID. For the model, the role needs both the inference profile and the foundation model in the destination regions. An inference profile spreads requests across several regions, and the EU profile across several European ones.

// lib/research-stack.ts
import * as path from "node:path";
import { CfnOutput, Duration, Stack, type StackProps } from "aws-cdk-lib";
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs";
import type { Construct } from "constructs";

export interface ResearchStackProps extends StackProps {
  modelId: string;
  trustedDomains: string[];
}

export class ResearchAssistantStack extends Stack {
  constructor(scope: Construct, id: string, props: ResearchStackProps) {
    super(scope, id, props);

    const fn = new NodejsFunction(this, "ResearchFunction", {
      entry: path.join(__dirname, "../src/handler.ts"),
      runtime: Runtime.NODEJS_24_X,
      architecture: Architecture.ARM_64,
      memorySize: 512,
      timeout: Duration.seconds(60),
      environment: {
        MODEL_ID: props.modelId,
        TRUSTED_DOMAINS: props.trustedDomains.join(","),
      },
      logGroup: new LogGroup(this, "ResearchLogs", { retention: RetentionDays.TWO_WEEKS }),
      bundling: {
        minify: true,
        // Bundle the SDK: the copy in the Lambda runtime may not know InvokeWebSearch yet.
        externalModules: [],
      },
    });

    // The tool belongs to AWS, so the account field of the ARN is the literal "aws".
    fn.addToRolePolicy(
      new PolicyStatement({
        actions: ["bedrock-agentcore:InvokeWebSearch"],
        resources: [`arn:${this.partition}:bedrock-agentcore:${this.region}:aws:tool/web-search.v1`],
      }),
    );

    // Cross-region inference profile plus the foundation model in the target regions.
    const baseModelId = props.modelId.replace(/^(eu|us|apac|global)\./, "");
    fn.addToRolePolicy(
      new PolicyStatement({
        actions: ["bedrock:InvokeModel"],
        resources: [
          `arn:${this.partition}:bedrock:${this.region}:${this.account}:inference-profile/${props.modelId}`,
          `arn:${this.partition}:bedrock:*::foundation-model/${baseModelId}`,
        ],
      }),
    );

    new CfnOutput(this, "FunctionName", { value: fn.functionName });
  }
}
Enter fullscreen mode Exit fullscreen mode

The line externalModules: [] makes esbuild pack the AWS SDK into the bundle. By default, NodejsFunction leaves out all @aws-sdk packages and relies on the version in the Lambda runtime, which often lags behind new API operations. Next comes the app file, which creates the stack in Ireland:

// bin/app.ts
import { App } from "aws-cdk-lib";
import { ResearchAssistantStack } from "../lib/research-stack";

const app = new App();
const modelId =
  app.node.tryGetContext("modelId") ?? "eu.anthropic.claude-haiku-4-5-20251001-v1:0";

new ResearchAssistantStack(app, "ResearchAssistant", {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: "eu-west-1" },
  modelId,
  trustedDomains: ["aws.amazon.com", "nodejs.org", "typescriptlang.org", "github.blog"],
});
Enter fullscreen mode Exit fullscreen mode

Step 6: Testing without AWS access

With aws-sdk-client-mock, you test the search client without making a real call. An excerpt from the tests:

// test/web-search.test.ts
import { BedrockAgentCoreClient, InvokeWebSearchCommand } from "@aws-sdk/client-bedrock-agentcore";
import { mockClient } from "aws-sdk-client-mock";
import { beforeEach, describe, expect, it } from "vitest";
import { buildSearchInput, normalizeDomain, searchWeb } from "../src/web-search";

const agentCoreMock = mockClient(BedrockAgentCoreClient);

describe("normalizeDomain", () => {
  it("removes scheme, path and port", () => {
    expect(normalizeDomain("https://Docs.AWS.amazon.com:443/lambda/")).toBe("docs.aws.amazon.com");
  });

  it("rejects values that are not domains", () => {
    expect(() => normalizeDomain("localhost")).toThrow(/Invalid domain/);
  });
});

describe("buildSearchInput", () => {
  it("only sends filters that are set", () => {
    expect(buildSearchInput({ query: "Node.js 24 Lambda" })).toEqual({
      webSearchId: "web-search.v1",
      searchQuery: "Node.js 24 Lambda",
      maxResults: 5,
    });
  });

  it("deduplicates domains and builds the date range", () => {
    const after = new Date("2026-10-01T00:00:00Z");
    const input = buildSearchInput({
      query: "Lambda runtimes",
      includeDomains: ["aws.amazon.com", "https://aws.amazon.com/blogs/"],
      publishedAfter: after,
    });
    expect(input.filters).toEqual({
      domainFilter: { include: ["aws.amazon.com"], exclude: undefined },
      publishedDateFilter: { publishedAfter: after, publishedBefore: undefined },
    });
  });

  it("rejects queries longer than 200 characters", () => {
    expect(() => buildSearchInput({ query: "x".repeat(201) })).toThrow(/1 to 200/);
  });

  it("rejects a reversed date range", () => {
    expect(() =>
      buildSearchInput({
        query: "test",
        publishedAfter: new Date("2026-11-02"),
        publishedBefore: new Date("2026-11-01"),
      }),
    ).toThrow(/later than/);
  });
});

describe("searchWeb", () => {
  beforeEach(() => agentCoreMock.reset());

  it("maps results to sources and handles missing fields", async () => {
    agentCoreMock.on(InvokeWebSearchCommand).resolves({
      results: [
        {
          title: "Lambda runtimes",
          url: "https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html",
          text: "Node.js 24 is supported.",
          publishedAt: new Date("2026-11-01T08:00:00Z"),
        },
        { text: "Extract without title and URL" },
      ],
    });
    const sources = await searchWeb(new BedrockAgentCoreClient({}), { query: "Node.js 24 Lambda" });
    expect(sources).toEqual([
      {
        title: "Lambda runtimes",
        url: "https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html",
        publishedAt: "2026-11-01T08:00:00.000Z",
        excerpt: "Node.js 24 is supported.",
      },
      { title: "Untitled", url: undefined, publishedAt: undefined, excerpt: "Extract without title and URL" },
    ]);
  });
});
Enter fullscreen mode Exit fullscreen mode

The repository also contains tests for the agent loop (including the search budget) and template assertions for the CDK stack that verify the role can really only call the AWS tool. Run everything like this:

npx tsc --noEmit
npx vitest run
npx cdk synth -q
Enter fullscreen mode Exit fullscreen mode

Expected result: no type errors, twelve green tests and a synthesized template without errors. With the bundled SDK, the Lambda package is just under 600 KB.

Step 7: Deploy and try it out

npx cdk deploy
aws lambda invoke \
  --region eu-west-1 \
  --function-name <FunctionName from the stack output> \
  --cli-binary-format raw-in-base64-out \
  --payload '{"question":"Which Node.js versions does AWS Lambda currently support?"}' \
  answer.json
cat answer.json
Enter fullscreen mode Exit fullscreen mode

The response contains three fields: answer with the text including references like [1], sources with the title, URL, date and extract of each source, and searches with the number of search queries. I can’t predict the exact content; it depends on the current index. In CloudWatch you’ll see one short log line per invocation with the number of searches and sources, which lets you read off the search costs directly.

To clean up, npx cdk destroy is enough. Without invocations there are no running costs, and the log group keeps entries for two weeks.

Typical mistakes and how to avoid them

AccessDeniedException despite a matching policy

Almost always, your account ID is in the resource ARN. The tool belongs to AWS, though, so the ARN is arn:aws:bedrock-agentcore:eu-west-1:aws:tool/web-search.v1. A wrong region in the ARN also leads to this error.

“InvokeWebSearchCommand is not a constructor”

This happens when the function loads the Lambda runtime’s SDK version at runtime instead of yours. Fix: bundle the SDK as in the stack above, or with other tools add the SDK to the package as a regular dependency.

ValidationException with the domain filter

The API only accepts domains without scheme and path, so nodejs.org rather than https://nodejs.org/en/. The search client above normalizes this automatically. You also get a ValidationException when publishedAfter is later than publishedBefore.

The last day is missing from the range

If you pass publishedBefore as a plain date, such as 2026-11-10, it means midnight UTC at the start of that day. Everything published on November 10 drops out. For whole days, use the last second, so 2026-11-10T23:59:59Z.

ThrottlingException under load

Ten requests per second are plenty for an assistant, but not for a data pipeline with many parallel Lambdas. Limit concurrency there, for example with the MaximumConcurrency of an SQS event source, and request a higher quota if needed.

My take

I think the AgentCore Web Search API is one of the more useful releases of the past few weeks, precisely because it’s so unspectacular. One call, IAM, done. Many agents I see in examples don’t need a full MCP setup at all, just one or two well-scoped tools. For those cases, the direct call is more honest and easier to operate than a gateway you create for a single tool.

I’m skeptical about how well the index works for German-language content and niche topics. You can’t tell from the documentation, and I would test it with a handful of real questions before building an assistant on it. Equally important to me is the domain filter. An agent that may only look at vendor sites hallucinates less cheerfully than one that quotes every forum in the world. This restriction costs you one line of code, so use it.

And one more thought for teams: the obligation to show sources is actually a gift. Whoever sees the links checks them. That’s exactly the attitude I want my team to have toward AI answers. You’ll find more on AI in everyday development on the blog.

FAQ

How much does the AgentCore Web Search API cost?

$7 per 1,000 search queries. Calling the API directly incurs no gateway charges. Costs for the language model come on top separately.

Is the AgentCore Web Search API available in Frankfurt?

Not at launch. In Europe it’s only available in Ireland (eu-west-1), plus five regions in the US and Asia Pacific.

Do I need an AgentCore Gateway for web search?

No. With InvokeWebSearch, you call the search directly through the AWS SDK or the CLI. The gateway pays off if your agents find their tools over MCP or you need JWT authentication.

How many results does InvokeWebSearch return?

Up to 25 per request, which is also the default. There is no pagination; for more hits you need a different search query.

Your next step

Take a question your team actually researched this week and ask the sample agent with three or four trusted domains. Compare the answer and the sources with what you found yourselves. After that, you’ll know fairly precisely whether the index works for your topics. Which domains would you allow your research agent first?

All code from this article, ready to run: smirowstanitzok/sascha-ms-examples

Sources

This post was created with AI assistance.


👉 Next step: Working with AWS CDK? Grab my printable AWS CDK cheat sheet with commands, concepts and a stack skeleton.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)