TheFocus.AI TheFocus.AI
01 setup Lesson 3

Chapter 03: Project Scaffolding

Convert the bootstrap script into a structured TypeScript project with tests, linting, and modular code.

TUTOR WITH THEFOCUS.AI

Agent Integration

Copy this prompt into Claude, ChatGPT, or any external AI assistant. It points the assistant to the course instructions and links it to your student profile to track your progress and customize observations.

Please tutor me in this lesson using the following context. First, read the instructions at: https://courses.thefocus.ai/llms.txt My Student ID is: <none> The lesson markdown source is at: https://courses.thefocus.ai/build-ai-coding-agent/01-setup/03-scaffolding.md

You are not enrolled yet. Enroll to generate a Student ID to track lesson completions and store learning notes.

Chapter 03: Project Scaffolding

What We’re Building

We’ll take the single-file bootstrap script and expand it into a clean, modular TypeScript project. We’ll add testing, linting, and a proper build system — all using our agent to do the work.

Step 1: Scaffold the Project

Start your bootstrap agent and paste this prompt:

Take bootstrap.ts and convert it into a cleanly structured typescript project. Use mise, bun and biome to control the building, running, testing, and linting of the project. Split out the prompt so that we can iterate on it going forward. Write tests for everything first, and make sure everything passes.

The agent will read your bootstrap.ts, understand the structure, and create:

src/
├── index.ts           # Entry point with REPL
├── agent.ts           # Core runTurn loop
├── lib/
│   ├── api.ts         # OpenRouter client
│   ├── types.ts       # Message and response types
│   └── prompts.ts     # System prompt (initially inline)
├── tools/
│   └── index.ts       # Tool definitions and dispatch
tests/
├── sanity.test.ts     # Basic tests
mise.toml              # Updated with test/check tasks
biome.json             # Linter configuration

Step 2: Add Testing and Linting

Now paste this follow-up prompt:

run the linters and the tests to make sure everything passes. make sure you have a test for the bash tool, make sure it can list all of the files in the directory

The agent will:

  1. Run biome check to lint the code
  2. Run bun test to execute the test suite
  3. Add a test specifically for the bash tool that verifies it can list files

Your mise.toml should end up with tasks like:

[tasks]
check = "biome check && bun test"
test = "bun test"
lint = "biome check"

The Check Command

Throughout this course, we’ll use mise run check as our verification step. It runs both the linter and tests. The agent’s run_check tool wraps exactly this command.

Step 3: Split the System Prompt

The system prompt should be in its own file so we can iterate on it. Your agent will create something like:

src/prompts/default.md:

---
name: default
description: The default full-featured agent.
tools:
  [
    bash,
    list_files,
    read_file,
    write_file,
    replace_in_file,
    search_files,
    run_check,
    git_diff,
    download_url,
  ]
subagents: [feature-planner, tech-researcher, code-map, poet]
---

You are a helpful AI assistant capable of running bash commands and editing files on the local system.
You have access to a set of tools to navigate, read, edit, and verify code.

# Workflow Rules

1. **Explore First**: Don't guess file paths. List and read files to understand the context.
2. **Verify Your Work**: After creating or modifying files, ALWAYS run "run_check" to verify.
3. **Atomic Changes**: Make small changes and verify them.
4. **Fix Issues**: If 'run_check' fails, analyze the error and fix the code immediately.
5. **Clean Code**: Maintain clean, type-safe code.
6. **No Auto-Commit**: NEVER automatically commit changes. Ask the user first.

The YAML frontmatter (between --- markers) defines:

  • name: The agent’s identifier
  • description: When to use this prompt
  • tools: Which tools this agent can access
  • subagents: Which other agents this one can spawn

Step 4: Test the New Agent

Run your new structured agent:

bun run src/index.ts

Try a few commands to make sure everything works:

what files are in the src directory?
run the tests for me
check the code for linting issues

If things aren’t working, go back to your bootstrap.ts agent and paste the errors — it will help debug.

The Bootstrap-as-Debugger Pattern

This is a key workflow you’ll use throughout the course: when your new agent breaks, use your old agent to fix it. The bootstrap agent is simpler and more reliable — use it as a debugging tool.

  1. Copy the error from the broken agent
  2. Paste it into the bootstrap agent: “Here’s an error I got. What’s wrong and how do I fix it?”
  3. Apply the fix
  4. Test again

Step 5: Verify Everything

mise run check

You should see:

Checked 12 files in 45ms. No errors.
✓ sanity.test.ts: all tests pass
✓ tools.test.ts: bash tool lists files correctly
✓ tools.test.ts: bash tool handles errors gracefully

What We Have Now

weekend-coding-agent/
├── src/
│   ├── index.ts           # REPL entry point
│   ├── agent.ts           # Core runTurn loop
│   ├── lib/
│   │   ├── api.ts         # OpenRouter client
│   │   ├── types.ts       # Type definitions
│   │   └── prompts.ts     # Prompt loading
│   ├── tools/
│   │   └── index.ts       # Tool dispatch
│   └── prompts/
│       └── default.md     # System prompt
├── tests/
│   ├── sanity.test.ts     # Core tests
│   └── tools.test.ts      # Tool tests
├── .env
├── .gitignore
├── mise.toml              # Tasks and dependencies
├── biome.json             # Linter config
└── package.json           # Bun project config

Key Source Files

Here’s the project structure we’re building toward. The runTurn function in src/agent.ts is the heart of the agent:

// src/agent.ts — The core loop
export async function runTurn(
  history: Message[],
  apiCaller: ApiCaller = callLLM,
  toolExecutor: CommandExecutor = defaultBashExecutor,
  tools: any[] = TOOLS,
  logger?: Logger,
  agentName = "default",
): Promise<TurnResult> {
  const currentMessages = [...history];
  const usage = { prompt_tokens: 0, completion_tokens: 0 };

  while (true) {
    const response = await apiCaller(currentMessages, tools);
    // accumulate usage tokens...
    const m = response.choices[0].message;

    if (m.tool_calls && m.tool_calls.length > 0) {
      // Execute ALL tool calls in parallel
      const toolResults = await Promise.all(
        m.tool_calls.map(async (toolCall) => {
          let result = await executeTool(
            toolCall.function.name,
            JSON.parse(toolCall.function.arguments),
          );
          return { role: "tool", tool_call_id: toolCall.id, content: result };
        }),
      );
      currentMessages.push(...toolResults);
      // Loop continues to feed tool results back to LLM
    } else {
      // Final response — done
      currentMessages.push({ role: "assistant", content: m.content });
      break;
    }
  }

  return { messages: currentMessages, usage };
}

The types in src/lib/types.ts:

export interface Message {
  role: "system" | "user" | "assistant" | "tool";
  content?: string;
  tool_calls?: any[];
  tool_call_id?: string;
  reasoning_details?: any;
  name?: string;
}

export interface CompletionResponse {
  choices: { message: Message }[];
  usage?: {
    prompt_tokens: number;
    completion_tokens: number;
    total_tokens: number;
  };
  error?: { message: string };
}

← Chapter 02 · Next: Part 2 →

Previous Lesson 3 of 11 Next