Bedrock Agents + EventBridge: Async Tools Without Blocking the Session

9 views
Bedrock Agents feel snappy until a tool waits on a 90-second CDK synth, a big CodeBuild, or a multi-minute RAG reindex. The session blocks, the user stares at a spinner, and retries create duplicate jobs. The Bedrock Agents EventBridge pattern splits sync control plane from async data plane: agent tools only enqueue work and return a correlation id; EventBridge fans out to workers; a status tool (or callback) resumes when the job is terminal.
⚡ TL;DR: For Bedrock Agents + EventBridge async tools, any call that regularly exceeds a few seconds should be start_* + get_*_status instead of one blocking invoke. Publish JobRequested to EventBridge, process with Lambda/Step Functions, store state in DynamoDB keyed by jobId, and make starts idempotent. Never let the agent poll AWS APIs with wild IAM — give it a narrow status read.
Also useful nearby: EventBridge Pipes to Lambda, EventBridge Scheduler for agent batches, and Amazon Bedrock Agents guardrails.

The anti-pattern: one tool that “does the whole thing”

typescript
// ❌ Blocking tool — looks simple, dies in prod
export async function runCdkSynth(_args: { stack: string }) {
  // 60–180s later...
  return { ok: true, logs: "..." };
}
Problems compound: Bedrock action timeouts, user-abandoned sessions, double-clicks that start two synths, and IAM that must allow the agent runtime to do everything the worker needs.

Bedrock Agents + EventBridge: start job → worker → status tool

typescript
// tools/start-cdk-synth.ts
import { EventBridgeClient, PutEventsCommand } from "@aws-sdk/client-eventbridge";
import { DynamoDBClient, PutItemCommand, GetItemCommand } from "@aws-sdk/client-dynamodb";
import { randomUUID } from "node:crypto";
import { z } from "zod";

const eb = new EventBridgeClient({});
const ddb = new DynamoDBClient({});
const TABLE = process.env.JOBS_TABLE!;
const BUS = process.env.APP_BUS!;

export const StartSynthArgs = z.object({
  schemaVersion: z.literal(1),
  idempotencyKey: z.string().min(8),
  stackName: z.string().min(1).max(128),
  ref: z.string().min(1), // git sha or branch
});

export async function startCdkSynth(raw: unknown) {
  const args = StartSynthArgs.parse(raw);
  const existing = await ddb.send(
    new GetItemCommand({
      TableName: TABLE,
      Key: { pk: { S: `IDEM#${args.idempotencyKey}` } },
    }),
  );
  if (existing.Item?.jobId?.S) {
    // ✅ Same idempotencyKey returns same jobId
    return {
      ok: true,
      jobId: existing.Item.jobId.S,
      status: existing.Item.status?.S ?? "UNKNOWN",
      resumed: true,
    };
  }

  const jobId = randomUUID();
  await ddb.send(
    new PutItemCommand({
      TableName: TABLE,
      Item: {
        pk: { S: `JOB#${jobId}` },
        gsi1pk: { S: `IDEM#${args.idempotencyKey}` },
        status: { S: "QUEUED" },
        stackName: { S: args.stackName },
        ref: { S: args.ref },
        createdAt: { S: new Date().toISOString() },
      },
      ConditionExpression: "attribute_not_exists(pk)",
    }),
  );
  // secondary idempotency pointer
  await ddb.send(
    new PutItemCommand({
      TableName: TABLE,
      Item: {
        pk: { S: `IDEM#${args.idempotencyKey}` },
        jobId: { S: jobId },
        status: { S: "QUEUED" },
      },
      ConditionExpression: "attribute_not_exists(pk)",
    }),
  );

  await eb.send(
    new PutEventsCommand({
      Entries: [
        {
          EventBusName: BUS,
          Source: "com.cheatcoders.agents",
          DetailType: "CdkSynthRequested",
          Detail: JSON.stringify({
            jobId,
            stackName: args.stackName,
            ref: args.ref,
            schemaVersion: 1,
          }),
        },
      ],
    }),
  );

  return {
    ok: true,
    jobId,
    status: "QUEUED",
    // Tell the model how to continue — do not invent busy-waits of 100 tool calls
    nextTool: "get_cdk_synth_status",
    pollAfterSec: 15,
  };
}
Worker side (Lambda on the rule) updates status to RUNNING → SUCCEEDED | FAILED and stores artifact URIs. Keep the EventBridge detail additive so new fields do not break older consumers — schema evolution guide.

Status tool: narrow read, structured resume

typescript
// tools/get-cdk-synth-status.ts
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
import { z } from "zod";

const ddb = new DynamoDBClient({});
const TABLE = process.env.JOBS_TABLE!;

export const StatusArgs = z.object({
  schemaVersion: z.literal(1),
  jobId: z.string().uuid(),
});

export async function getCdkSynthStatus(raw: unknown) {
  const { jobId } = StatusArgs.parse(raw);
  const out = await ddb.send(
    new GetItemCommand({
      TableName: TABLE,
      Key: { pk: { S: `JOB#${jobId}` } },
    }),
  );
  if (!out.Item) {
    return {
      ok: false,
      error: { code: "NOT_FOUND", retryable: false, message: "unknown jobId" },
    };
  }

  const status = out.Item.status.S!;
  return {
    ok: true,
    jobId,
    status,
    // ✅ Terminal payloads only when done — keep interim small
    artifactUri: out.Item.artifactUri?.S,
    errorMessage: out.Item.errorMessage?.S,
    resumeHint:
      status === "QUEUED" || status === "RUNNING"
        ? { action: "poll", pollAfterSec: 15 }
        : status === "SUCCEEDED"
          ? { action: "continue", summaryTool: "summarize_synth_artifact" }
          : { action: "replan" },
  };
}
Agent instructions should say: after start_*, call get_*_status with backoff; do not start a second job unless status is FAILED and the user confirms. For memory/session discipline around tool results, see Bedrock Agents: Tool Use, Memory, Guardrails.

IAM: agent runtime ≠ worker runtime

Split roles:
Principal Allowed Denied
Agent action Lambda PutEvents on app bus, GetItem/PutItem on jobs table (job keys only) iam:*, CodeBuild start except via bus, S3 write to artifact bucket
Worker Lambda CodeBuild/CDK as needed, S3 artifact put, job status update PutEvents storm to unrelated buses; agent session APIs
❌ Attaching the worker policy to the Bedrock action group Lambda “so one tool can do it all.”
✅ EventBridge as the privilege boundary — same idea as dual control in HITL gates.

UX: correlation ids in the user-visible reply

Return jobId in the tool result and ask the agent to echo a short id to the user (“Synth job a1b2… queued”). When users refresh mid-flight, a list_my_jobs tool scoped by sessionId / principalId beats archaeology in CloudWatch.
typescript
export type JobRecord = {
  jobId: string;
  principalId: string;
  sessionId: string;
  status: "QUEUED" | "RUNNING" | "SUCCEEDED" | "FAILED";
  createdAt: string;
};

Optional: push resume instead of poll

For longer jobs, emit CdkSynthCompleted to EventBridge → API Destination / AppSync / websocket notifier. Polling status tools remain the portable baseline for Bedrock Agents; push is an optimization once you own the client.

Related reading

Checklist

  • [ ] Tools that can exceed ~5–10s are split into start_* + get_*_status
  • [ ] idempotencyKey on start; DynamoDB conditional writes
  • [ ] EventBridge detail versioned; consumers additive
  • [ ] Worker updates terminal status + artifact/error fields
  • [ ] Agent IAM cannot perform worker privileges directly
  • [ ] Status responses include resumeHint (poll / continue / replan)
  • [ ] User-visible correlation id; optional session-scoped job list
  • [ ] Alarms on queue age, failed jobs, DLQ from the rule target

Related reading

Last updated on September 11, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.