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 typed QueryResult with text, usage stats, todos, tool calls, and stop reason
  • streamQuery(config) — an async generator that yields typed StreamEvents 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

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 — lifecycle
  • TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_END — streamed text
  • TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END — tool calls
  • CUSTOM { name: "session" } — SDK session ID
  • CUSTOM { name: "usage" } — token usage stats
  • CUSTOM { name: "todo_update" } — todo progress
  • CUSTOM { 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:

FieldTypeDescription
promptstringRequired. The prompt to send.
model"sonnet" | "opus" | "haiku"Model to use. Defaults to sonnet.
maxTurnsnumberMaximum conversation turns. Defaults to 100.
resumeSessionIdstringSession ID to resume a previous conversation.
forkSessionbooleanFork from an existing session instead of continuing it.
cwdstringWorking directory for the agent.
allowedToolsstring[]Tools the agent can use.
agentsRecord<string, SubagentConfig>Subagent definitions.
systemPromptstringSystem prompt override.
maxBudgetUsdnumberMaximum 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:

  1. Attached files are sent alongside the prompt as multipart/form-data to the /stream endpoint.
  2. The API ingests files into the session's working directory via the SessionManager.ingestFiles() method.
  3. The agent's prompt is augmented with the list of uploaded filenames.
  4. The agent accesses the files using its built-in tools (Read, Bash, Grep, etc.).

Per-strategy behavior:

StrategyIngestion method
Localfs.writeFile into the session directory
DockerNot yet implemented (throws error)
AzureNot 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

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.

  1. Log in to Azure:
azd auth login
  1. Create a new environment and set your API key:
azd env new my-agent
azd env set ANTHROPIC_API_KEY <your-anthropic-api-key>
  1. Run the first provision (creates ACR, builds session container image):
azd provision
  1. 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

ResourcePurpose
Container Apps EnvironmentHosts the API container app
Container AppRuns agent-starter-api with SESSION_STRATEGY=azure
Container RegistryStores the session executor container image
Dynamic Session PoolManages Hyper-V-isolated session containers
User-Assigned Managed IdentityAuthenticates between services (ACR pull, session execution)
Log Analytics WorkspaceCentralized logging and monitoring

Technology choices

ChoiceWhy
SSE (not WebSocket)LLM streaming is server-to-client only. SSE is simpler, has native browser support, auto-reconnects, and works through proxies.
HonoLightweight, fast, runs on Node.js and edge runtimes. Native SSE streaming support.
React + ViteFast dev server, native TypeScript, standard React tooling.
shadcn/uiCopies components into your project — full control, no version lock-in. Radix primitives + Tailwind.
Tailwind CSS v4Via @tailwindcss/vite plugin. Zero-config, JIT compilation.
VitestFast, Jest-compatible, native Vite integration.
react-markdownRenders assistant markdown responses with syntax highlighting via react-syntax-highlighter.

Environment variables

VariableRequiredDefaultDescription
ANTHROPIC_API_KEYYesYour Anthropic API key.
AGENT_MODELNosonnetDefault model (sonnet, opus, haiku).
AGENT_MAX_TURNSNo100Maximum conversation turns.
API_PORTNo3000Port for the API server.
SESSION_STRATEGYNolocalSession isolation strategy: local, docker, or azure.
SESSION_BASE_DIRNo./sessionsBase directory for local session folders.
SESSION_CLEANUPNofalseAuto-delete local session directories after query completes.
DOCKER_IMAGENoagent-starter-apiDocker image for the docker strategy.
AZURE_SESSION_POOL_ENDPOINTAzure onlyAzure Dynamic Sessions pool management endpoint.
AZURE_CLIENT_IDAzure onlyManaged identity client ID for Azure authentication.
SESSION_POOL_AUDIENCENohttps://dynamicsessions.io/.defaultToken audience for session pool authentication.

Next steps

  • Add your own subagents for domain-specific tasks
  • Customize the system prompt in config.ts or per-query
  • Add custom tools via MCP servers
  • Set up a CI pipeline to run pnpm test on every push
  • Review the Claude Agent SDK documentation for advanced features