Agent Starter
February 10, 2026 · View on GitHub
A production-ready monorepo for building AI agent systems with the Claude Agent SDK. The core package handles all SDK boilerplate — streaming, sessions, cost tracking, todo tracking, stop reasons, and subagents — so you can focus on your domain logic. Thin surface packages (API server, web app) consume the core without reimplementing anything.
Architecture
@agent-starter/core ← All agent logic lives here
↑
├── @agent-starter/api ← REST/SSE HTTP surface (Hono)
└── @agent-starter/web ← React chat UI (Vite + shadcn/ui)
The core package exports two main functions:
runQuery(config)— runs a complete agent query and returns a typedQueryResultwith text, usage stats, todos, tool calls, and stop reasonstreamQuery(config)— an async generator that yields typedStreamEvents as the agent works (text deltas, tool calls, todo updates, usage)
Every surface package (API, web, CLI, etc.) just calls these functions and maps results to its transport. No SDK imports outside of core.
Prerequisites
- Node.js 18 or later
- pnpm 9 or later
- An Anthropic API key
Getting started
Clone the repo, install dependencies, and set your API key:
git clone <your-repo-url> agent-starter
cd agent-starter
pnpm install
cp .env.example .env
# Edit .env and set ANTHROPIC_API_KEY
Run the API server
pnpm dev:api
# Server starts on http://localhost:3000
Run the web app
pnpm dev:web
# Opens http://localhost:5173 with API proxy to :3000
Run tests
pnpm test
Project structure
packages/
├── core/ # @agent-starter/core — agent logic + utilities
│ └── src/
│ ├── run-query.ts # runQuery() and streamQuery()
│ ├── types.ts # Shared TypeScript types
│ ├── config.ts # Environment + defaults
│ ├── sessions/ # Session isolation strategies
│ │ ├── index.ts # Strategy factory
│ │ ├── local-strategy.ts # Folder-per-session
│ │ ├── docker-strategy.ts # Ephemeral Docker containers
│ │ └── azure-strategy.ts # Azure Dynamic Sessions
│ ├── usage.ts # Token tracking (deduplicates by message ID)
│ ├── todos.ts # TodoWrite event parsing
│ ├── stop-reasons.ts # Stop reason classification helpers
│ ├── messages.ts # SDK message extraction utilities
│ └── index.ts # Barrel export
├── api/ # @agent-starter/api — REST + SSE server
│ └── src/
│ ├── routes.ts # POST /query, POST /stream, POST /ag-ui, GET /health
│ ├── ag-ui-stream.ts # AG-UI event emitter (streamQuery → AG-UI events)
│ └── index.ts # Hono server entry
└── web/ # @agent-starter/web — React chat UI
└── src/
├── hooks/use-chat.ts # SSE streaming hook (legacy)
├── hooks/use-ag-ui-chat.ts # AG-UI protocol client hook
└── components/chat/ # Chat UI components
session-container/ # Custom container for Azure Dynamic Sessions
├── Dockerfile
└── server.js # HTTP execution server
infra/ # Azure infrastructure (Bicep)
├── main.bicep
├── resources.bicep
└── main.parameters.json
azure.yaml # Azure Developer CLI template
scripts/ # Helper scripts for local development
.claude/skills/ # Agent skills (SKILL.md files)
└── code-review/ # Example: structured code review skill
API endpoints
The API server exposes these endpoints:
GET /health
Returns server status.
POST /query
Runs a single agent query and returns the complete result as JSON.
curl -X POST http://localhost:3000/query \
-H "Content-Type: application/json" \
-d '{"prompt": "What files are in the current directory?"}'
Response:
{
"text": "The current directory contains...",
"sessionId": "abc-123",
"stopReason": "end_turn",
"usage": {
"inputTokens": 1500,
"outputTokens": 200,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0
},
"todos": { "todos": [], "total": 0, "completed": 0, "inProgress": 0, "pending": 0 },
"toolCalls": [{ "id": "t1", "name": "Bash", "input": { "command": "ls" } }]
}
POST /stream
Streams agent events as SSE. Each event has an event field (text_delta, tool_call, todo_update, usage, session, done, error) and a JSON data payload.
Accepts either JSON or multipart/form-data (for file uploads). When sending multipart, include prompt as a text field and attach files under the files field.
# JSON (no files)
curl -X POST http://localhost:3000/stream \
-H "Content-Type: application/json" \
-d '{"prompt": "Explain this codebase"}'
# Multipart with file attachments
curl -X POST http://localhost:3000/stream \
-F "prompt=Analyze this CSV data" \
-F "files=@data.csv"
POST /ag-ui
AG-UI protocol endpoint. Accepts the standard RunAgentInput body and responds with AG-UI SSE events, making the API compatible with any AG-UI client (CopilotKit, CLI clients, custom UIs, etc.).
curl -X POST http://localhost:3000/ag-ui \
-H "Content-Type: application/json" \
-d '{
"threadId": "thread-1",
"runId": "run-1",
"messages": [{"id": "m1", "role": "user", "content": "Hello"}],
"tools": [],
"context": [],
"state": {},
"forwardedProps": {}
}'
AG-UI events emitted:
RUN_STARTED/RUN_FINISHED/RUN_ERROR— lifecycleTEXT_MESSAGE_START/TEXT_MESSAGE_CONTENT/TEXT_MESSAGE_END— streamed textTOOL_CALL_START/TOOL_CALL_ARGS/TOOL_CALL_END— tool callsCUSTOM { name: "session" }— SDK session IDCUSTOM { name: "usage" }— token usage statsCUSTOM { name: "todo_update" }— todo progressCUSTOM { name: "done_result" }— final query result
To resume a session, pass resumeSessionId in forwardedProps:
curl -X POST http://localhost:3000/ag-ui \
-H "Content-Type: application/json" \
-d '{
"threadId": "thread-1",
"runId": "run-2",
"messages": [{"id": "m2", "role": "user", "content": "Follow up question"}],
"tools": [],
"context": [],
"forwardedProps": {"resumeSessionId": "session-abc-123"}
}'
Also accepts multipart/form-data for file uploads (include prompt, threadId, runId as text fields and files as file fields).
POST /sessions/:id/files
Upload files into an existing session's working directory. Requires a valid session ID (returned from a previous /stream or /query call). Files are ingested into the session's working directory so the agent can access them with its Read, Bash, and Grep tools.
curl -X POST http://localhost:3000/sessions/abc-123/files \
-F "files=@report.txt" \
-F "files=@data.csv"
Response:
{
"files": [
{ "name": "report.txt", "path": "/abs/path/sessions/env-id/report.txt", "size": 1024 },
{ "name": "data.csv", "path": "/abs/path/sessions/env-id/data.csv", "size": 2048 }
]
}
Constraints:
- Allowed file types:
.txt,.csv - Maximum file size: 10 MB per file
Request body
All endpoints accept AgentQueryConfig:
| Field | Type | Description |
|---|---|---|
prompt | string | Required. The prompt to send. |
model | "sonnet" | "opus" | "haiku" | Model to use. Defaults to sonnet. |
maxTurns | number | Maximum conversation turns. Defaults to 100. |
resumeSessionId | string | Session ID to resume a previous conversation. |
forkSession | boolean | Fork from an existing session instead of continuing it. |
cwd | string | Working directory for the agent. |
allowedTools | string[] | Tools the agent can use. |
agents | Record<string, SubagentConfig> | Subagent definitions. |
systemPrompt | string | System prompt override. |
maxBudgetUsd | number | Maximum budget in USD. |
settingSources | ("user" | "project" | "local")[] | Setting sources to load. Include "project" for .claude/skills/. |
File uploads
The web app supports uploading .txt and .csv files to give the agent context. Click the paperclip icon in the chat input to attach files, then send your message.
How it works:
- Attached files are sent alongside the prompt as
multipart/form-datato the/streamendpoint. - The API ingests files into the session's working directory via the
SessionManager.ingestFiles()method. - The agent's prompt is augmented with the list of uploaded filenames.
- The agent accesses the files using its built-in tools (Read, Bash, Grep, etc.).
Per-strategy behavior:
| Strategy | Ingestion method |
|---|---|
| Local | fs.writeFile into the session directory |
| Docker | Not yet implemented (throws error) |
| Azure | Not yet implemented (throws error) |
Constraints:
- Allowed types:
.txt,.csv - Max file size: 10 MB per file
- Filenames are sanitized (special characters replaced, length limited)
Files can also be uploaded to an existing session via POST /sessions/:id/files (see API endpoints).
Customization guide
Add a subagent
Define subagents in the agents field of your query config. The SDK's main agent decides when to delegate based on the description you provide:
import { runQuery } from "@agent-starter/core";
const result = await runQuery({
prompt: "Review this PR and run the tests",
agents: {
"code-reviewer": {
description: "Reviews code for quality and style issues",
tools: ["Read", "Grep", "Glob"],
prompt: "You are a code review expert. Focus on bugs and style.",
},
"test-runner": {
description: "Runs test suites and reports results",
tools: ["Bash"],
prompt: "Run the project test suite and report results.",
},
},
});
Use skills
Skills are folders of instructions that Claude loads dynamically to improve performance on specialized tasks. Each skill is a directory containing a SKILL.md file with YAML frontmatter and markdown instructions.
Create a skill
Add a SKILL.md file under .claude/skills/<skill-name>/:
---
name: my-skill
description: A clear description of what this skill does and when to use it.
---
# My Skill
Instructions, examples, and guidelines that Claude will follow when this skill is active.
This project includes an example skill at .claude/skills/code-review/ that demonstrates a structured code review checklist.
Load skills
Set settingSources to ['project'] to make the SDK discover skills from the .claude/skills/ directory in the working directory:
import { runQuery } from "@agent-starter/core";
const result = await runQuery({
prompt: "Review the changes in src/auth.ts",
settingSources: ["project"],
});
You can also load user-level skills from ~/.claude/skills/ by including 'user':
const result = await runQuery({
prompt: "...",
settingSources: ["user", "project"],
});
Skills in subagents
Preload specific skills into a subagent's context using the skills field:
const result = await runQuery({
prompt: "Review this PR",
settingSources: ["project"],
agents: {
reviewer: {
name: "reviewer",
description: "Reviews code quality",
prompt: "You are a code reviewer.",
skills: ["code-review"],
},
},
});
Resume a session
Pass a resumeSessionId to continue a conversation:
// First query
const result1 = await runQuery({ prompt: "What files are here?" });
const sessionId = result1.sessionId;
// Continue the conversation
const result2 = await runQuery({
prompt: "Now explain the main entry point",
resumeSessionId: sessionId,
});
Track costs
Every QueryResult and streaming usage event includes token counts. The core package deduplicates by message ID automatically (the SDK emits the same usage for multiple content blocks in a single turn):
import { streamQuery } from "@agent-starter/core";
for await (const event of streamQuery({ prompt: "..." })) {
if (event.type === "usage") {
console.log(`Tokens so far: ${event.usage.inputTokens + event.usage.outputTokens}`);
}
if (event.type === "done") {
console.log("Final usage:", event.result.usage);
}
}
Handle stop reasons
Use the stop reason helpers to classify why the agent stopped:
import { runQuery, isComplete, isRefusal, isMaxTokens, stopReasonLabel } from "@agent-starter/core";
const result = await runQuery({ prompt: "..." });
if (isComplete(result.stopReason)) {
console.log("Agent finished normally");
} else if (isRefusal(result.stopReason)) {
console.log("Agent refused the request");
} else if (isMaxTokens(result.stopReason)) {
console.log("Hit token limit — consider resuming");
}
console.log(stopReasonLabel(result.stopReason)); // "Completed", "Refused", etc.
Monitor todo progress
The agent creates todos automatically for complex multi-step tasks. Track them in streaming mode:
import { streamQuery } from "@agent-starter/core";
for await (const event of streamQuery({ prompt: "Refactor the auth module" })) {
if (event.type === "todo_update") {
const { completed, total } = event.todos;
console.log(`Progress: ${completed}/${total}`);
for (const todo of event.todos.todos) {
const icon = todo.status === "completed" ? "✅" : todo.status === "in_progress" ? "🔧" : "⬜";
console.log(` ${icon} ${todo.content}`);
}
}
}
Add a new surface (for example, CLI)
Create a new package that imports from @agent-starter/core:
// packages/cli/src/index.ts
import { streamQuery } from "@agent-starter/core";
import * as readline from "readline";
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question("Prompt: ", async (prompt) => {
for await (const event of streamQuery({ prompt })) {
if (event.type === "text_delta") {
process.stdout.write(event.text);
}
}
rl.close();
});
Session isolation
Each agent session needs its own working directory so file operations don't collide. The SESSION_STRATEGY environment variable controls how sessions are isolated. It defaults to local.
Local strategy (default)
The local strategy creates a folder per session under a configurable base path. This is the simplest option and works well for local development.
# Default — sessions stored in ./sessions/{sessionId}/
SESSION_STRATEGY=local
# Optional — customize the base directory
SESSION_BASE_DIR=./my-sessions
# Optional — auto-delete session directories after each query
SESSION_CLEANUP=true
No additional setup is required. The directory is created automatically when a session starts.
Docker strategy
The Docker strategy runs each session inside an ephemeral Docker container with an isolated filesystem. The main API stays as the orchestrator and proxies SDK calls into per-session containers.
SESSION_STRATEGY=docker
# Optional — specify the Docker image to use
DOCKER_IMAGE=agent-starter-api
Build the Docker image first:
pnpm build
docker build -f packages/api/Dockerfile -t agent-starter-api .
Azure strategy
The Azure strategy delegates code execution to Azure Container Apps Dynamic Sessions using custom containers. The API runs in a standard Container App and sends execution requests to Hyper-V-isolated session containers via REST.
SESSION_STRATEGY=azure
AZURE_SESSION_POOL_ENDPOINT=https://<region>.dynamicsessions.io/subscriptions/...
AZURE_CLIENT_ID=<managed-identity-client-id>
See Azure deployment for full setup instructions.
Hosting
The Claude Agent SDK spawns Claude Code as a subprocess, so each instance needs:
- Isolation: one container per session (Docker, Fly Machines, Modal, etc.)
- Resources: 1 GiB RAM, 5 GiB disk, 1 CPU minimum
- Network: outbound HTTPS to the Anthropic API
- Node.js: version 18 or later
A Dockerfile is included in packages/api/. Build and run:
# Build all packages first
pnpm build
# Build and run the Docker image
docker build -f packages/api/Dockerfile -t agent-starter-api .
docker run -e ANTHROPIC_API_KEY=your_key -p 3000:3000 agent-starter-api
For production deployments, spin up an ephemeral container per user session and destroy it when the session ends.
Azure deployment
This project includes a full Azure Developer CLI (azd) template for deploying to Azure Container Apps with Dynamic Sessions for isolated code execution.
┌─────────────────────────┐
│ User/Client │
└───────────┬─────────────┘
│ HTTPS
▼
┌───────────────────────────────────────┐
│ Azure Container App │
│ (agent-starter-api) │
│ ┌─────────────────────────────────┐ │
│ │ Hono API + Claude Agent SDK │ │
│ │ SESSION_STRATEGY=azure │ │
│ └─────────────────────────────────┘ │
└───────┬──────────────────┬────────────┘
│ │
Anthropic │ │ Managed Identity
API Key │ │
▼ ▼
┌──────────────────────┐ ┌─────────────────────────────┐
│ Anthropic API │ │ Dynamic Session Pool │
│ (Claude) │ │ (Custom Containers) │
└──────────────────────┘ │ ┌───────────────────────┐ │
│ │ Session Container │ │
│ │ Node.js 22 │ │
│ │ Hyper-V isolated │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
│ Pulls from
▼
┌──────────────────────────────┐
│ Azure Container Registry │
└──────────────────────────────┘
Prerequisites
- Azure CLI installed
- Azure Developer CLI (azd) installed
- An Azure subscription
- An Anthropic API key
Deploy to Azure
The deployment uses a two-step process because the Dynamic Session Pool requires the custom container image to exist in Azure Container Registry before it can be created.
- Log in to Azure:
azd auth login
- Create a new environment and set your API key:
azd env new my-agent
azd env set ANTHROPIC_API_KEY <your-anthropic-api-key>
- Run the first provision (creates ACR, builds session container image):
azd provision
- Deploy everything (creates session pool + deploys the API):
azd up
After the initial setup, subsequent deployments only need azd up.
Run locally with Azure backend
You can run the API server locally while using Azure Dynamic Sessions for code execution:
# Load azd environment variables
# Windows (PowerShell):
./scripts/load-env.ps1
# Linux/macOS:
source ./scripts/load-env.sh
# Start the API server
pnpm dev:api
Tear down
Remove all Azure resources:
azd down
Azure resources provisioned
| Resource | Purpose |
|---|---|
| Container Apps Environment | Hosts the API container app |
| Container App | Runs agent-starter-api with SESSION_STRATEGY=azure |
| Container Registry | Stores the session executor container image |
| Dynamic Session Pool | Manages Hyper-V-isolated session containers |
| User-Assigned Managed Identity | Authenticates between services (ACR pull, session execution) |
| Log Analytics Workspace | Centralized logging and monitoring |
Technology choices
| Choice | Why |
|---|---|
| SSE (not WebSocket) | LLM streaming is server-to-client only. SSE is simpler, has native browser support, auto-reconnects, and works through proxies. |
| Hono | Lightweight, fast, runs on Node.js and edge runtimes. Native SSE streaming support. |
| React + Vite | Fast dev server, native TypeScript, standard React tooling. |
| shadcn/ui | Copies components into your project — full control, no version lock-in. Radix primitives + Tailwind. |
| Tailwind CSS v4 | Via @tailwindcss/vite plugin. Zero-config, JIT compilation. |
| Vitest | Fast, Jest-compatible, native Vite integration. |
| react-markdown | Renders assistant markdown responses with syntax highlighting via react-syntax-highlighter. |
Environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
ANTHROPIC_API_KEY | Yes | — | Your Anthropic API key. |
AGENT_MODEL | No | sonnet | Default model (sonnet, opus, haiku). |
AGENT_MAX_TURNS | No | 100 | Maximum conversation turns. |
API_PORT | No | 3000 | Port for the API server. |
SESSION_STRATEGY | No | local | Session isolation strategy: local, docker, or azure. |
SESSION_BASE_DIR | No | ./sessions | Base directory for local session folders. |
SESSION_CLEANUP | No | false | Auto-delete local session directories after query completes. |
DOCKER_IMAGE | No | agent-starter-api | Docker image for the docker strategy. |
AZURE_SESSION_POOL_ENDPOINT | Azure only | — | Azure Dynamic Sessions pool management endpoint. |
AZURE_CLIENT_ID | Azure only | — | Managed identity client ID for Azure authentication. |
SESSION_POOL_AUDIENCE | No | https://dynamicsessions.io/.default | Token audience for session pool authentication. |
Next steps
- Add your own subagents for domain-specific tasks
- Customize the system prompt in
config.tsor per-query - Add custom tools via MCP servers
- Set up a CI pipeline to run
pnpm teston every push - Review the Claude Agent SDK documentation for advanced features