The hardest part of building a coding agent isn't getting the LLM to respond—it's organizing the chaos. Context management, tool execution, streaming responses—without structure, you'll end up with spaghetti code fast.
The answer is simpler than you might think. You need a single class that owns the session state, knows how to talk to your LLM providers, and wraps all your tool calls. Think of it as the central nervous system of your agent.
We'll keep this general enough that you can apply these patterns to any TypeScript project, whether you're building a coding assistant, a data analysis agent, or something entirely different.
What We're Building
Here's what our agent class needs to handle:
- Session state (user, mode, current context)
- Talking to multiple LLM providers (OpenAI, Anthropic, Google, etc.)
- Streaming results back to the UI
- Loading and executing tools based on the current mode and model
The key insight is separation of concerns. Initialization, prompt selection, model routing, and request handling should all be distinct, testable pieces.
The Basic Structure
Let's start with a minimal skeleton:
// agent.ts
import { Agent, run } from "@openai/agents";
type Mode = "agent" | "chat";
type Model = "auto" | "gpt" | "claude" | "gemini";
export class CodingAgent {
private initialized = false;
private agent?: Agent<any>;
constructor(
private mode: Mode = "agent",
private agentMode: Model = "auto",
private mux: any,
) {}
private initModels() {/* map provider slugs -> SDK clients */}
private createPrompt(): string {/* pick prompt based on mode/state */}
private loadTools(model: Model) {/* return tool set based on model/mode */}
async initialize() {
if (this.initialized) return;
// load cached context (file tree, design system, db state, etc)
this.agent = this.buildAgent();
this.initialized = true;
}
private buildAgent(modelOverride?: Model) {
const model = modelOverride || this.agentMode;
return new Agent({
name: "Coding Agent",
instructions: this.createPrompt(),
tools: this.loadTools(model),
model: /* provider(model) */,
});
}
async processRequest(input: string, history: any[] = []) {
await this.initialize();
const messages = [...history, { role: "user", content: input }];
const stream = await run(this.agent!, messages, { stream: true, context: {/* handles */} });
for await (const event of stream) {
await this.mux.put(event);
}
}
}That's it. You'll wire in your own provider setup, tool builders, and context shape. But the structure stays the same.
The Key Pieces
Let me break down what each part does and why it matters.
Constructor: Define Your Shape
Start by identifying what your agent needs to know:
- Mode: Is this an agent that can write code, or just a chat for planning?
- Model: Which LLM should handle the request? You might default to
autoand let your routing logic decide. - Mux: How you'll stream responses back to the UI (we'll cover this later).
Keep this simple. Don't try to pass everything through the constructor.
Initialize: Load Context Once
Before you handle any requests, you need to load context. File tree summary, design tokens, database schemas—whatever your agent needs to be useful.
Do this once and cache it. You don't want to reload the entire file tree on every message.
Only after you have context should you instantiate your Agent. This way your system prompt can reference real information about the project.
createPrompt: Pick the Right Instructions
Your system prompt changes based on the situation. Maybe you have different prompts for chat mode (planning only), agent mode (can modify files), or error-fixing mode (focused on debugging).
Keep this logic in one place so it's easy to update and test.
loadTools: Choose Available Actions
Different modes and models need different tools. Agent mode gets file operations and code execution. Chat mode is read-only. Some models work better with certain tool formats.
We'll build the actual tools in the next guide. For now, just understand that this is where you decide what the agent can do.
processRequest: The Main Loop
This is where everything comes together:
- Initialize if needed
- Add the user's message to the conversation history
- Stream the response from the LLM
- Forward events to your UI
The run() function from @openai/agents handles the tool calling loop for you. Your agent calls tools, the LLM processes results, and it keeps going until it has a final answer.
Mental Model
Think of your agent class like this:
User Input → processRequest() → Agent (with tools) → Stream Response
↓
Tools execute
↓
Results back to Agent
↓
Agent decides: done or continue?
The class manages state and configuration. The Agent (from @openai/agents) handles the reasoning and tool loop. Your tools do the actual work.
It's a clean separation that makes everything easier to test and debug.
What We're Skipping (For Now)
There's more you might add in production—model routing logic, billing and rate limits, error recovery, metrics and logging. We'll cover these in later guides.
Right now, focus on the basic structure. Get a simple agent working first, then add complexity as you need it.
What's Next
You now have the structure of an agent class. In the next guide, we'll build actual tools—functions your agent can call to read files, search code, and execute commands.
That's where things get really interesting.