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_*_statusinstead of one blocking call. PublishJobRequestedto EventBridge, process with Lambda/Step Functions, store state in DynamoDB keyed byjobId, 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 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
// 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 - [ ]
idempotencyKeyon 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
- Bedrock Agents: Tool Use, Memory, and Guardrails
- Bedrock Agents: Idempotent Tool Calls Against DynamoDB Writes
- EventBridge Schema Evolution: Additive Changes Without Breaking Consumers
- Human-in-the-Loop Gates: Dual Control for Prod-Touching Agent Tools
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.