AI

Building an AI Code Companion: Vercel AI SDK, Tool Calling, and Local Sandboxes

DD
Ankur Ishwar
7 min read Updated Sep 6, 2026
Building an AI Code Companion with Vercel AI SDK and Tool Calling

Why Copy-Pasting Code into Web Chats Fails

General-purpose browser chat windows make poor programming partners. When working on a non-trivial project, pasting individual snippets into a web prompt strips away file tree relationships, package manifests, and local compiler feedback. The assistant hallucinates missing exports, suggests deprecated package APIs, and forgets your linting constraints after three conversational turns.

A true code companion does not live in an isolated browser tab. It runs locally, reads files directly from your workspace, executes automated tests in an isolated shell, and inspects compiler diagnostics when edits fail. Building this caliber of assistant no longer requires training a custom foundation model from scratch. By pairing modern frontier models with the Vercel AI SDK and strict tool-calling schemas, you can construct a resilient, project-aware coding companion in under 200 lines of TypeScript.

Architecture: The Tool-Calling Execution Loop

Instead of relying on single-shot completions, an agentic coding assistant operates inside an automated feedback cycle. The user issues an instruction. The model inspects the repository, decides which files to examine, writes code modifications, runs your test runner, and reads the resulting terminal output. If a test fails, the agent self-corrects before handing control back to you.

Here is how the data flows through the system:

User Prompt: "Add a slugify helper to string-utils and verify tests pass"
   │
   ▼
[Agent Controller (Vercel AI SDK)] ───► Evaluates available tools
   │
   ├── Step 1: Tool Call `read_file` (src/string-utils.ts)
   │     └── Tool Result: Returns existing source code
   │
   ├── Step 2: Tool Call `write_file` (adds slugify function and export)
   │     └── Tool Result: File updated on disk
   │
   ├── Step 3: Tool Call `run_command` (npm test tests/string-utils.test.ts)
   │     └── Tool Result: PASS 1 test passed (14ms)
   │
   ▼
Final Response: "Added slugify() with unicode normalization. Tests pass."

Step 1: Installing Dependencies and Setup

We use Node 22 LTS with native ES modules and TypeScript 5.7. The Vercel AI SDK provides high-level primitives for multi-step tool resolution (`maxSteps`), streaming token delivery, and provider abstractions.

# Initialize project
mkdir dev-companion && cd dev-companion
npm init -y

# Install Vercel AI SDK, Anthropic provider, and Zod
npm install ai @ai-sdk/anthropic zod dotenv
npm install -D typescript @types/node tsx

# Initialize TypeScript configuration
npx tsc --init

Store your API key in a local .env file:

ANTHROPIC_API_KEY=sk-ant-api03-xxxx
WORKSPACE_ROOT=/Users/username/projects/my-app

Step 2: Defining Schema-Enforced Workspace Tools

The companion requires three deterministic operations: reading file contents, writing updated source files, and executing validation commands. Each tool must use Zod schemas to ensure the language model supplies strictly typed arguments.

// src/tools.ts
import { tool } from 'ai';
import { z } from 'zod';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';

const execAsync = promisify(exec);
const ROOT_DIR = path.resolve(process.env.WORKSPACE_ROOT || process.cwd());

// Prevent path traversal outside the project directory
function assertSafePath(targetPath: string): string {
  const resolved = path.resolve(ROOT_DIR, targetPath);
  if (!resolved.startsWith(ROOT_DIR)) {
    throw new Error(`Access denied: Path ${targetPath} points outside workspace.`);
  }
  return resolved;
}

export const workspaceTools = {
  readFile: tool({
    description: 'Read the UTF-8 text contents of a workspace file.',
    parameters: z.object({
      filePath: z.string().describe('Relative path to the target file from workspace root'),
    }),
    execute: async ({ filePath }) => {
      try {
        const fullPath = assertSafePath(filePath);
        const content = await fs.readFile(fullPath, 'utf-8');
        return { success: true, content };
      } catch (err: any) {
        return { success: false, error: err.message };
      }
    },
  }),

  writeFile: tool({
    description: 'Write complete source code to a workspace file. Overwrites existing content.',
    parameters: z.object({
      filePath: z.string().describe('Relative path to the target file from workspace root'),
      content: z.string().describe('The complete file content to write'),
    }),
    execute: async ({ filePath, content }) => {
      try {
        const fullPath = assertSafePath(filePath);
        await fs.mkdir(path.dirname(fullPath), { recursive: true });
        await fs.writeFile(fullPath, content, 'utf-8');
        return { success: true, message: `Wrote ${content.length} characters to ${filePath}` };
      } catch (err: any) {
        return { success: false, error: err.message };
      }
    },
  }),

  runCommand: tool({
    description: 'Run a shell command within the workspace. Restricted to build, test, and lint runners.',
    parameters: z.object({
      command: z.string().describe('Shell command to execute, such as npm test or tsc --noEmit'),
    }),
    execute: async ({ command }) => {
      // Defensive check: block destructive commands
      const banned = ['rm -rf', 'mkfs', 'dd ', ':(){ :|:& };:', 'curl', 'wget'];
      if (banned.some((term) => command.includes(term))) {
        return { success: false, error: 'Command rejected: Contains banned shell instructions.' };
      }

      try {
        const { stdout, stderr } = await execAsync(command, { cwd: ROOT_DIR, timeout: 30000 });
        return { success: true, stdout: stdout.slice(0, 4000), stderr: stderr.slice(0, 2000) };
      } catch (err: any) {
        return {
          success: false,
          exitCode: err.code,
          stdout: err.stdout?.slice(0, 2000) || '',
          stderr: err.stderr?.slice(0, 2000) || err.message,
        };
      }
    },
  }),
};

Step 3: Orchestrating the Multi-Step Agent Runner

By default, models generate a single response. With Vercel AI SDK's streamText and maxSteps, the framework automatically executes tool requests returned by the model, feeds tool outputs back into the conversational thread, and continues prompting until the model delivers a final answer.

// src/agent.ts
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { workspaceTools } from './tools.js';
import * as dotenv from 'dotenv';

dotenv.config();

export async function runCodingTask(instruction: string): Promise<void> {
  const systemPrompt = `You are a Principal Software Engineer companion running in the user's terminal.
Your task is to inspect code, make precise edits, and verify your changes with existing test suites.

Strict Rules:
1. Always read existing files before proposing edits.
2. Do not emit markdown code blocks when you intend to edit files; invoke the writeFile tool instead.
3. After modifying any file, invoke runCommand with 'npm test' or the project's typechecker.
4. If a test fails, read the stack trace, formulate a hypothesis, and fix the code.
5. Keep final summaries concise. Detail what changed and confirmed test results.`;

  console.log(`\n[Agent Task]: ${instruction}\n`);

  const result = streamText({
    model: anthropic('claude-3-5-sonnet-20241022'),
    system: systemPrompt,
    prompt: instruction,
    tools: workspaceTools,
    maxSteps: 6, // Allows up to 6 back-and-forth tool call cycles
    onStepFinish: (step) => {
      if (step.toolCalls.length > 0) {
        for (const call of step.toolCalls) {
          console.log(`[Tool Call]: ${call.toolName}(${JSON.stringify(call.args)})`);
        }
      }
      if (step.toolResults.length > 0) {
        for (const res of step.toolResults) {
          console.log(`[Tool Result]: ${JSON.stringify(res.result).slice(0, 120)}...`);
        }
      }
    },
  });

  // Stream final textual synthesis directly to stdout
  for await (const delta of result.textStream) {
    process.stdout.write(delta);
  }
  console.log('\n');
}

Step 4: Running a Real Refactoring Task

Let us execute the agent against a concrete repository task: adding input validation to an existing user service and testing the change.

// src/index.ts
import { runCodingTask } from './agent.js';

async function main() {
  const task = `
    1. Inspect src/auth/token.ts.
    2. Add an expiration check helper function 'isTokenExpired(token: string): boolean'.
    3. Handle invalid base64 and malformed payload strings gracefully by returning true.
    4. Run 'npm test tests/token.test.ts' to verify implementation.
  `;

  await runCodingTask(task);
}

main().catch(console.error);

When run via npx tsx src/index.ts, the agent executes the sequence autonomously:

[Agent Task]: Inspect src/auth/token.ts and add expiration check...

[Tool Call]: readFile({"filePath":"src/auth/token.ts"})
[Tool Result]: {"success":true,"content":"import jwt from 'jsonwebtoken';\n..."}...
[Tool Call]: writeFile({"filePath":"src/auth/token.ts","content":"..."})
[Tool Result]: {"success":true,"message":"Wrote 1420 characters to src/auth/token.ts"}...
[Tool Call]: runCommand({"command":"npm test tests/token.test.ts"})
[Tool Result]: {"success":true,"stdout":"PASS tests/token.test.ts\n✓ isTokenExpired handles expired JWTs..."}...

Implemented isTokenExpired() in src/auth/token.ts with defensive try/catch blocks for malformed tokens. All 4 unit tests in tests/token.test.ts are passing.

Key Operational Guardrails

Deploying an agent that touches local disk files requires concrete safety practices:

  • Workspace Path Fencing: Never allow the model to operate on arbitrary paths. Use path.resolve and verify that target paths start with your project root directory to block attacks like ../../etc/passwd.
  • Token Output Truncation: Massive command outputs (like a 50,000-line minified bundle trace) will crash your token budget. Slice stdout and stderr to a fixed limit before returning results to the model.
  • Git Snapshotting: Before running multi-step agent tasks, ensure your local git tree is clean or create a temporary stash. If the model produces malformed logic across five files, a simple git checkout . restores sanity instantly.

Pairing structured tool definitions with an automated verification loop changes AI from an unreliable code guesser into a disciplined pair programmer that tests its own work before presenting the result. If you want to connect these tools with broader engineering workflows, read our breakdown on automated code reviews on GitHub, master effective prompting techniques, or build a code explainer to break down unfamiliar repositories quickly.

Found this useful?
View all articles

Keep Reading

Related Articles

Learn with Dropout Developer

Build real software with AI

Step-by-step learning paths, vibe coding tutorials, and certified developer programs designed for the modern engineer.