diff --git a/eventbridge-apidestination-agentcore-cdk/README.md b/eventbridge-apidestination-agentcore-cdk/README.md new file mode 100644 index 000000000..d2d1d21dc --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/README.md @@ -0,0 +1,134 @@ +# Amazon EventBridge API Destination to Amazon Bedrock AgentCore Runtime + +This pattern demonstrates **Lambda-less, event-driven invocation of an AI agent**: an EventBridge rule delivers events directly to an Amazon Bedrock AgentCore Runtime endpoint via an API Destination, authenticated with Cognito machine-to-machine (M2M) OAuth. No Lambda function, no glue code. + +The CDK stack is **fully self-contained** — it builds and deploys the AgentCore Runtime (from the bundled `agent-code/` Docker image) alongside the EventBridge plumbing, so a single `cdk deploy` gives you a working, testable pattern. + +![Architecture](architecture.png) + +``` +EventBridge Rule ──▶ API Destination (HTTPS + OAuth) ──▶ AgentCore Runtime + │ │ │ +custom event bus Connection: Cognito async processing +(demo.orders) client_credentials JWT (ack < 5s, work in + background) +``` + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/ + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## How it works + +1. An event (e.g. `source: demo.orders`, `detail-type: OrderCreated`) is published to a custom event bus. +2. An EventBridge rule matches the event and forwards it to an **API Destination** whose endpoint is the AgentCore Runtime `InvokeAgentRuntime` HTTPS API. +3. The API Destination's **Connection** obtains an OAuth access token from a **Cognito user pool token endpoint** using the `client_credentials` grant, and attaches it as a Bearer token. +4. The AgentCore Runtime validates the JWT against the Cognito user pool (inbound identity / `customJwtAuthorizer`), **acknowledges the request within 5 seconds**, and processes the event **asynchronously**. +5. Failed deliveries (after 3 retries) are sent to an SQS dead-letter queue. + +## Key technical details + +### 1. The 5-second timeout → async execution + +EventBridge API Destinations enforce a hard **5-second response timeout**. Agent reasoning takes much longer than that. The AgentCore Runtime therefore runs in **asynchronous mode**: the agent entrypoint returns an acknowledgment immediately (HTTP 2xx) and continues working in the background. See [`agent-code/agent.py`](agent-code/agent.py) for the implementation — it uses `asyncio.create_task` to kick off the real work, then returns `{"status": "accepted"}` well within the 5-second window. + +```python +from bedrock_agentcore import BedrockAgentCoreApp +import asyncio + +app = BedrockAgentCoreApp() + +@app.entrypoint +async def invoke(payload): + # Kick off long-running agent work in the background + asyncio.create_task(process_event(payload)) + # Acknowledge within the 5-second API Destination timeout + return {"status": "accepted"} +``` + +### 2. The URL-encoding gotcha → use the agent ID, not the ARN + +API Destinations **automatically decode `%XX` sequences** in the endpoint URL. A URL-encoded runtime ARN in the path (containing `:` and `/`) gets decoded back and breaks the request signature/routing. + +The fix: use the **agent runtime ID in the path** and pass the **account ID as a query parameter**. Per the AWS docs: *"When you use the agent ID instead of the full ARN, you don't need to URL-encode the identifier."* The stack derives this URL automatically from the runtime it creates (`CfnRuntime.attrAgentRuntimeId`): + +``` +https://bedrock-agentcore..amazonaws.com/runtimes//invocations?accountId=&qualifier=DEFAULT +``` + +### 3. Authentication → Cognito M2M (client_credentials) + +The stack creates: +- A **Cognito user pool** with a hosted domain (provides the `/oauth2/token` endpoint) +- A **resource server** (`agentcore`) with a custom scope (`agentcore/invoke`) +- An **app client** with a secret and the `client_credentials` grant + +The EventBridge Connection is configured with OAuth (client credentials) against the Cognito token endpoint. The AgentCore Runtime's `customJwtAuthorizer` is wired to the **same** user pool at creation time (its `discoveryUrl` and `allowedClients` reference the pool and app client this stack creates), so there is no manual post-deploy step. + +## Prerequisites + +- [AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) with sufficient permissions +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cli.html) installed and configured +- [Node.js 20+](https://nodejs.org/en/download/) and npm +- [AWS CDK CLI](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) (`npm i -g aws-cdk`), bootstrapped in the target account/region +- [Docker](https://docs.docker.com/get-docker/) installed and running (the CDK build packages the agent into a container image) +- Access to the Amazon Bedrock model your agent uses (the bundled agent uses the [Strands](https://strandsagents.com/) default model; enable model access in the Amazon Bedrock console for your region) + +## Deployment + +1. Clone and enter the pattern directory: + + ```bash + git clone https://github.com/aws-samples/serverless-patterns + cd serverless-patterns/eventbridge-apidestination-agentcore-cdk/cdk + npm install + ``` + +2. Deploy. The stack builds the agent container image, deploys the AgentCore Runtime, and wires up EventBridge — all in one command: + + ```bash + cdk deploy + ``` + +3. Note the stack outputs — in particular `EventBusName`, `AgentRuntimeId`, and `DeadLetterQueueUrl`. No further configuration is required: the runtime's JWT authorizer already trusts the Cognito app client created by this stack. + +## Testing + +Publish a test event to the custom bus (`EventBusName` output): + +```bash +aws events put-events --entries '[ + { + "EventBusName": "agentcore-events", + "Source": "demo.orders", + "DetailType": "OrderCreated", + "Detail": "{\"orderId\": \"12345\", \"prompt\": \"Summarize this order and flag any anomalies.\"}" + } +]' +``` + +Verify the invocation: + +1. **AgentCore Runtime logs** — check CloudWatch Logs for the runtime (`/aws/bedrock-agentcore/runtimes/-DEFAULT`) to see the event arrive and background processing run. +2. **Connection health** — `aws events describe-connection --name agentcore-cognito-oauth` should show `AUTHORIZED`. +3. **Failures** — if delivery fails after retries, events land in the DLQ: + + ```bash + aws sqs receive-message --queue-url + ``` + +Common failure causes: +- HTTP 401/403 in the DLQ → the Connection couldn't obtain or present a valid token (check the Connection status and the Cognito app client secret). +- Timeouts → the agent isn't acknowledging within 5 seconds (keep the entrypoint async; see `agent-code/agent.py`). + +## Cleanup + +```bash +cdk destroy +``` + +--- + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/eventbridge-apidestination-agentcore-cdk/agent-code/.dockerignore b/eventbridge-apidestination-agentcore-cdk/agent-code/.dockerignore new file mode 100644 index 000000000..caf4e5aa4 --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/agent-code/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +.git +.gitignore +.venv diff --git a/eventbridge-apidestination-agentcore-cdk/agent-code/.gitignore b/eventbridge-apidestination-agentcore-cdk/agent-code/.gitignore new file mode 100644 index 000000000..309de5763 --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/agent-code/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +.venv diff --git a/eventbridge-apidestination-agentcore-cdk/agent-code/Dockerfile b/eventbridge-apidestination-agentcore-cdk/agent-code/Dockerfile new file mode 100644 index 000000000..acc74f75f --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/agent-code/Dockerfile @@ -0,0 +1,20 @@ +FROM public.ecr.aws/docker/library/python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +RUN useradd -m -u 1000 bedrock_agentcore +USER bedrock_agentcore + +EXPOSE 8080 + +COPY . . + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/ping || exit 1 + +CMD ["python", "agent.py"] diff --git a/eventbridge-apidestination-agentcore-cdk/agent-code/agent.py b/eventbridge-apidestination-agentcore-cdk/agent-code/agent.py new file mode 100644 index 000000000..33a486e44 --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/agent-code/agent.py @@ -0,0 +1,50 @@ +""" +Minimal async AgentCore Runtime entrypoint for the +EventBridge API Destination -> AgentCore Runtime pattern. + +Why async: EventBridge API Destinations enforce a hard 5-second response +timeout on the target endpoint. Agent reasoning (an LLM call via Strands) +routinely takes longer than that, so this entrypoint acknowledges the +request immediately (HTTP 2xx, well under 5s) and continues the actual +agent work in a background asyncio task. + +This is intentionally minimal so the pattern deploys and can be tested +end-to-end. Swap the Strands `Agent()` call for your own tools/model +config as needed. +""" +import asyncio +import logging + +from bedrock_agentcore import BedrockAgentCoreApp +from strands import Agent + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = BedrockAgentCoreApp() +agent = Agent(model="us.anthropic.claude-haiku-4-5-20251001-v1:0") + + +async def process_event(payload: dict) -> None: + """Runs the actual agent reasoning after the HTTP response has + already been returned to EventBridge. Errors here are logged only: + there is no caller left to report back to.""" + prompt = payload.get("prompt", "Summarize this event.") + order_id = payload.get("orderId", "unknown") + try: + result = agent(prompt) + logger.info("orderId=%s agent result: %s", order_id, result) + except Exception: + logger.exception("orderId=%s agent invocation failed", order_id) + + +@app.entrypoint +async def invoke(payload: dict) -> dict: + # Fire-and-forget the real work so we can return well within the + # API Destination's 5-second timeout. + asyncio.create_task(process_event(payload)) + return {"status": "accepted", "orderId": payload.get("orderId")} + + +if __name__ == "__main__": + app.run() diff --git a/eventbridge-apidestination-agentcore-cdk/agent-code/requirements.txt b/eventbridge-apidestination-agentcore-cdk/agent-code/requirements.txt new file mode 100644 index 000000000..d4c4ad71a --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/agent-code/requirements.txt @@ -0,0 +1,2 @@ +strands-agents==1.50.2 +bedrock-agentcore==1.18.1 diff --git a/eventbridge-apidestination-agentcore-cdk/architecture.png b/eventbridge-apidestination-agentcore-cdk/architecture.png new file mode 100644 index 000000000..1dc63d8ee Binary files /dev/null and b/eventbridge-apidestination-agentcore-cdk/architecture.png differ diff --git a/eventbridge-apidestination-agentcore-cdk/cdk/.gitignore b/eventbridge-apidestination-agentcore-cdk/cdk/.gitignore new file mode 100644 index 000000000..459d58545 --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/cdk/.gitignore @@ -0,0 +1,7 @@ +node_modules +cdk.out +*.js +!jest.config.js +*.d.ts +.cdk.staging +*.tsbuildinfo diff --git a/eventbridge-apidestination-agentcore-cdk/cdk/bin/app.ts b/eventbridge-apidestination-agentcore-cdk/cdk/bin/app.ts new file mode 100644 index 000000000..f92ea1154 --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/cdk/bin/app.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib'; +import { EventBridgeAgentCoreStack } from '../lib/eventbridge-agentcore-stack'; + +const app = new cdk.App(); + +new EventBridgeAgentCoreStack(app, 'EventBridgeAgentCoreStack', { + description: + 'ServerlessLand pattern: EventBridge API Destination -> AgentCore Runtime (Lambda-less event-driven agent invocation)', + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}); diff --git a/eventbridge-apidestination-agentcore-cdk/cdk/cdk.json b/eventbridge-apidestination-agentcore-cdk/cdk/cdk.json new file mode 100644 index 000000000..020a631ce --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/cdk/cdk.json @@ -0,0 +1,21 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "node_modules", + "cdk.out" + ] + }, + "context": { + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true + } +} diff --git a/eventbridge-apidestination-agentcore-cdk/cdk/lib/eventbridge-agentcore-stack.ts b/eventbridge-apidestination-agentcore-cdk/cdk/lib/eventbridge-agentcore-stack.ts new file mode 100644 index 000000000..169c3782f --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/cdk/lib/eventbridge-agentcore-stack.ts @@ -0,0 +1,328 @@ +import * as path from 'path'; +import * as cdk from 'aws-cdk-lib'; +import * as bedrockagentcore from 'aws-cdk-lib/aws-bedrockagentcore'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as ecrAssets from 'aws-cdk-lib/aws-ecr-assets'; +import * as events from 'aws-cdk-lib/aws-events'; +import * as targets from 'aws-cdk-lib/aws-events-targets'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as sqs from 'aws-cdk-lib/aws-sqs'; +import { Construct } from 'constructs'; + +/** + * EventBridge API Destination -> Amazon Bedrock AgentCore Runtime + * "Lambda-less" event-driven agent invocation. + * + * Flow: + * EventBridge Rule -> API Destination (HTTPS, OAuth via Cognito M2M) + * -> AgentCore Runtime InvokeAgentRuntime endpoint (async processing) + * + * This stack is fully self-contained: it builds and deploys the AgentCore + * Runtime (from the bundled agent-code/ Docker image) alongside the + * EventBridge plumbing, so `cdk deploy` produces a working, testable + * pattern with no manual "update the runtime's authorizer" step. + * + * Key design decisions: + * + * 1. ASYNC execution: API Destinations enforce a hard 5-second timeout on + * target responses. Agent reasoning takes far longer than 5 seconds, so + * the AgentCore Runtime must acknowledge the request immediately (HTTP 2xx) + * and continue processing in the background (async invocation mode). + * See agent-code/agent.py for the entrypoint implementation. + * + * 2. URL encoding gotcha: API Destinations automatically decode %XX sequences + * in the endpoint URL. A URL-encoded runtime ARN in the path (which + * contains ":" and "/") gets decoded back and breaks the request. We + * therefore use the plain agent runtime ID (CfnRuntime.attrAgentRuntimeId) + * in the path and pass the account ID as a query parameter — no URL + * encoding needed. + * + * 3. Auth: Cognito User Pool with a Resource Server (client_credentials + * grant). The EventBridge Connection fetches OAuth tokens from the Cognito + * token endpoint; the AgentCore Runtime validates the JWT via its inbound + * identity (customJwtAuthorizer), configured at creation time against the + * same Cognito user pool — no two-step deploy required. + */ +export class EventBridgeAgentCoreStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // ------------------------------------------------------------------- + // 1. Cognito User Pool for machine-to-machine (M2M) authentication + // ------------------------------------------------------------------- + const userPool = new cognito.UserPool(this, 'AgentAuthUserPool', { + userPoolName: 'agentcore-m2m-pool', + selfSignUpEnabled: false, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // Hosted domain is required so the OAuth2 token endpoint exists. + const userPoolDomain = userPool.addDomain('AgentAuthDomain', { + cognitoDomain: { + // Domain prefix must be globally unique per region. + domainPrefix: `agentcore-invoke-${this.account}`, + }, + }); + + // Resource server defines the custom scope granted to the M2M client. + const invokeScope = new cognito.ResourceServerScope({ + scopeName: 'invoke', + scopeDescription: 'Invoke the AgentCore Runtime', + }); + + const resourceServer = userPool.addResourceServer('AgentResourceServer', { + identifier: 'agentcore', + userPoolResourceServerName: 'agentcore', + scopes: [invokeScope], + }); + + // App client using the client_credentials grant (M2M, no user login). + const appClient = userPool.addClient('EventBridgeM2MClient', { + userPoolClientName: 'eventbridge-connection-client', + generateSecret: true, + oAuth: { + flows: { + clientCredentials: true, + }, + scopes: [cognito.OAuthScope.resourceServer(resourceServer, invokeScope)], + }, + authFlows: { + userSrp: false, + userPassword: false, + }, + }); + + const tokenEndpoint = `https://${userPoolDomain.domainName}.auth.${this.region}.amazoncognito.com/oauth2/token`; + + // ------------------------------------------------------------------- + // 2. EventBridge Connection (OAuth client_credentials -> Cognito) + // ------------------------------------------------------------------- + const connection = new events.Connection(this, 'AgentCoreConnection', { + connectionName: 'agentcore-cognito-oauth', + description: + 'OAuth client_credentials connection to Cognito for AgentCore Runtime invocation', + authorization: events.Authorization.oauth({ + authorizationEndpoint: tokenEndpoint, + clientId: appClient.userPoolClientId, + clientSecret: appClient.userPoolClientSecret, + httpMethod: events.HttpMethod.POST, + bodyParameters: { + grant_type: events.HttpParameter.fromString('client_credentials'), + scope: events.HttpParameter.fromString('agentcore/invoke'), + }, + }), + }); + + // ------------------------------------------------------------------- + // 3. AgentCore Runtime — build from the bundled agent-code/ Dockerfile + // and deploy it, with its JWT authorizer pointed at the Cognito + // user pool created above. No separate "bring your own runtime" + // step: this stack is self-contained end to end. + // ------------------------------------------------------------------- + const agentImage = new ecrAssets.DockerImageAsset(this, 'AgentImage', { + directory: path.join(__dirname, '..', '..', 'agent-code'), + platform: ecrAssets.Platform.LINUX_ARM64, + }); + + const agentRuntimeName = 'eventbridge_apidestination_agentcore_demo'; + + // NOTE: these statements are attached as an INLINE policy on the role + // (not via role.addToPolicy, which would create a separate + // AWS::IAM::Policy resource). The CfnRuntime below only references the + // role's ARN, so CloudFormation would not otherwise wait for a separate + // policy to attach before creating the runtime — and the runtime + // assumes this role immediately to validate the ECR image. Keeping the + // permissions inline makes them part of the AWS::IAM::Role resource that + // the runtime depends on, avoiding an IAM propagation race. + const agentRuntimeRole = new iam.Role(this, 'AgentRuntimeRole', { + assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com', { + conditions: { + StringEquals: { 'aws:SourceAccount': this.account }, + ArnLike: { 'aws:SourceArn': `arn:aws:bedrock-agentcore:${this.region}:${this.account}:*` }, + }, + }), + inlinePolicies: { + AgentRuntimePolicy: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + sid: 'ECRImageAccess', + actions: ['ecr:BatchGetImage', 'ecr:GetDownloadUrlForLayer'], + resources: [agentImage.repository.repositoryArn], + }), + new iam.PolicyStatement({ + sid: 'ECRTokenAccess', + actions: ['ecr:GetAuthorizationToken'], + // ecr:GetAuthorizationToken does not support resource-level permissions. + resources: ['*'], + }), + new iam.PolicyStatement({ + actions: ['logs:DescribeLogStreams', 'logs:CreateLogGroup'], + resources: [`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/runtimes/*`], + }), + new iam.PolicyStatement({ + actions: ['logs:DescribeLogGroups'], + resources: [`arn:aws:logs:${this.region}:${this.account}:log-group:*`], + }), + new iam.PolicyStatement({ + actions: ['logs:CreateLogStream', 'logs:PutLogEvents'], + resources: [`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*`], + }), + new iam.PolicyStatement({ + actions: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords', 'xray:GetSamplingRules', 'xray:GetSamplingTargets'], + // X-Ray actions do not support resource-level permissions. + resources: ['*'], + }), + new iam.PolicyStatement({ + actions: ['cloudwatch:PutMetricData'], + resources: ['*'], + conditions: { StringEquals: { 'cloudwatch:namespace': 'bedrock-agentcore' } }, + }), + new iam.PolicyStatement({ + sid: 'GetAgentAccessToken', + actions: [ + 'bedrock-agentcore:GetWorkloadAccessToken', + 'bedrock-agentcore:GetWorkloadAccessTokenForJWT', + 'bedrock-agentcore:GetWorkloadAccessTokenForUserId', + ], + resources: [ + `arn:aws:bedrock-agentcore:${this.region}:${this.account}:workload-identity-directory/default`, + `arn:aws:bedrock-agentcore:${this.region}:${this.account}:workload-identity-directory/default/workload-identity/${agentRuntimeName}-*`, + ], + }), + new iam.PolicyStatement({ + sid: 'BedrockModelInvocation', + actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'], + resources: ['arn:aws:bedrock:*::foundation-model/*', `arn:aws:bedrock:${this.region}:${this.account}:*`], + }), + ], + }), + }, + }); + + const discoveryUrl = `https://cognito-idp.${this.region}.amazonaws.com/${userPool.userPoolId}/.well-known/openid-configuration`; + + const agentRuntime = new bedrockagentcore.CfnRuntime(this, 'AgentRuntime', { + agentRuntimeName, + agentRuntimeArtifact: { + containerConfiguration: { + containerUri: agentImage.imageUri, + }, + }, + networkConfiguration: { + networkMode: 'PUBLIC', + }, + roleArn: agentRuntimeRole.roleArn, + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl, + allowedClients: [appClient.userPoolClientId], + }, + }, + }); + + // ------------------------------------------------------------------- + // 4. API Destination -> AgentCore Runtime InvokeAgentRuntime endpoint + // ------------------------------------------------------------------- + // NOTE: plain agent runtime ID in the path + accountId as a query + // parameter. Do NOT use the URL-encoded full ARN — API Destinations + // decode %XX sequences in the URL and would corrupt it. + const invocationEndpoint = + `https://bedrock-agentcore.${this.region}.amazonaws.com` + + `/runtimes/${agentRuntime.attrAgentRuntimeId}/invocations` + + `?accountId=${this.account}&qualifier=DEFAULT`; + + const apiDestination = new events.ApiDestination(this, 'AgentCoreApiDestination', { + apiDestinationName: 'agentcore-runtime-invoke', + connection, + endpoint: invocationEndpoint, + httpMethod: events.HttpMethod.POST, + rateLimitPerSecond: 10, + description: + 'Invokes the AgentCore Runtime asynchronously (runtime must ack within 5s)', + }); + + // ------------------------------------------------------------------- + // 5. Event bus, DLQ, and rule + // ------------------------------------------------------------------- + const eventBus = new events.EventBus(this, 'AgentEventBus', { + eventBusName: 'agentcore-events', + }); + + // Failed deliveries (after retries) land here for inspection/redrive. + const dlq = new sqs.Queue(this, 'DeliveryDlq', { + queueName: 'agentcore-invoke-dlq', + retentionPeriod: cdk.Duration.days(14), + enforceSSL: true, + }); + + const rule = new events.Rule(this, 'InvokeAgentRule', { + ruleName: 'invoke-agentcore-on-order-event', + eventBus, + description: 'Routes order events to the AgentCore Runtime via API Destination', + eventPattern: { + source: ['demo.orders'], + detailType: ['OrderCreated'], + }, + }); + + rule.addTarget( + new targets.ApiDestination(apiDestination, { + deadLetterQueue: dlq, + retryAttempts: 3, + maxEventAge: cdk.Duration.minutes(10), + // Shape the payload the agent receives. AgentCore Runtime expects a + // JSON body; the "prompt" key is what a typical agent entrypoint + // reads. Adjust to match your agent's input contract. + event: events.RuleTargetInput.fromObject({ + prompt: events.EventField.fromPath('$.detail.prompt'), + orderId: events.EventField.fromPath('$.detail.orderId'), + eventId: events.EventField.eventId, + source: events.EventField.source, + }), + }) + ); + + // ------------------------------------------------------------------- + // Outputs + // ------------------------------------------------------------------- + new cdk.CfnOutput(this, 'EventBusName', { + value: eventBus.eventBusName, + description: 'Custom event bus to publish test events to', + }); + + new cdk.CfnOutput(this, 'ApiDestinationEndpoint', { + value: invocationEndpoint, + description: 'AgentCore Runtime invocation URL used by the API Destination', + }); + + new cdk.CfnOutput(this, 'CognitoTokenEndpoint', { + value: tokenEndpoint, + description: 'OAuth2 token endpoint used by the EventBridge Connection', + }); + + new cdk.CfnOutput(this, 'CognitoDiscoveryUrl', { + value: discoveryUrl, + description: 'OIDC discovery URL used by the AgentCore Runtime customJwtAuthorizer', + }); + + new cdk.CfnOutput(this, 'CognitoAppClientId', { + value: appClient.userPoolClientId, + description: 'App client ID trusted by the AgentCore Runtime customJwtAuthorizer', + }); + + new cdk.CfnOutput(this, 'AgentRuntimeArn', { + value: agentRuntime.attrAgentRuntimeArn, + description: 'ARN of the deployed AgentCore Runtime', + }); + + new cdk.CfnOutput(this, 'AgentRuntimeId', { + value: agentRuntime.attrAgentRuntimeId, + description: 'ID of the deployed AgentCore Runtime (used in the invocation URL)', + }); + + new cdk.CfnOutput(this, 'DeadLetterQueueUrl', { + value: dlq.queueUrl, + description: 'SQS DLQ for failed deliveries to the API Destination', + }); + } +} diff --git a/eventbridge-apidestination-agentcore-cdk/cdk/package.json b/eventbridge-apidestination-agentcore-cdk/cdk/package.json new file mode 100644 index 000000000..050a618fa --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/cdk/package.json @@ -0,0 +1,25 @@ +{ + "name": "eventbridge-apidestination-agentcore-cdk", + "version": "1.0.0", + "description": "EventBridge API Destination to Amazon Bedrock AgentCore Runtime - Lambda-less event-driven agent invocation", + "bin": { + "app": "bin/app.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk", + "deploy": "cdk deploy", + "destroy": "cdk destroy" + }, + "devDependencies": { + "@types/node": "20.14.9", + "aws-cdk": "2.1136.0", + "ts-node": "10.9.2", + "typescript": "5.5.3" + }, + "dependencies": { + "aws-cdk-lib": "2.264.0", + "constructs": "^10.8.1" + } +} diff --git a/eventbridge-apidestination-agentcore-cdk/cdk/tsconfig.json b/eventbridge-apidestination-agentcore-cdk/cdk/tsconfig.json new file mode 100644 index 000000000..b1eaa510e --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/cdk/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["es2022"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": ["./node_modules/@types"] + }, + "exclude": ["node_modules", "cdk.out"] +} diff --git a/eventbridge-apidestination-agentcore-cdk/example-pattern.json b/eventbridge-apidestination-agentcore-cdk/example-pattern.json new file mode 100644 index 000000000..afbb29288 --- /dev/null +++ b/eventbridge-apidestination-agentcore-cdk/example-pattern.json @@ -0,0 +1,120 @@ +{ + "title": "Amazon EventBridge API Destination to Amazon Bedrock AgentCore Runtime", + "description": "Invoke an AgentCore Runtime agent directly from EventBridge without any Lambda glue code, using an API Destination with Cognito M2M OAuth.", + "language": "TypeScript", + "level": "300", + "framework": "CDK", + "patternArch": { + "icon1": { + "x": 20, + "y": 50, + "service": "eventbridge", + "label": "Amazon EventBridge" + }, + "icon2": { + "x": 50, + "y": 50, + "service": "eventbridge-api-destination", + "label": "API Destination" + }, + "icon3": { + "x": 80, + "y": 50, + "service": "bedrock", + "label": "Amazon Bedrock AgentCore Runtime" + }, + "icon4": { + "x": 50, + "y": 20, + "service": "cognito", + "label": "Amazon Cognito (M2M OAuth)" + }, + "icon5": { + "x": 50, + "y": 80, + "service": "sqs", + "label": "Dead-letter queue" + }, + "line1": { + "from": "icon1", + "to": "icon2" + }, + "line2": { + "from": "icon2", + "to": "icon3" + }, + "line3": { + "from": "icon4", + "to": "icon2" + }, + "line4": { + "from": "icon2", + "to": "icon5" + } + }, + "introBox": { + "headline": "How it works", + "text": [ + "This pattern shows a Lambda-less, event-driven invocation of an Amazon Bedrock AgentCore Runtime agent. The CDK stack is self-contained: it builds and deploys the AgentCore Runtime (from a bundled agent container image) along with all the EventBridge plumbing.", + "An EventBridge rule matches events on a custom bus and routes them to an API Destination whose HTTPS endpoint is the AgentCore Runtime InvokeAgentRuntime API.", + "The EventBridge Connection authenticates using the OAuth client_credentials flow against an Amazon Cognito user pool (machine-to-machine). The AgentCore Runtime validates the resulting JWT via its inbound identity (customJwtAuthorizer) configuration, which the stack wires to the same Cognito user pool at creation time.", + "Because API Destinations enforce a 5-second response timeout, the AgentCore Runtime is invoked in asynchronous mode: the agent entrypoint acknowledges the request immediately and continues processing the event in the background.", + "The endpoint URL uses the agent runtime ID plus an accountId query parameter instead of a URL-encoded ARN, because API Destinations automatically decode percent-encoded sequences in target URLs.", + "Deliveries that fail after retries are captured in an SQS dead-letter queue for inspection and redrive.", + "IAM permissions follow least privilege: the AgentCore Runtime execution role grants only ecr:BatchGetImage and ecr:GetDownloadUrlForLayer to the specific ECR repository, scoped CloudWatch Logs and X-Ray permissions, and bedrock:InvokeModel for foundation models. The EventBridge rule's target role has only events:InvokeApiDestination on the specific API Destination. The SQS DLQ enforces SSL in transit." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/eventbridge-apidestination-agentcore-cdk", + "templateURL": "serverless-patterns/eventbridge-apidestination-agentcore-cdk", + "projectFolder": "eventbridge-apidestination-agentcore-cdk", + "templateFile": "cdk/lib/eventbridge-agentcore-stack.ts" + } + }, + "resources": { + "bullets": [ + { + "text": "Amazon EventBridge API Destinations", + "link": "https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-api-destinations.html" + }, + { + "text": "Amazon Bedrock AgentCore Runtime", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agents-tools-runtime.html" + }, + { + "text": "AgentCore Runtime inbound JWT authorizer", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/inbound-jwt-authorizer.html" + }, + { + "text": "Cognito machine-to-machine authorization (client credentials grant)", + "link": "https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-client-apps.html" + } + ] + }, + "deploy": { + "text": [ + "cd cdk", + "npm install", + "cdk deploy" + ] + }, + "testing": { + "headline": "Testing", + "text": [ + "See the GitHub repo README.md for detailed testing instructions.", + "Sample test event payload: {\"EventBusName\":\"agentcore-events\",\"Source\":\"demo.orders\",\"DetailType\":\"OrderCreated\",\"Detail\":\"{\\\"orderId\\\":\\\"12345\\\",\\\"prompt\\\":\\\"Summarize this order and flag any anomalies.\\\"}\"}" + ] + }, + "cleanup": { + "headline": "Cleanup", + "text": ["cd cdk", "cdk destroy"] + }, + "authors": [ + { + "name": "Antoine Boucherie", + "bio": "Principal Solutions Architect, AWS Global Financial Services", + "linkedin": "antoineboucherie" + } + ] +}