{"slug": "ai-agents-with-sqs-and-lambda-build-a-plan-act-observe-loop-in-node-js", "title": "AI Agents with SQS and Lambda: Build a Plan‑Act‑Observe Loop in Node.js", "summary": "A developer demonstrates how to build a plan-act-observe loop for autonomous AI agents using AWS SQS and Lambda in Node.js. The approach uses a FIFO queue to ensure ordered message processing and three separate Lambda functions for planning, acting, and observing, providing a reliable messaging backbone for LLM tool use.", "body_md": "You’ve seen flashy EventBridge Pipes demos, but they hide the mechanics of an autonomous AI agent. In just a few lines of TypeScript you can wire SQS and Lambda together to give your LLM a reliable tool‑use loop. Let’s demystify the messaging backbone that makes the agent think, act, and observe.\n\n**Why a loop?**\n\nThink of an autonomous agent like a chef following a recipe:\n\nRepeating these three steps lets the LLM keep a conversation alive, call APIs, and adjust its next prompt based on real data.\n\n**Key terms**\n\nBelow is a tiny TypeScript type that captures a tool request. Using the `satisfies`\n\nkeyword tells the compiler “this object must match the shape, but keep the exact literal types for later safety.”\n\n```\n// src/types.ts\nexport interface ToolRequest {\n  /** Unique identifier for the step – used for deduplication */\n  requestId: string;\n  /** Name of the tool the LLM wants to use, e.g. \"weather\" */\n  toolName: string;\n  /** Arbitrary parameters the tool needs, kept as a plain object */\n  args: Record<string, unknown>;\n}\n\n/* The `satisfies` operator checks that the literal we export conforms to ToolRequest\n   without widening the type – helpful for strict runtime validation later. */\nexport const exampleRequest = {\n  requestId: \"req-001\",\n  toolName: \"weather\",\n  args: { location: \"Seattle, WA\" },\n} satisfies ToolRequest;\n```\n\nIn plain English– The loop is just the chef’s three‑step routine. By breaking the job into “plan, act, observe” we give the LLM a predictable place to read, write, and react to data.\n\n**Why FIFO?**\n\nA FIFO (First‑In‑First‑Out) queue guarantees that messages are processed in the exact order they were sent. For a planner‑executor‑observer chain, out‑of‑order execution can corrupt the reasoning flow (imagine adding salt before the broth is even simmered).\n\n**How to create it** – we’ll use the `@aws-sdk/client-sqs`\n\nv3 client. The code below can be run locally with the AWS CLI configured, or as part of a CDK deployment script.\n\n``` js\n// src/createQueue.ts\nimport {\n  SQSClient,\n  CreateQueueCommand,\n  CreateQueueCommandInput,\n} from \"@aws-sdk/client-sqs\";\n\n// The SQS client reads credentials from the environment (AWS_ACCESS_KEY_ID, etc.)\nconst sqs = new SQSClient({});\n\n/** Create a FIFO queue named \"agent-steps.fifo\". */\nasync function createFifoQueue() {\n  const params: CreateQueueCommandInput = {\n    QueueName: \"agent-steps.fifo\", // FIFO queues must end with .fifo\n    Attributes: {\n      // Guarantees exactly‑once processing when combined with content‑based deduplication\n      FifoQueue: \"true\",\n      // Enable content‑based deduplication so we can send the same payload twice\n      // without creating duplicate entries (useful for retries)\n      ContentBasedDeduplication: \"true\",\n      // Visibility timeout: how long a message stays invisible after a Lambda receives it\n      VisibilityTimeout: \"30\", // seconds – must be longer than Lambda execution time\n      // Long polling reduces empty receives (costs) by waiting up to 20 seconds\n      ReceiveMessageWaitTimeSeconds: \"20\",\n    },\n  };\n\n  const command = new CreateQueueCommand(params);\n  const response = await sqs.send(command);\n  console.log(\"FIFO queue URL:\", response.QueueUrl);\n}\n\ncreateFifoQueue().catch(console.error);\n```\n\nTip– The`VisibilityTimeout`\n\nmust belongerthan the longest Lambda execution that reads from this queue. If it’s shorter, the same message can become visible again while the first Lambda is still working, leading to duplicate external calls.\n\n**Why three Lambdas?**\n\nSeparating responsibilities keeps each function small, testable, and easier to reason about.\n\n``` js\n// src/plannerLambda.ts\nimport {\n  SQSClient,\n  ReceiveMessageCommand,\n  DeleteMessageCommand,\n  SendMessageCommand,\n} from \"@aws-sdk/client-sqs\";\nimport {\n  LambdaClient,\n  InvokeCommand,\n} from \"@aws-sdk/client-lambda\";\nimport { ToolRequest } from \"./types\";\n\nconst sqs = new SQSClient({});\nconst lambda = new LambdaClient({});\n\nconst PLAN_QUEUE_URL = process.env.PLAN_QUEUE_URL!;\nconst EXECUTOR_FUNCTION = process.env.EXECUTOR_FUNCTION!;\n\n/** Entry point for the Planner Lambda */\nexport const handler = async (): Promise<void> => {\n  // Pull one message at a time to keep ordering intact\n  const receive = new ReceiveMessageCommand({\n    QueueUrl: PLAN_QUEUE_URL,\n    MaxNumberOfMessages: 1,\n    WaitTimeSeconds: 20, // long polling\n    VisibilityTimeout: 30, // seconds – matches queue attribute\n  });\n\n  const { Messages } = await sqs.send(receive);\n  if (!Messages?.length) return; // nothing to do\n\n  const raw = Messages[0];\n  const body = JSON.parse(raw.Body!) as ToolRequest;\n\n  // Forward the request to the Executor Lambda\n  const invoke = new InvokeCommand({\n    FunctionName: EXECUTOR_FUNCTION,\n    Payload: Buffer.from(JSON.stringify(body)),\n    // Invoke synchronously so we can delete the message only after success\n    InvocationType: \"RequestResponse\",\n  });\n  await lambda.send(invoke);\n\n  // Remove the message now that processing succeeded\n  const del = new DeleteMessageCommand({\n    QueueUrl: PLAN_QUEUE_URL,\n    ReceiptHandle: raw.ReceiptHandle!,\n  });\n  await sqs.send(del);\n};\njs\n// src/executorLambda.ts\nimport {\n  SQSClient,\n  SendMessageCommand,\n} from \"@aws-sdk/client-sqs\";\nimport fetch from \"node-fetch\"; // native fetch works in Node 22, but keep explicit for clarity\nimport { ToolRequest } from \"./types\";\n\nconst sqs = new SQSClient({});\nconst RESPONSE_QUEUE_URL = process.env.RESPONSE_QUEUE_URL!;\n\n/** Simple executor that knows only how to call a weather API */\nexport const handler = async (event: any): Promise<void> => {\n  // The event payload is the ToolRequest JSON string from Planner\n  const request: ToolRequest = JSON.parse(event.body?.toString() ?? event);\n\n  // Very small example – a real implementation would handle errors, auth, etc.\n  const apiUrl = `https://api.open-meteo.com/v1/forecast?latitude=47.61&longitude=-122.33&current_weather=true`;\n  const apiResponse = await fetch(apiUrl);\n  const data = await apiResponse.json();\n\n  // Package the raw API response together with the original requestId\n  const responseMessage = {\n    requestId: request.requestId,\n    toolName: request.toolName,\n    result: data,\n  };\n\n  // Push the result onto the response queue for the Observer\n  const send = new SendMessageCommand({\n    QueueUrl: RESPONSE_QUEUE_URL,\n    MessageBody: JSON.stringify(responseMessage),\n    MessageGroupId: \"responses\", // required for FIFO queues\n    MessageDeduplicationId: request.requestId, // deduplicate retries\n  });\n  await sqs.send(send);\n};\njs\n// src/observerLambda.ts\nimport {\n  SQSClient,\n  ReceiveMessageCommand,\n  DeleteMessageCommand,\n  SendMessageCommand,\n} from \"@aws-sdk/client-sqs\";\nimport { ToolRequest } from \"./types\";\n\nconst sqs = new SQSClient({});\nconst RESPONSE_QUEUE_URL = process.env.RESPONSE_QUEUE_URL!;\nconst PLAN_QUEUE_URL = process.env.PLAN_QUEUE_URL!;\n\n/** Reads the API result, formats a new LLM prompt, and puts it back on the plan queue */\nexport const handler = async (): Promise<void> => {\n  const receive = new ReceiveMessageCommand({\n    QueueUrl: RESPONSE_QUEUE_URL,\n    MaxNumberOfMessages: 1,\n    WaitTimeSeconds: 20,\n    VisibilityTimeout: 30,\n  });\n\n  const { Messages } = await sqs.send(receive);\n  if (!Messages?.length) return;\n\n  const raw = Messages[0];\n  const payload = JSON.parse(raw.Body!);\n\n  // Create a friendly LLM prompt that includes the observed data\n  const nextPrompt = {\n    requestId: payload.requestId,\n    toolName: \"continue\", // special token that tells the LLM to keep going\n    args: {\n      observation: payload.result,\n      instruction: \"Summarize the weather and decide if we need an umbrella.\",\n    },\n  } satisfies ToolRequest;\n\n  // Send the new request back to the planner queue\n  const send = new SendMessageCommand({\n    QueueUrl: PLAN_QUEUE_URL,\n    MessageBody: JSON.stringify(nextPrompt),\n    MessageGroupId: \"plans\",\n    MessageDeduplicationId: nextPrompt.requestId,\n  });\n  await sqs.send(send);\n\n  // Delete the processed response message\n  const del = new DeleteMessageCommand({\n    QueueUrl: RESPONSE_QUEUE_URL,\n    ReceiptHandle: raw.ReceiptHandle!,\n  });\n  await sqs.send(del);\n};\n```\n\nHelpful tip– Keep each Lambda under 10 seconds for this demo. If you need longer processing, increase the queue’s`VisibilityTimeout`\n\naccordingly, and remember the gotcha about duplicates (see the next section).\n\n**Why the v3 SDK?**\n\nVersion 3 of the AWS SDK ships each service as a separate, tree‑shakable package (`@aws-sdk/client-sqs`\n\n, `@aws-sdk/client-lambda`\n\n). This reduces bundle size for Lambda layers and makes the import graph clearer for newcomers.\n\n**How to wire everything together** – a tiny “bootstrap” script that seeds the first plan message and shows the flow end‑to‑end.\n\n``` js\n// src/seed.ts\nimport {\n  SQSClient,\n  SendMessageCommand,\n} from \"@aws-sdk/client-sqs\";\nimport { ToolRequest } from \"./types\";\n\nconst sqs = new SQSClient({});\nconst PLAN_QUEUE_URL = process.env.PLAN_QUEUE_URL!;\n\n/** Kick‑starts the loop with an initial tool request */\nasync function seedPlan() {\n  const initialRequest: ToolRequest = {\n    requestId: \"req-\" + Date.now(),\n    toolName: \"weather\",\n    args: { location: \"Seattle, WA\" },\n  };\n\n  const cmd = new SendMessageCommand({\n    QueueUrl: PLAN_QUEUE_URL,\n    MessageBody: JSON.stringify(initialRequest),\n    MessageGroupId: \"plans\",\n    // Using the requestId as deduplication id prevents accidental re‑queues\n    MessageDeduplicationId: initialRequest.requestId,\n  });\n\n  const res = await sqs.send(cmd);\n  console.log(\"Seeded plan message, MessageId:\", res.MessageId);\n}\n\nseedPlan().catch(console.error);\n```\n\n**Key TypeScript pattern – satisfies**\n\n``` js\nconst nextPrompt = {\n  requestId: payload.requestId,\n  toolName: \"continue\",\n  args: { /* ... */ },\n} satisfies ToolRequest;\n```\n\nThe `satisfies`\n\nkeyword makes sure `nextPrompt`\n\nadheres to the `ToolRequest`\n\nshape **without** widening the literal types. This gives us compile‑time safety (the LLM never receives a malformed payload) while preserving exact string literals for downstream JSON schema checks.\n\nIn plain English– Think of the SDK as a set of toolboxes (SQS, Lambda). You pick the exact toolbox you need, and TypeScript’s`satisfies`\n\nis like a checklist that guarantees every tool you put in the box matches the required specification.\n\n**Why observability matters**\n\nWhen an autonomous agent runs unattended, you need to know whether each step succeeded, failed, or was retried. CloudWatch metrics, log statements, and DLQ (Dead‑Letter Queue) wiring give you that visibility.\n\n**The visibility‑timeout pitfall** – If a Lambda takes 45 seconds but the queue’s visibility timeout is 30 seconds, the message reappears after 30 seconds. The Lambda may still be finishing its work, so the same request gets handed to a second Lambda instance. The external API is called twice, which looks like the agent “hallucinated” an extra action.\n\n``` js\n// src/updateTimeout.ts\nimport {\n  SQSClient,\n  SetQueueAttributesCommand,\n} from \"@aws-sdk/client-sqs\";\n\nconst sqs = new SQSClient({});\nconst PLAN_QUEUE_URL = process.env.PLAN_QUEUE_URL!;\n\nasync function extendVisibility() {\n  const cmd = new SetQueueAttributesCommand({\n    QueueUrl: PLAN_QUEUE_URL,\n    Attributes: {\n      // Make the timeout 90 seconds – comfortably larger than any Lambda in this demo\n      VisibilityTimeout: \"90\",\n    },\n  });\n  await sqs.send(cmd);\n  console.log(\"Visibility timeout updated to 90 seconds\");\n}\n\nextendVisibility().catch(console.error);\njs\n// src/createDlq.ts\nimport {\n  SQSClient,\n  CreateQueueCommand,\n} from \"@aws-sdk/client-sqs\";\n\nconst sqs = new SQSClient({});\n\nasync function createDlq() {\n  // Simple standard queue to collect messages that exceeded max retries\n  const dlq = await sqs.send(\n    new CreateQueueCommand({ QueueName: \"agent-dlq\" })\n  );\n\n  const planQueue = await sqs.send(\n    new CreateQueueCommand({\n      QueueName: \"agent-steps.fifo\",\n      Attributes: {\n        FifoQueue: \"true\",\n        RedrivePolicy: JSON.stringify({\n          deadLetterTargetArn: dlq.QueueArn,\n          maxReceiveCount: \"5\", // after 5 attempts, move to DLQ\n        }),\n      },\n    })\n  );\n\n  console.log(\"DLQ URL:\", dlq.QueueUrl);\n  console.log(\"Plan queue URL:\", planQueue.QueueUrl);\n}\ncreateDlq().catch(console.error);\n```\n\n**Logging pattern** – Insert a single `console.log`\n\nat the start and end of each Lambda, including `requestId`\n\n. CloudWatch will automatically group logs by request ID, making it easy to trace a full plan‑act‑observe cycle.\n\nKey takeaway– Always set the SQS visibility timeoutlongerthan the Lambda’s maximum execution time. Pair that with a DLQ and you’ll avoid duplicate external calls and have a clean audit trail.\n\n`@aws-sdk/client-sqs`\n\n, `@aws-sdk/client-lambda`\n\n) reduce bundle size and make imports clearer for beginners.\n`satisfies`\n\noperator validates message shapes at compile time without losing literal types.\nWith these building blocks you can assemble a reliable AI‑agent loop that’s transparent, debuggable, and cost‑predictable—no EventBridge Pipes required. Happy coding!\n\nTransparency noticeThis article was written with the help of an AI system —\n\n[Groq](GPT OSS 120B).\n\nPublished:2026-08-24 ·Primary focus:SQSAll code blocks are intended to be correct and runnable, but please verify them\n\nagainst the official docs for the tools mentioned before using in production.\n\nFind an error? Drop a comment — corrections are always welcome.", "url": "https://wpnews.pro/news/ai-agents-with-sqs-and-lambda-build-a-plan-act-observe-loop-in-node-js", "canonical_source": "https://dev.to/dineshgowtham/ai-agents-with-sqs-and-lambda-build-a-plan-act-observe-loop-in-nodejs-32n7", "published_at": "2026-08-24 11:54:43+00:00", "updated_at": "2026-08-24 12:13:41.557967+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["AWS", "SQS", "Lambda", "Node.js", "TypeScript"], "alternates": {"html": "https://wpnews.pro/news/ai-agents-with-sqs-and-lambda-build-a-plan-act-observe-loop-in-node-js", "markdown": "https://wpnews.pro/news/ai-agents-with-sqs-and-lambda-build-a-plan-act-observe-loop-in-node-js.md", "text": "https://wpnews.pro/news/ai-agents-with-sqs-and-lambda-build-a-plan-act-observe-loop-in-node-js.txt", "jsonld": "https://wpnews.pro/news/ai-agents-with-sqs-and-lambda-build-a-plan-act-observe-loop-in-node-js.jsonld"}}