Bedrock Agents Plus EventBridge: Async Tools Without Blocking the Session

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 unfair advantage is splitting 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 the conversation when the job is terminal.

⚡ TL;DR: For any tool that regularly exceeds a few seconds, implement start_* + get_*_status instead of one blocking call. 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. Build on Bedrock Agents tool use, idempotent tool writes, and EventBridge schema evolution.

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

// ❌ 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.

Pattern: start job → EventBridge → worker → status tool

// 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 RUNNINGSUCCEEDED | 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

// 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.

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.

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


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 Reply