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:
-
Bedrock permission – the ability to call the
InvokeModeloperation on the Claude model you choose. - 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);
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:InvokeModelfor Claude andstates:StartExecutionfor 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:
- Sends a prompt to Claude asking it to produce a tool call (the JSON edit description).
- Receives the response, which is wrapped inside a string field called
toolResult. - Parses that string as JSON and verifies it matches a TypeScript interface using the
satisfiesoperator (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 };
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;
}
Why each piece matters
- The
accept: "application/json"header tells Bedrock to put the tool output insidetoolResult. Forgetting it gives you plain text, which breaks the JSON parsing step. - The
satisfieskeyword lets the TypeScript compiler verify thatrawResultmatchesCodeEditToolResultwithout casting (as). If Claude ever adds an extra field or misspells a key, the compiler will warn you during development.
Key takeaway: Using
satisfiesgives 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
}
}
}
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);
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:
- Receive a developer’s code snippet (e.g., via an HTTP endpoint).
- Call
requestCodeEditto get a structured edit from Claude. - Start the Step Functions state machine, passing the edited code.
- 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"));
Explanation of the flow
- The HTTP
/editendpoint receives raw code and a file path. -
requestCodeEdittalks to Claude, ensuring theacceptheader is set so the JSON lands intoolResult. - The returned
CodeEditToolResultis passed as the input payload for the Step Functions state machine. - The state machine runs the
LintFunctionandFormatterFunctionmicro‑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);
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:InvokeModelandstates:StartExecution, and remember that any explicit deny wins. -
accept: application/jsonis non‑negotiable; without it Claude’s response lands in a plain‑text field, breaking the tool‑result workflow. -
The
satisfieskeyword 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)