DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

Claude AI Agent on Lambda: Build the Plan‑Act‑Observe Loop in Node.js

Imagine an AI coding assistant that can think, act, and learn—running entirely on a serverless function. In minutes you’ll have a self‑contained Claude agent that processes user prompts, calls external tools, and streams back answers without managing servers.

Why Serverless AI Agents Matter

Serverless platforms such as AWS Lambda give you compute that starts only when a request arrives and disappears when it finishes. That model matches the way a conversation with an AI works: each user turn is a short burst of work that can be handled independently.

Why does that matter?

  • Cost predictability – you pay for the milliseconds the function runs, not for idle servers.
  • Scalability – Lambda can spin up dozens of copies at the same time, so many users can chat with the same agent without you writing load‑balancing code.
  • Operational simplicity – there is no operating system to patch, no container image to keep warm (unless you choose provisioned concurrency).

Think of a serverless AI agent like a pop‑up restaurant. The kitchen (Lambda) only opens when a customer (the API request) walks in, cooks the dish (runs the model), serves it, and then closes. You never have to keep the kitchen staffed 24/7, but you can still serve hundreds of diners at once because the restaurant chain can open many temporary locations.

In plain English: A serverless AI agent lets you run sophisticated conversational logic without owning any long‑running machines.

Setting Up a TypeScript Lambda Handler with Native Fetch

Node 22 ships with a built‑in fetch function, so you don’t need a third‑party HTTP client to call Claude’s API. The handler below is written in TypeScript, the language many JavaScript developers already know, and it uses only the standard library plus the AWS SDK packages you’ll need later.

Tip: If you add a layer that contains an older version of Node or a module that forces require('esm'), Lambda will silently fail to load the layer. Keep the runtime clean and let the built‑in fetch do the work.

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

// The Lambda runtime already provides fetch in Node 22
// No extra dependency needed
export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  // Parse the incoming JSON body; the client sends { "message": "..." }
  const body = event.body ? JSON.parse(event.body) : {};
  const userMessage = body.message ?? '';

  // Prepare the request payload for Claude's chat/completions endpoint
  const claudePayload = {
    model: 'claude-3-5-sonnet-20240620',
    messages: [{ role: 'user', content: userMessage }],
    // Setting `stream: true` lets us forward Claude's partial answers directly
    stream: true,
  };

  // Call Claude – note the use of the native fetch API
  const response = await fetch(
    'https://api.anthropic.com/v1/chat/completions',
    {
      method: 'POST',
      headers: {
        // Your Anthropic API key must be stored securely in Lambda env vars
        'x-api-key': process.env.ANTHROPIC_API_KEY!,
        'Content-Type': 'application/json',
        Accept: 'text/event-stream',
      },
      body: JSON.stringify(claudePayload),
    }
  );

  // Lambda response streaming requires the right content‑type header
  // Otherwise the platform buffers the whole payload before sending it back
  const streamingHeaders = {
    'Content-Type': 'text/event-stream',
    // Prevent API Gateway from adding its own chunking
    'Transfer-Encoding': 'chunked',
  };

  // Forward Claude's raw stream directly to the caller
  const bodyStream = response.body as ReadableStream<Uint8Array>;

  // Return the streaming response to API Gateway
  return {
    statusCode: 200,
    headers: streamingHeaders,
    body: bodyStream, // API Gateway knows how to handle a stream object
    isBase64Encoded: false,
  };
};
Enter fullscreen mode Exit fullscreen mode

In this snippet we:

  1. Extract the user’s message from the API Gateway event.
  2. Build a minimal payload that tells Claude which model to use and that we want a streamed answer.
  3. Call Claude with native fetch.
  4. Set the HTTP headers that tell API Gateway to treat the output as a live stream.

Key takeaway: Using native fetch and correct streaming headers lets a Lambda function behave like a real‑time conduit between a client and Claude.

Implementing the Plan‑Act‑Observe Loop

The “Plan‑Act‑Observe” pattern is a three‑step cycle that lets an AI decide what to do, do it, and then look at the result before answering the user.

  • Plan – Claude receives the user prompt and decides whether it can answer directly or needs to call an external tool.
  • Act – Your code executes the requested tool (for example, a Lambda that runs a shell command).
  • Observe – The result of the tool is fed back to Claude so it can incorporate the new information into its final reply.

Why split the work this way? Because Claude’s model is stateless; it only knows what you send it in the request. If you want it to “think” about a command result, you have to send that result as a new message in the same conversation.

Below is a compact implementation of the loop. It assumes the Claude response includes a tool_calls array when a tool is needed.

import {
  InvokeCommand,
  LambdaClient,
} from '@aws-sdk/client-lambda';
import { Readable } from 'stream';

interface ClaudeChunk {
  // A simplified shape of Claude's streaming event
  type: 'message' | 'tool_call' | 'error';
  content?: string;
  tool_calls?: Array<{ name: string; arguments: string }>;
}

// Helper to read the streamed chunks from Claude
async function collectClaudeChunks(
  stream: ReadableStream<Uint8Array>
): Promise<ClaudeChunk[]> {
  const reader = stream.getReader();
  const chunks: ClaudeChunk[] = [];

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    // Each chunk arrives as a UTF‑8 encoded line; parse it
    const text = Buffer.from(value).toString('utf‑8').trim();
    if (!text) continue;
    try {
      const json = JSON.parse(text);
      chunks.push(json as ClaudeChunk);
    } catch {
      // Non‑JSON lines can be ignored for this simple demo
    }
  }
  return chunks;
}

// Main loop handler – called from the API Gateway entry point
export async function runPlanActObserve(
  userMessage: string,
  lambdaClient: LambdaClient
): Promise<string> {
  // 1️⃣ PLAN – ask Claude what it wants to do
  const firstResponse = await fetchClaude(userMessage, false);
  const firstChunks = await collectClaudeChunks(firstResponse.body!);
  const toolCallChunk = firstChunks.find(c => c.type === 'tool_call');

  // If Claude didn’t request a tool, just return its answer
  if (!toolCallChunk) {
    const answer = firstChunks
      .filter(c => c.type === 'message')
      .map(c => c.content)
      .join('');
    return answer;
  }

  // 2️⃣ ACT – invoke the helper Lambda that actually runs the command
  const { name, arguments: argsJson } = toolCallChunk.tool_calls![0];
  const args = JSON.parse(argsJson);

  const invokeCmd = new InvokeCommand({
    FunctionName: process.env.HELPER_LAMBDA_NAME!,
    Payload: Buffer.from(JSON.stringify({ command: name, args })),
  });

  const invokeResult = await lambdaClient.send(invokeCmd);
  const toolResult = Buffer.from(invokeResult.Payload!).toString('utf‑8');

  // 3️⃣ OBSERVE – feed the tool result back to Claude and ask for final answer
  const observationMessage = `Tool result:\n${toolResult}`;
  const secondResponse = await fetchClaude(userMessage, true, observationMessage);
  const secondChunks = await collectClaudeChunks(secondResponse.body!);
  const finalAnswer = secondChunks
    .filter(c => c.type === 'message')
    .map(c => c.content)
    .join('');

  return finalAnswer;
}

/**
 * Small wrapper around Claude’s API.
 * `includeObservation` tells Claude to treat `observation` as a system‑level message.
 */
async function fetchClaude(
  userMessage: string,
  includeObservation: boolean,
  observation?: string
) {
  const messages: any[] = [{ role: 'user', content: userMessage }];
  if (includeObservation && observation) {
    messages.unshift({ role: 'assistant', content: observation });
  }

  const payload = {
    model: 'claude-3-5-sonnet-20240620',
    messages,
    stream: true,
  };

  return fetch('https://api.anthropic.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.ANTHROPIC_API_KEY!,
      'Content-Type': 'application/json',
      Accept: 'text/event-stream',
    },
    body: JSON.stringify(payload),
  });
}
Enter fullscreen mode Exit fullscreen mode

Key points in the code:

  • collectClaudeChunks reads the streaming response line‑by‑line and builds an array of parsed JSON objects.
  • The first call to Claude (fetchClaude with includeObservation = false) asks the model to plan. If the model returns a tool_call, we move to the Act step.
  • InvokeCommand from @aws-sdk/client-lambda runs a helper Lambda. The helper could be anything – a shell runner, a database query, or an external API wrapper.
  • The result of that helper is turned into a plain‑text observation and sent back to Claude as a new message. Claude then produces the final answer, which we return to the user.

Analogy: Think of the loop as a detective asking a lab technician for evidence. First the detective (Claude) decides what evidence is needed, then the technician (helper Lambda) runs the test, and finally the detective looks at the test result before giving the verdict.

Handling Claude’s Tool Calls Inside Lambda

Claude signals that it needs external help by returning a partial response that contains a tool_calls field. If you treat the Lambda invocation as a one‑off request and immediately return that partial payload, you lose the context needed for the Observe step.

The solution is to keep the conversation state somewhere that survives across the two internal calls (the first Claude request and the second after the tool finishes). Two common patterns are:

  1. EventBridge payload – embed the conversation ID and any intermediate data into the EventBridge event that triggers the second Lambda step.
  2. DynamoDB – store the full message history under a unique key, then read it back when the observation arrives.

Below is a minimalist example that stores the partial conversation in DynamoDB. It also shows a subtle gotcha: Lambda streaming only works when you set the Content-Type to text/event-stream; otherwise API Gateway buffers the whole response, breaking real‑time interactivity.

import {
  DynamoDBClient,
  PutItemCommand,
  GetItemCommand,
} from '@aws-sdk/client-dynamodb';
import {
  InvokeCommand,
  LambdaClient,
} from '@aws-sdk/client-lambda';

// Table name is set in the environment
const db = new DynamoDBClient({});
const lambda = new LambdaClient({});

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  const { message, conversationId } = JSON.parse(event.body ?? '{}');

  // Step 1: ask Claude for a plan
  const firstResp = await fetchClaude(message);
  const chunks = await collectClaudeChunks(firstResp.body!);
  const toolChunk = chunks.find(c => c.type === 'tool_call');

  // If no tool needed, just stream the answer back
  if (!toolChunk) {
    const answer = chunks
      .filter(c => c.type === 'message')
      .map(c => c.content)
      .join('');
    return {
      statusCode: 200,
      headers: { 'Content-Type': 'text/plain' },
      body: answer,
    };
  }

  // Persist the intermediate state so the second pass can retrieve it
  await db.send(
    new PutItemCommand({
      TableName: process.env.STATE_TABLE!,
      Item: {
        conversationId: { S: conversationId },
        // Store the original user message and the tool name for later reference
        userMessage: { S: message },
        toolName: { S: toolChunk.tool_calls![0].name },
        toolArgs: { S: toolChunk.tool_calls![0].arguments },
      },
    })
  );

  // Invoke helper Lambda (Act)
  const invoke = new InvokeCommand({
    FunctionName: process.env.HELPER_LAMBDA_NAME!,
    Payload: Buffer.from(
      JSON.stringify({
        command: toolChunk.tool_calls![0].name,
        args: JSON.parse(toolChunk.tool_calls![0].arguments),
      })
    ),
  });
  const toolResult = await lambda.send(invoke);
  const resultText = Buffer.from(toolResult.Payload!).toString('utf‑8');

  // Retrieve stored state (Observe)
  const stored = await db.send(
    new GetItemCommand({
      TableName: process.env.STATE_TABLE!,
      Key: { conversationId: { S: conversationId } },
    })
  );

  const observation = `Tool "${stored.Item?.toolName?.S}" returned:\n${resultText}`;
  const finalResp = await fetchClaude(stored.Item?.userMessage?.S ?? '', true, observation);
  const finalChunks = await collectClaudeChunks(finalResp.body!);
  const finalAnswer = finalChunks
    .filter(c => c.type === 'message')
    .map(c => c.content)
    .join('');

  // Stream the final answer back to the client
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'text/plain' },
    body: finalAnswer,
  };
};
Enter fullscreen mode Exit fullscreen mode

Gotchas highlighted in the code

  • Partial tool‑call flag – If you ignore tool_calls and return the raw stream, the client receives an unfinished answer and the loop stops.
  • Stateless Lambda – Because each invocation starts fresh, you must persist any data you need for the next step (here we use DynamoDB).
  • Streaming headers – Forgetting Transfer-Encoding: chunked or using the wrong Content-Type forces API Gateway to buffer, adding seconds of latency.

Helpful tip: When you see Claude’s response stop early, check whether the JSON includes a tool_calls array. That’s the signal that your Lambda needs to keep working, not finish.

Scheduling Periodic Agent Checks with EventBridge

Sometimes you want the agent to run on a schedule – for example, a nightly audit that asks Claude to summarize log files stored in S3. EventBridge provides a built‑in scheduler that can fire a Lambda at a fixed rate or cron expression.

Why use EventBridge instead of a setTimeout in code?

  • Reliability – EventBridge guarantees delivery (with retries) even if the Lambda is throttled.
  • Visibility – All scheduled events appear in the console, making it easy to audit.
  • Separation of concerns – The Lambda stays focused on the AI logic; scheduling lives in a dedicated service.

Below is a tiny script that creates a rule that runs every hour and points it at the same Lambda we built earlier. It uses the @aws-sdk/client-eventbridge package and shows a couple of the known gotchas.

import {
  EventBridgeClient,
  PutRuleCommand,
  PutTargetsCommand,
} from '@aws-sdk/client-eventbridge';

// Create a client that talks to the EventBridge service
const eb = new EventBridgeClient({});

/**
 * Creates or updates a scheduled rule that triggers `targetArn` every hour.
 * Returns the ARN of the created rule.
 */
export async function scheduleHourlyCheck(targetArn: string): Promise<string> {
  // 1️⃣ Define the rule – a cron expression for "at minute 0 of every hour"
  const ruleName = 'HourlyClaudeAudit';
  const putRule = new PutRuleCommand({
    Name: ruleName,
    ScheduleExpression: 'cron(0 * * * ? *)',
    State: 'ENABLED',
    Description: 'Runs Claude agent hourly to process audit logs',
  });
  const ruleResult = await eb.send(putRule);
  const ruleArn = ruleResult.RuleArn!;

  // 2️⃣ Attach the Lambda as the target
  //   Gotcha: EventBridge delivers events to Lambda with a 256 KB payload limit.
  //   If you need more data, store it in S3 and send the S3 key instead.
  const putTargets = new PutTargetsCommand({
    Rule: ruleName,
    Targets: [
      {
        Id: 'ClaudeAgentTarget',
        Arn: targetArn,
        // Pass a small constant payload; the Lambda can fetch more data on its own.
        Input: JSON.stringify({ scheduled: true, trigger: 'hourly' }),
      },
    ],
  });
  await eb.send(putTargets);

  // 3️⃣ (Optional) Add permission for EventBridge to invoke the Lambda
  //   This is required only the first time you wire the two services.
  //   The SDK call is omitted for brevity; see AWS docs for `addPermission`.

  return ruleArn;
}

// Example usage (run once during deployment)
(async () => {
  const lambdaArn = process.env.AI_AGENT_LAMBDA_ARN!;
  const ruleArn = await scheduleHourlyCheck(lambdaArn);
  console.log('Scheduled rule ARN:', ruleArn);
})();
Enter fullscreen mode Exit fullscreen mode

Gotchas to keep in mind

  • Filter evaluation limit – If you add a complex event pattern to the rule, EventBridge stops evaluating after 5 seconds and the rule silently drops events. Keep filters simple.
  • Timezone quirks – The scheduler works in UTC. When you need a local timezone, calculate the offset yourself or use a Lambda that adjusts the time.
  • Delivery delay under load – During heavy traffic the service can add 30 seconds or more of latency. For time‑critical jobs, consider a direct CloudWatch alarm instead.

In plain English: EventBridge gives you a reliable alarm clock for your AI agent, but you must respect its payload size and timing nuances.

The Takeaway

  • Serverless platforms let an AI agent run only when a user talks to it, keeping costs low and scaling automatically.
  • Native fetch in Node 22 removes the need for extra HTTP libraries and works well with Claude’s streaming API.
  • The Plan‑Act‑Observe loop separates decision making, execution, and reflection, turning a single Claude call into a multi‑step workflow.
  • Claude’s tool‑call flag is a cue to keep the Lambda alive; persisting state in DynamoDB (or passing it via EventBridge) prevents loss of context.
  • Streaming responses require exact Content-Type and Transfer-Encoding headers; otherwise API Gateway will buffer and add latency.
  • EventBridge’s scheduler can fire the same Lambda on a regular cadence, but watch out for filter limits, UTC time, and payload size restrictions.

Now you have a complete, production‑ready pattern for building a Claude‑powered AI assistant that lives inside a single Lambda function, calls other Lambdas when needed, and can be triggered on demand or on a schedule. Happy coding!


Transparency notice

This article was written with the help of an AI system — Groq (GPT OSS 120B).

Published: 2026-09-08 · Primary focus: Lambda

All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.

Find an error? Drop a comment — corrections are always welcome.

Top comments (0)