DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

How to Build an AI Coding Assistant with Amazon Bedrock Claude Function Calling in Node.js

Ever wondered how to turn Claude on Bedrock into a real‑time pair programmer that can edit your code for you? In a few minutes you’ll see a complete, type‑safe example that calls Claude, parses its structured tool output, and stitches the result into a live workflow. No magic black box – just plain Node.js, TypeScript, and AWS services you already trust.


Why Claude Function Calling Matters for Coding Assistants

When you ask a language model to “fix this bug”, the model usually replies with a block of text that looks like code. Your application then has to trust that the text is correct, extract it, and apply it. If the model makes a tiny syntax mistake, the whole process crashes.

Function calling solves that problem. Instead of returning raw text, Claude can emit a structured JSON payload that describes the exact edit it wants to make (file name, line range, replacement text, etc.). Your code can validate that payload against a TypeScript type, guaranteeing that the assistant never hands you malformed data.

In plain English: Function calling turns “Claude says some code” into “Claude tells us exactly what to change, in a format we can verify”.

Analogy – the mechanic’s checklist

Imagine you bring a car to a mechanic. A casual conversation (“I think the engine is noisy”) leaves you guessing. A proper checklist (“Replace spark plug #3, tighten belt A”) gives you a precise, actionable list. Function calling is the checklist for code changes.


Setting Up Bedrock Access and IAM Permissions

Before any code runs, you need two things:

  1. Bedrock permission – the ability to call the InvokeModel operation on the Claude model you choose.
  2. IAM permission – an AWS Identity and Access Management (IAM) role that your Node.js process assumes, with policies that let it talk to Bedrock and start a Step Functions state machine.

1. Create an IAM role for the assistant

import {
  IAMClient,
  CreateRoleCommand,
  PutRolePolicyCommand,
} from "@aws-sdk/client-iam";

const iam = new IAMClient({ region: "us-east-1" });

async function createAssistantRole() {
  // 1️⃣ Create a role that can be assumed by Lambda or an EC2 instance
  const createRole = new CreateRoleCommand({
    RoleName: "BedrockCodingAssistantRole",
    AssumeRolePolicyDocument: JSON.stringify({
      Version: "2012-10-17",
      Statement: [
        {
          Effect: "Allow",
          Principal: { Service: "lambda.amazonaws.com" },
          Action: "sts:AssumeRole",
        },
      ],
    }),
  });
  const role = await iam.send(createRole);

  // 2️⃣ Attach the minimum permissions we need
  const policy = new PutRolePolicyCommand({
    RoleName: role.Role.RoleName!,
    PolicyName: "BedrockAndStepFunctionsPolicy",
    PolicyDocument: JSON.stringify({
      Version: "2012-10-17",
      Statement: [
        // Bedrock InvokeModel permission
        {
          Effect: "Allow",
          Action: "bedrock:InvokeModel",
          Resource: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-v2",
        },
        // Step Functions start execution permission
        {
          Effect: "Allow",
          Action: "states:StartExecution",
          Resource: "arn:aws:states:us-east-1:123456789012:stateMachine:LintAndFormatSM",
        },
      ],
    }),
  });
  await iam.send(policy);
  console.log(`Created role ${role.Role.RoleName}`);
}
createAssistantRole().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

What the code does

  • CreateRoleCommand builds an IAM role that Lambda (or any AWS compute service you choose) can assume.
  • PutRolePolicyCommand adds an inline policy granting exactly the two actions we need: bedrock:InvokeModel for Claude and states:StartExecution for Step Functions.

Tip: IAM evaluation is first‑deny, then‑allow. If any other policy (for example a Service Control Policy at the organization level) denies bedrock:InvokeModel, your role will still be blocked.

2. Attach the role to your runtime

If you run the code from a local machine, configure the AWS CLI with a profile that can sts:AssumeRole into the role you just created. For a Lambda function, set the role ARN in the console or CloudFormation.

Gotchas you’ll hit

Area Gotcha Why it matters
IAM Explicit deny in a separate policy overrides our allow Your code will get AccessDeniedException even though the inline policy looks correct
IAM Default AssumeRole session duration is 1 hour Long‑running lint‑and‑format jobs that exceed an hour will silently stop because the token expires
Bedrock Token limit is per minute, not per request Burst traffic can trigger ThrottlingException even if each request is tiny

Writing the Node.js TypeScript Wrapper with satisfies

The heart of the assistant is a tiny function that:

  1. Sends a prompt to Claude asking it to produce a tool call (the JSON edit description).
  2. Receives the response, which is wrapped inside a string field called toolResult.
  3. Parses that string as JSON and verifies it matches a TypeScript interface using the satisfies operator (available from TS 4.9).

Define the shape of Claude’s tool response

// The exact data Claude will return when we ask it to edit code
interface CodeEditToolResult {
  filePath: string;           // Relative path of the file to edit
  startLine: number;          // 1‑based line number where the edit begins
  endLine: number;            // 1‑based line number where the edit ends
  replacement: string;        // The new code that should replace the range
}

// Helper type to assert that the parsed JSON conforms to the interface
type ValidEdit = {
  // Using the `satisfies` keyword ensures the object matches CodeEditToolResult
  // at compile time but does not widen the type at runtime.
  edit: unknown;
} & { edit: CodeEditToolResult } satisfies { edit: CodeEditToolResult };
Enter fullscreen mode Exit fullscreen mode

The wrapper that talks to Bedrock

import {
  BedrockRuntimeClient,
  InvokeModelCommand,
} from "@aws-sdk/client-bedrock-runtime";

const bedrock = new BedrockRuntimeClient({ region: "us-east-1" });

/**
 * Sends a code snippet to Claude and asks for a structured edit.
 *
 * @param snippet The raw code the developer wants fixed.
 * @returns A validated CodeEditToolResult object.
 */
export async function requestCodeEdit(snippet: string): Promise<CodeEditToolResult> {
  // 1️⃣ Build the prompt that tells Claude we expect a tool call
  const prompt = `
You are a pair‑programmer. Given the following JavaScript snippet, return a JSON object
that describes the exact edit needed. Use the following schema:
{
  "filePath": string,
  "startLine": number,
  "endLine": number,
  "replacement": string
}
Wrap the JSON in a tool call named "applyEdit". Do NOT add extra text.
---
${snippet}
`;

  // 2️⃣ Call Claude via Bedrock. Important: request JSON output.
  const command = new InvokeModelCommand({
    // Model identifier for Claude v2 (replace with the version you have access to)
    modelId: "anthropic.claude-v2",
    // The body must be a JSON string, per Bedrock API contract
    body: JSON.stringify({
      prompt,
      temperature: 0.0,          // deterministic output for tooling
      maxTokens: 1024,
    }),
    // Crucial: tell Bedrock we want JSON back
    contentType: "application/json",
    accept: "application/json",   // <‑‑ the gotcha: missing this returns plain text
  });

  const response = await bedrock.send(command);
  // The response payload is a Uint8Array; convert to string
  const payload = Buffer.from(response.body).toString("utf-8");
  const parsed = JSON.parse(payload) as { result: string; toolResult?: string };

  // 3️⃣ The toolResult field contains the JSON string we asked for
  if (!parsed.toolResult) {
    throw new Error("Claude did not return a toolResult. Check your prompt or headers.");
  }

  // 4️⃣ Parse the toolResult string into an object
  const rawResult = JSON.parse(parsed.toolResult) as unknown;

  // 5️⃣ Type‑safety check using `satisfies`
  const validated: ValidEdit = { edit: rawResult } satisfies { edit: CodeEditToolResult };
  return validated.edit;
}
Enter fullscreen mode Exit fullscreen mode

Why each piece matters

  • The accept: "application/json" header tells Bedrock to put the tool output inside toolResult. Forgetting it gives you plain text, which breaks the JSON parsing step.
  • The satisfies keyword lets the TypeScript compiler verify that rawResult matches CodeEditToolResult without casting (as). If Claude ever adds an extra field or misspells a key, the compiler will warn you during development.

Key takeaway: Using satisfies gives you compile‑time confidence while still allowing the runtime to accept any JSON that matches the shape.


Orchestrating Multi‑Step Tool Calls with Step Functions

A single Claude call can suggest an edit, but a good coding assistant also runs linters and formatters to make sure the change follows project standards. AWS Step Functions let you glue together several micro‑services into a reliable, retry‑aware workflow.

What is Step Functions?

Step Functions is a serverless state machine service. Think of it as a flowchart where each box is a Lambda (or container) that does one piece of work, and arrows dictate the order and error handling.

Define the state machine (JSON Amazon States Language)

{
  "Comment": "Lint, format, and return edited code",
  "StartAt": "RunLint",
  "States": {
    "RunLint": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:LintFunction",
      "ResultPath": "$.lintResult",
      "Next": "RunFormatter"
    },
    "RunFormatter": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:FormatterFunction",
      "ResultPath": "$.formatResult",
      "End": true
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Explanation

  • RunLint invokes a Lambda that runs ESLint (or any linter) on the edited file.
  • RunFormatter runs Prettier (or another formatter) on the lint‑clean code.
  • The state machine returns the final, cleaned code to the caller.

Plain English: Step Functions is the conductor that makes sure each instrument (lint, format) plays in the right order and recovers from mistakes.

Deploy the state machine with the SDK

import {
  SFNClient,
  CreateStateMachineCommand,
} from "@aws-sdk/client-sfn";

const sfn = new SFNClient({ region: "us-east-1" });

async function createLintAndFormatSM() {
  const definition = JSON.stringify({
    Comment: "Lint, format, and return edited code",
    StartAt: "RunLint",
    States: {
      RunLint: {
        Type: "Task",
        Resource: "arn:aws:lambda:us-east-1:123456789012:function:LintFunction",
        ResultPath: "$.lintResult",
        Next: "RunFormatter",
      },
      RunFormatter: {
        Type: "Task",
        Resource: "arn:aws:lambda:us-east-1:123456789012:function:FormatterFunction",
        ResultPath: "$.formatResult",
        End: true,
      },
    },
  });

  const command = new CreateStateMachineCommand({
    name: "LintAndFormatSM",
    roleArn: "arn:aws:iam::123456789012:role/StepFunctionsExecutionRole",
    definition,
  });

  const result = await sfn.send(command);
  console.log(`Created state machine ${result.stateMachineArn}`);
}
createLintAndFormatSM().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Gotcha reminder – the IAM role used by Step Functions must have lambda:InvokeFunction permission for both Lambda ARNs, otherwise the execution will stop with an AccessDenied error.


Putting It All Together: A Live Coding Assistant Demo

Now we combine everything:

  1. Receive a developer’s code snippet (e.g., via an HTTP endpoint).
  2. Call requestCodeEdit to get a structured edit from Claude.
  3. Start the Step Functions state machine, passing the edited code.
  4. Return the final, linted, formatted code to the caller.

Minimal Express server (TypeScript)

import express from "express";
import { requestCodeEdit } from "./claudeWrapper";
import {
  SFNClient,
  StartExecutionCommand,
} from "@aws-sdk/client-sfn";

const app = express();
app.use(express.json());

const sfn = new SFNClient({ region: "us-east-1" });
const STATE_MACHINE_ARN = "arn:aws:states:us-east-1:123456789012:stateMachine:LintAndFormatSM";

app.post("/edit", async (req, res) => {
  const { code, filePath } = req.body;
  if (typeof code !== "string" || typeof filePath !== "string") {
    return res.status(400).json({ error: "Invalid payload" });
  }

  try {
    // 1️⃣ Ask Claude for the edit description
    const edit = await requestCodeEdit(code);

    // 2️⃣ Build the input for Step Functions – include the replacement code
    const sfInput = {
      filePath: edit.filePath,
      startLine: edit.startLine,
      endLine: edit.endLine,
      replacement: edit.replacement,
    };

    // 3️⃣ Start the state machine; it will run lint → format
    const startCmd = new StartExecutionCommand({
      stateMachineArn: STATE_MACHINE_ARN,
      input: JSON.stringify(sfInput),
    });
    const execution = await sfn.send(startCmd);

    // 4️⃣ Poll for completion (simple example – in production use callbacks or SNS)
    const poll = async () => {
      const { describeExecution } = await import("@aws-sdk/client-sfn");
      const describeCmd = new describeExecution.Command({
        executionArn: execution.executionArn,
      });
      const status = await sfn.send(describeCmd);
      if (status.status === "RUNNING") {
        await new Promise((r) => setTimeout(r, 500));
        return poll();
      }
      if (status.status === "FAILED") {
        throw new Error(`Step Functions failed: ${status.error}`);
      }
      return JSON.parse(status.output!);
    };

    const finalResult = await poll();

    // 5️⃣ Return the polished code to the developer
    res.json({ editedCode: finalResult.formatResult });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: (err as Error).message });
  }
});

app.listen(3000, () => console.log("Assistant listening on http://localhost:3000"));
Enter fullscreen mode Exit fullscreen mode

Explanation of the flow

  • The HTTP /edit endpoint receives raw code and a file path.
  • requestCodeEdit talks to Claude, ensuring the accept header is set so the JSON lands in toolResult.
  • The returned CodeEditToolResult is passed as the input payload for the Step Functions state machine.
  • The state machine runs the LintFunction and FormatterFunction micro‑services (you can implement them as simple Lambdas that call ESLint/Prettier).
  • Once the state machine finishes, the server replies with the cleaned, edited code.

Tip: For production workloads replace the naive polling loop with an SNS topic or EventBridge rule that notifies your service when the execution finishes.

Handling rate limits and streaming responses

Bedrock limits tokens per minute, not per request. If you expect many developers to hit the endpoint at once, add a simple token bucket middleware:

import rateLimit from "express-rate-limit";

const limiter = rateLimit({
  windowMs: 60_000, // 1 minute
  max: 30, // max 30 requests per minute per IP
  standardHeaders: true,
  legacyHeaders: false,
});

app.use(limiter);
Enter fullscreen mode Exit fullscreen mode

If you need streaming (e.g., streaming Claude’s partial thoughts), Bedrock returns Server‑Sent Events (SSE). Node.js does not parse SSE automatically, so you must read the response line‑by‑line and split on \n\n. For the purpose of a coding assistant, a single synchronous response is usually sufficient, but be aware that the streaming feature exists and has its own parsing complexity.


The Takeaway

What you now have: a fully type‑safe, multi‑step AI coding assistant built with Amazon Bedrock, IAM, and Step Functions, all driven from a modest Node.js/TypeScript codebase.

  • Function calling turns raw text into verified JSON, eliminating fragile string parsing.
  • IAM policies must be explicit: allow bedrock:InvokeModel and states:StartExecution, and remember that any explicit deny wins.
  • accept: application/json is non‑negotiable; without it Claude’s response lands in a plain‑text field, breaking the tool‑result workflow.
  • The satisfies keyword gives compile‑time safety without runtime casts, catching schema mismatches early.
  • Step Functions orchestrates linting and formatting, providing retries, error handling, and a clear separation of concerns.
  • Rate limits are per‑minute tokens, so guard your endpoint with a bucket or queue to avoid sudden throttling.

With these building blocks, you can extend the assistant to handle multiple files, integrate unit‑test generation, or even hook into your CI pipeline. The core pattern—prompt → structured tool output → validated JSON → orchestrated micro‑services—remains the same, and it scales nicely as your AI‑augmented development workflow grows. Happy coding!


Transparency notice

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

Published: 2026-09-02 · Primary focus: Bedrock

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)