Agents Workshop

Build

Let's start by creating our Coding Agent. We'll make a request to OpenAI's GPT-4.1-mini via the AI Gateway. We'll also provide our agent with a system prompt to tailor it's behaviour to our use case.

utils/agent.ts
import { generateText } from "ai";

export async function codingAgent(prompt: string) {
  const result = await generateText({
    model: "openai/gpt-4.1-mini",
    prompt,
    system:
      "You are a coding agent. You will be working with js/ts projects. Your responses must be concise.",
  });

  return { response: result.text };
}

Run the script with the following command:

pnpm run test

This will call our coding agent via the utils/test.ts file. Currently the prompt is "Tell me about this project". Notice the model doesn't have access to our project so will ask for us to provide it.

Let's add a tool that will list files and directories at a given path. If no path is provided, it should list files in the current directory.

utils/agent.ts
import { generateText, tool } from "ai";
import z from "zod/v4";
import fs from "fs"; 

export async function codingAgent(prompt: string) {
  const result = await generateText({
    model: "openai/gpt-4.1-mini",
    prompt,
    system:
      "You are a coding agent. You will be working with js/ts projects. Your responses must be concise.",
    tools: { 
      list_files: tool({ 
        description:
        "List files and directories at a given path. If no path is provided, lists files in the current directory.", 
        inputSchema: z.object({ 
          path: z 
            .string() 
            .nullable() 
            .describe( 
              "Optional relative path to list files from. Defaults to current directory if not provided.", 
            ), 
        }), 
        execute: async ({ path: generatedPath }) => { 
          if (generatedPath === ".git" || generatedPath === "node_modules") { 
            return { error: "You cannot read the path: ", generatedPath }; 
          } 
          const path = generatedPath?.trim() ? generatedPath : "."; 
          try { 
            console.log(`Listing files at '${path}'`); 
            const output = fs.readdirSync(path, { 
              recursive: false, 
            }); 
            return { path, output }; 
          } catch (e) { 
            console.error(`Error listing files:`, e); 
            return { error: e }; 
          } 
        }, 
      }), 
    }, 
  });

  return { response: result.text };
}

Run the script again.

Notice we don't see any output? We need to make our call agentic by defining new stopping conditions with stopWhen.

Define a stopping condition with the stopWhen property.

utils/agent.ts
import { generateText, stepCountIs, tool } from "ai"; 
import z from "zod/v4";
import fs from "fs";

export async function codingAgent(prompt: string) {
  const result = await generateText({
    model: "openai/gpt-4.1-mini",
    prompt,
    system:
      "You are a coding agent. You will be working with js/ts projects. Your responses must be concise.",
    stopWhen: stepCountIs(10), 
    tools: {
      list_files: tool({
        description:
          "List files and directories at a given path. If no path is provided, lists files in the current directory.",
        inputSchema: z.object({
          path: z
            .string()
            .nullable()
            .describe(
              "Optional relative path to list files from. Defaults to current directory if not provided.",
            ),
        }),
        execute: async ({ path: generatedPath }) => {
          if (generatedPath === ".git" || generatedPath === "node_modules") {
            return { error: "You cannot read the path: ", generatedPath };
          }
          const path = generatedPath?.trim() ? generatedPath : ".";
          try {
            console.log(`Listing files at '${path}'`);
            const output = fs.readdirSync(path, {
              recursive: false,
            });
            return { path, output };
          } catch (e) {
            console.error(`Error listing files:`, e);
            return { error: e };
          }
        },
      }),
    },
  });

  return {
    response: result.text,
  };
}

Run the script again.

Notice now the "agent' is taking multiple steps to understand the project. However, notice now it's restricted by it's one tool. Let's add a tool to help it read files.

The read_file tool should look similar to our list_files tool.

utils/agent.ts
import { generateText, stepCountIs, tool } from "ai";
import z from "zod/v4";
import fs from "fs";

export async function codingAgent(prompt: string) {
  const result = await generateText({
    model: "openai/gpt-4.1-mini",
    prompt,
    system:
      "You are a coding agent. You will be working with js/ts projects. Your responses must be concise.",
    stopWhen: stepCountIs(10),
    tools: {
      list_files: tool({
        description:
          "List files and directories at a given path. If no path is provided, lists files in the current directory.",
        inputSchema: z.object({
          path: z
            .string()
            .nullable()
            .describe(
              "Optional relative path to list files from. Defaults to current directory if not provided.",
            ),
        }),
        execute: async ({ path: generatedPath }) => {
          if (generatedPath === ".git" || generatedPath === "node_modules") {
            return { error: "You cannot read the path: ", generatedPath };
          }
          const path = generatedPath?.trim() ? generatedPath : ".";
          try {
            console.log(`Listing files at '${path}'`);
            const output = fs.readdirSync(path, {
              recursive: false,
            });
            return { path, output };
          } catch (e) {
            console.error(`Error listing files:`, e);
            return { error: e };
          }
        },
      }),
      read_file: tool({ 
        description:
          "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.", 
          inputSchema: z.object({ 
            path: z 
              .string() 
              .describe("The relative path of a file in the working directory."), 
          }), 
          execute: async ({ path }) => { 
            try { 
              console.log(`Reading file at '${path}'`); 
              const output = fs.readFileSync(path, "utf-8"); 
              return { path, output }; 
            } catch (error) { 
              console.error(`Error reading file at ${path}:`, error.message); 
              return { path, error: error.message }; 
            } 
          }, 
      }), 
    },
  });

  return {
    response: result.text,
  };
}

Run the script again.

You should see the model list the files, then check the README. Finally, it responds with an overview of the project. Very cool, right?

We can even experiment here and ask the agent to tell us how the agent works!

utils/agent.ts
import { codingAgent } from "./agent";
import dotenv from "dotenv";

dotenv.config({ path: ".env.local" });

codingAgent("Tell me how this agent currently works.")
  .then(console.log)
  .catch(console.error);

Pretty wild, right? The agent is telling us how the agent works.

But what if we ask it to update the README to add a section for contributoring? Go to utils/test.ts and update the prompt to say:

utils/agent.ts
import { codingAgent } from "./agent";
import dotenv from "dotenv";

dotenv.config({ path: ".env.local" });

codingAgent(
  "Add a contributing section to the readme of this project. Use standard format.", 
)
  .then(console.log)
  .catch(console.error);

Notice it gets stuck in a loop. Let's add a tool to edit + create files.

Create a new tool called edit_file that will be used to edit or create files.

utils/agent.ts
import { generateText, stepCountIs, tool } from "ai";
import z from "zod/v4";
import fs from "fs";

export async function codingAgent(prompt: string) {
  const result = await generateText({
    model: "openai/gpt-4.1-mini",
    prompt,
    system:
      "You are a coding agent. You will be working with js/ts projects. Your responses must be concise.",
    stopWhen: stepCountIs(10),
    tools: {
      list_files: tool({
        description:
          "List files and directories at a given path. If no path is provided, lists files in the current directory.",
        inputSchema: z.object({
          path: z
            .string()
            .nullable()
            .describe(
              "Optional relative path to list files from. Defaults to current directory if not provided.",
            ),
        }),
        execute: async ({ path: generatedPath }) => {
          if (generatedPath === ".git" || generatedPath === "node_modules") {
            return { error: "You cannot read the path: ", generatedPath };
          }
          const path = generatedPath?.trim() ? generatedPath : ".";
          try {
            console.log(`Listing files at '${path}'`);
            const output = fs.readdirSync(path, {
              recursive: false,
            });
            return { path, output };
          } catch (e) {
            console.error(`Error listing files:`, e);
            return { error: e };
          }
        },
      }),
      read_file: tool({
        description:
          "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.",
        inputSchema: z.object({
          path: z
            .string()
            .describe("The relative path of a file in the working directory."),
        }),
        execute: async ({ path }) => {
          try {
            console.log(`Reading file at '${path}'`);
            const output = fs.readFileSync(path, "utf-8");
            return { path, output };
          } catch (error) {
            console.error(`Error reading file at ${path}:`, error.message);
            return { path, error: error.message };
          }
        },
      }),
      edit_file: tool({ 
        description:
          "Make edits to a text file or create a new file. Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. If the file specified with path doesn't exist, it will be created.", 
          inputSchema: z.object({ 
            path: z.string().describe("The path to the file"), 
            old_str: z 
              .string() 
              .nullable() 
              .describe( 
                "Text to search for - must match exactly and must only have one match exactly", 
              ), 
            new_str: z.string().describe("Text to replace old_str with"), 
          }), 
          execute: async ({ path, old_str, new_str }) => { 
            try { 
              const fileExists = fs.existsSync(path); 
              if (fileExists && old_str !== null) { 
                console.log(`Editing file '${path}'`); 
                const fileContents = fs.readFileSync(path, "utf-8"); 
                const newContents = fileContents.replace(old_str, new_str); 
                fs.writeFileSync(path, newContents); 
                return { path, success: true, action: "edit" }; 
              } else { 
                console.log(`Creating file '${path}'`); 
                fs.writeFileSync(path, new_str); 
                return { path, success: true, action: "create" }; 
              } 
            } catch (e) { 
              console.error(`Error editing file ${path}:`, e); 
              return { error: e, success: false }; 
            } 
          }, 
      }), 
    },
  });

  return {
    response: result.text,
  };
}

Run the script again.

Check your README. You should see a new section for contributing added. How cool, right?!

You've just created a coding agent!