Chapter 03: Project Scaffolding
Convert the bootstrap script into a structured TypeScript project with tests, linting, and modular code.
TUTOR WITH THEFOCUS.AI
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.
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:
- Run
biome checkto lint the code - Run
bun testto execute the test suite - Add a test specifically for the
bashtool 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.
- Copy the error from the broken agent
- Paste it into the bootstrap agent: “Here’s an error I got. What’s wrong and how do I fix it?”
- Apply the fix
- 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 };
}