Think about how you use your favorite apps: checking email on your phone, responding from your laptop, or asking your smart speaker to read messages. Each device talks to the same service through an API. This is the power of decoupling — separating core logic from any single interface so it can be accessed from anywhere.
In this course, we'll put three of those factors into practice by building a real API layer around an AI agent. Specifically, this lesson kicks off Factor 11 — Trigger from anywhere, meet users where they are, and expands Factor 6 — Launch / Pause / Resume with simple APIs by implementing the launch part.
We'll build an Express server that exposes an AI agent through REST endpoints, making it accessible from web browsers, mobile apps, scripts, or any tool that can make HTTP requests. The server logic lives in app.ts, the entry point that starts listening lives in main.ts, and agent runs are kicked off as background async work using void runAgentInBackground(...).
Project Structure
As we transition from building standalone agent logic to exposing it via a web service, our project structure reflects a clear separation between the API layer and the core logic. You are already familiar with the core directory, which houses the agent's decision-making logic, tools, and models. We now introduce a server directory to handle web requests and a top-level test.ts script to simulate a client:
src/core/: Contains the Agent class and State models you've used in previous lessons.
src/server/app.ts: This is where the Express application is configured. It imports the agent from core and exposes it through REST endpoints.
src/server/main.ts: The entry point that imports app from app.ts and calls app.listen(...) to start accepting connections.
test.ts: A standalone script that uses the built-in fetchAPI to talk to your server, allowing you to test the full lifecycle of an agent task.
This separation is central to Factor 11: your agent logic remains independent of how it is triggered — whether it's via a CLI, a web API, a Slack bot, or a cron job. The server directory is just one thin adapter around a stable core.
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal
Setting Up Express
Turning Errors into JSON Responses
Before we start validating input, we need a consistent way to report failures back to the client. When something goes wrong — a malformed request body, a missing record — we want the client to receive a structured JSON response with the right HTTP status code, not a generic server crash. Two small pieces make this happen: a custom HttpError class and a global error-handling middleware.
HttpError is a tiny subclass of Error that carries an HTTP status code alongside its message, so any route can express both "what went wrong" and "which status the client should receive":
In Express, an error thrown inside a synchronous route handler is automatically caught and forwarded to a special error-handling middleware — one that takes four parameters instead of the usual three. We register it after all the routes so it acts as a catch-all:
When the error is an HttpError, the middleware uses its statusCode; anything else falls back to 500. The payoff is that any route can simply throw new HttpError(400, "input_prompt must be a non-empty string") and trust that the client receives { "detail": "input_prompt must be a non-empty string" } with status 400. Every failure flows through this one place and comes back as predictable JSON.
Note: This automatic forwarding applies to synchronous throws. All our route handlers in this unit are synchronous — they kick off long-running work in the background rather than awaiting it — so a plain throw is enough here. In the next unit, once handlers begin to await database and agent calls, you'll add a small wrapper so asynchronous errors reach this same middleware too.
Defining the Launch Request Validation
When your API receives data from a client, you must ensure that the data is structured correctly. Express does not include built-in request validation, so we handle this manually with a small helper function that leans on the HttpError we just defined:
function requireStringField(body: unknown, field: string): string { if (typeof body !== "object" || body === null || Array.isArray(body)) { throw new HttpError(400, "Request body must be an object"); } const value = (body as Record<string, unknown>)[field]; if (typeof value !== "string" || value.trim() === "") { throw new HttpError(400, `${field} must be a non-empty string`); } return value;}
requireStringField accepts any unknown value as body, checks that it is a non-null object, then extracts and validates the named field. If validation fails, it throws an HttpError with a 400 status — the standard code for a bad client request. We call it inside route handlers like this:
This gives us the specific guarantee this endpoint needs: if input_prompt is missing or empty, the request is rejected with a clean 400JSON response — thanks to the error middleware — before any agent logic runs.
Understanding the State Model
The agent's progress is tracked through a State type that comes from your core agent logic. It includes fields like id, status, steps, context, and final_answer:
import type { State } from "../core/models/state.js";
Because this is a TypeScripttype import, it exists only at compile time and carries no runtime overhead. The State type serves as the shared contract between the server layer and the core agent — every endpoint that reads or writes agent progress operates on a State object. When sending a response, you pass a State directly to response.json(...), which serializes it to JSON automatically:
response.json(state);
Managing State with In-Memory Storage
When an agent starts working on a task, it might take several seconds or even minutes to complete. During this time, you need to store information about the agent's progress so clients can check back later:
// In-memory storage will be replaced with SQLite in the next unitconst states = new Map<string, State>();
In-memory storage is implemented via Map<string, State>, which is a typed key-value store where keys are state IDs (string) and values are State objects. To write a new state, you call states.set(...), and to read one back, you call states.get(...):
states.set(initialState.id, initialState); // storeconst state = states.get(stateId); // retrieve (returns State | undefined)
This approach is simple and works well for learning, though in a later unit, you will replace this Map with a database that persists data even when the server restarts.
Creating the Agent Instance
Before building endpoints, you need an Agent instance that will handle the actual work:
import { Agent } from "../core/agent.js";// Create a single agent instance shared across all requestsconst agent = new Agent({ maxSteps: 10 });
The { maxSteps: 10 } option caps how many reasoning steps the agent is allowed to take before it stops and returns. The agent's run method processes a State, makes LLM calls, executes tool functions, and returns an updated State when it finishes.
Building the Launch Endpoint Structure
The launch endpoint is where clients start a new agent workflow. This is the first piece of Factor 6 — giving external systems a simple API to launch agent execution:
import { randomUUID } from "node:crypto";app.post("/agent/launch", (request: Request, response: Response) => { const inputPrompt = requireStringField(request.body, "input_prompt"); const initialState: State = { id: randomUUID(), // Generate a unique identifier steps: 0, status: "running", context: [{ role: "user", content: inputPrompt }], pending_tool_calls: [], error: null, final_answer: null }; // Store the state before background work starts so clients can poll immediately states.set(initialState.id, initialState); void runAgentInBackground(initialState.id); response.json(initialState);});
app.post("/agent/launch", ...) registers the handler for POST requests to that path. randomUUID() from Node's built-in crypto module generates a collision-resistant unique ID. The initial State is stored immediately, and the background helper is triggered with void runAgentInBackground(...). Using void signals explicitly that the returned Promise is intentionally not awaited — the response is sent back to the client right away while the agent runs in the background. Because the handler is synchronous, any HttpError thrown by requireStringField is caught by Express and forwarded to the error middleware.
Implementing Background Task Execution
Agent workflows can take a long time to complete, so awaiting them directly inside the request handler would block the HTTP response. Instead, we delegate the work to an async helper function:
async function runAgentInBackground(stateId: string): Promise<void> { const state = states.get(stateId); if (!state) { return; } // The agent is a reducer: State in, State out. // We pass the current State in and store the updated State it returns. const finalState = await agent.run(state); states.set(stateId, finalState);}
This function looks up the current State by id, calls await agent.run(state) to let the agent work through its reasoning steps, and then stores the resulting State back in the Map. Because the function is async and is called with void from the route handler, the route does not wait for the returned Promise to settle. The helper may still start executing immediately and run synchronously until its first await, but the HTTP response is not blocked on the agent run.
Creating the State Retrieval Endpoint
Once a client has launched an agent and received a state id, they need a way to check the agent's progress:
app.get("/agent/state/:stateId", (request: Request, response: Response) => { const stateId = requireStringField(request.params, "stateId"); const state = states.get(stateId); if (!state) { throw new HttpError(404, "State not found"); } response.json(state);});
app.get("/agent/state/:stateId", ...) registers a handler for GET requests where :stateId is a URL path parameter — Express makes it available in request.params. We validate it with requireStringField(...), then attempt to look it up with states.get(...). If nothing is found, we throw new HttpError(404, "State not found"), and the global error middleware turns it into a 404 response with a { "detail": "State not found" } body. Otherwise, the current State is serialized and sent back. Together with the launch endpoint, this forms the foundation of Factor 6: clients can launch a workflow and then poll its status through simple API calls.
Setting Up the Test Script
To see your API in action, you need a client. The test script starts by launching an agent:
import type { State } from "./src/core/models/state.js";const baseUrl = "http://localhost:8000";// Send a POST request to launch the agent with a promptconst launchResponse = await fetch(`${baseUrl}/agent/launch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input_prompt: "Solve the root of this equation: x^2 - 5x + 6 = 0" })});// Parse the response JSON to get the initial state objectconst state = (await launchResponse.json()) as State;// Print the unique state ID for referenceconsole.log(`Launched agent with ID: ${state.id}`);
fetch is built into modern Node.js, so no extra dependencies are needed. JSON.stringify(...) serializes the request body, and as State is a TypeScript type assertion that tells the compiler what shape to expect from the parsed JSON. This test script is a demonstration of Factor 11 — it's a separate process accessing the agent through its RESTAPI.
Implementing the Polling Loop
Viewing the Complete Output
When you run the test script, you'll see the agent's progress:
Launched agent with ID: 5b06d69b-b2ce-42b5-ac8c-c33a29647b29Final State:{ "id": "5b06d69b-b2ce-42b5-ac8c-c33a29647b29", "steps": 7, "status": "complete", "context": [ { "role": "user", "content": "Solve the root of this equation: x^2 - 5x + 6 = 0" }, { "type": "function_call", "name": "power", "arguments": "{\"base\":-5,\"exponent\":2}", "call_id": "call_bcRzpWRhyXCWNT99RbpPo6Ve" }, ... { "type": "function_call", "name": "final_answer", "arguments": "{\"answer\":\"The roots are x = 2 and x = 3.\"}", "call_id": "call_DIr1BltE3clHRyjxtJKyTdQU" } ], "pending_tool_calls": [], "error": null, "final_answer": "The roots are x = 2 and x = 3."}
The output shows the agent finished in 7 steps. The context array contains the full trace of reasoning and tool calls used to reach the final_answer. This transparency is valuable for debugging and understanding how the agent arrives at its conclusions.
Summary and What's Next
You've implemented the first part of Factor 6 — Launch / Pause / Resume with simple APIs and addressed Factor 11 — Trigger from anywhere by decoupling your agent. You learned to configure an Express app with cors and express.json(), define REST endpoints with app.post(...) and app.get(...), validate incoming data with requireStringField(...) that throws HttpError, turn thrown errors into structured JSON responses with a global error-handling middleware, store agent states in a Map<string, State>, run agent work in the background using void runAgentInBackground(...) with await agent.run(state), and interact with the API from a TypeScriptfetch client. In the next lesson, you'll replace the in-memory Map with a real database so that agent states survive server restarts — and extend this error handling so asynchronous route failures return structured JSON too.
Express is a minimal and flexible Node.js web framework for building APIs. To create the server, you start by calling express() and configuring a couple of essential middleware layers:
TypeScript
import cors from "cors";import express from "express";// Create the Express applicationexport const app = express();// Allow requests from any origin (useful for development and cross-origin clients)app.use(cors({ origin: "*", credentials: false }));// Parse incoming JSON request bodies automaticallyapp.use(express.json());
cors(...) adds the necessary HTTP headers so that browsers running on a different origin (for example, a frontend on port 3000) can call your API. express.json() parses the raw JSON body of incoming requests and makes it available as request.body.
The server itself is started in a separate file, main.ts, which imports app and calls app.listen(...):
TypeScript
import { app } from "./app.js";const port = Number(process.env.PORT ?? 8000);const host = process.env.HOST ?? "0.0.0.0";app.listen(port, host, () => { console.log(`Backend API listening on http://${host}:${port}`);});
Keeping the app configuration (app.ts) separate from the startup call (main.ts) makes it easy to import the app in tests without accidentally binding to a port.
function requireStringField(body: unknown, field: string): string { if (typeof body !== "object" || body === null || Array.isArray(body)) { throw new HttpError(400, "Request body must be an object"); } const value = (body as Record<string, unknown>)[field]; if (typeof value !== "string" || value.trim() === "") { throw new HttpError(400, `${field} must be a non-empty string`); } return value;}
import type { State } from "../core/models/state.js";
TypeScript
response.json(state);
TypeScript
// In-memory storage will be replaced with SQLite in the next unitconst states = new Map<string, State>();
TypeScript
states.set(initialState.id, initialState); // storeconst state = states.get(stateId); // retrieve (returns State | undefined)
TypeScript
import { Agent } from "../core/agent.js";// Create a single agent instance shared across all requestsconst agent = new Agent({ maxSteps: 10 });
TypeScript
import { randomUUID } from "node:crypto";app.post("/agent/launch", (request: Request, response: Response) => { const inputPrompt = requireStringField(request.body, "input_prompt"); const initialState: State = { id: randomUUID(), // Generate a unique identifier steps: 0, status: "running", context: [{ role: "user", content: inputPrompt }], pending_tool_calls: [], error: null, final_answer: null }; // Store the state before background work starts so clients can poll immediately states.set(initialState.id, initialState); void runAgentInBackground(initialState.id); response.json(initialState);});
TypeScript
async function runAgentInBackground(stateId: string): Promise<void> { const state = states.get(stateId); if (!state) { return; } // The agent is a reducer: State in, State out. // We pass the current State in and store the updated State it returns. const finalState = await agent.run(state); states.set(stateId, finalState);}
TypeScript
app.get("/agent/state/:stateId", (request: Request, response: Response) => { const stateId = requireStringField(request.params, "stateId"); const state = states.get(stateId); if (!state) { throw new HttpError(404, "State not found"); } response.json(state);});
TypeScript
import type { State } from "./src/core/models/state.js";const baseUrl = "http://localhost:8000";// Send a POST request to launch the agent with a promptconst launchResponse = await fetch(`${baseUrl}/agent/launch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input_prompt: "Solve the root of this equation: x^2 - 5x + 6 = 0" })});// Parse the response JSON to get the initial state objectconst state = (await launchResponse.json()) as State;// Print the unique state ID for referenceconsole.log(`Launched agent with ID: ${state.id}`);
After launching the agent, the test script repeatedly checks the agent's status until it reaches a terminal state via a polling loop:
TypeScript
while (true) { // Fetch the current state from the server const stateResponse = await fetch(`${baseUrl}/agent/state/${encodeURIComponent(state.id)}`); const currentState = (await stateResponse.json()) as State; // Check if the agent has finished processing if (["complete", "max_steps_reached", "failed"].includes(currentState.status)) { console.log("Final State:"); console.log(JSON.stringify(currentState, null, 2)); break; } // Wait 1 second before polling again to avoid overwhelming the server await new Promise((resolve) => setTimeout(resolve, 1000));}
encodeURIComponent(state.id) safely encodes the state id for use in a URL. The while (true) loop continues until status is one of the terminal values: complete, max_steps_reached, or failed. The await new Promise(...) pattern is the idiomatic way to pause async execution in TypeScript without blocking the event loop.
text
Launched agent with ID: 5b06d69b-b2ce-42b5-ac8c-c33a29647b29Final State:{ "id": "5b06d69b-b2ce-42b5-ac8c-c33a29647b29", "steps": 7, "status": "complete", "context": [ { "role": "user", "content": "Solve the root of this equation: x^2 - 5x + 6 = 0" }, { "type": "function_call", "name": "power", "arguments": "{\"base\":-5,\"exponent\":2}", "call_id": "call_bcRzpWRhyXCWNT99RbpPo6Ve" }, ... { "type": "function_call", "name": "final_answer", "arguments": "{\"answer\":\"The roots are x = 2 and x = 3.\"}", "call_id": "call_DIr1BltE3clHRyjxtJKyTdQU" } ], "pending_tool_calls": [], "error": null, "final_answer": "The roots are x = 2 and x = 3."}