A small, production-shaped TypeScript starter for building agentic apps on the Claude API — the part you'd otherwise rewrite on every project:
- a streaming agentic loop (tool call → execute → feed result back → repeat) with a real loop guard,
- prompt caching wired in (caches the stable system prompt + tool list),
- typed tools defined with Zod — one schema both documents the parameters and validates whatever the model sends,
- proper handling of every
stop_reason(tool_use,end_turn,max_tokens,pause_turn,refusal), - a runnable CLI chat and a test suite that exercises the loop with a mocked client (no API key needed for tests).
It's not a framework — it's ~300 lines you can read in one sitting and then own. Default model: claude-opus-4-7.
src/
├── agent.ts # the agentic loop — Agent class, streaming, caching, stop-reason handling
├── tool.ts # AgentTool<T> + Zod → JSON-schema conversion for the API
├── config.ts # model id, system prompt, defaults (env-overridable)
├── cli.ts # interactive REPL — `npm run chat`
├── index.ts # library entry point
└── tools/
├── calculator.ts # arithmetic on two numbers
├── current-time.ts # time in an IANA timezone (optional param, recoverable error)
└── read-workspace-file.ts# read a text file from ./workspace, with path-traversal guard
workspace/ # sandbox the file tool can read from
git clone https://github.com/Zarenk/claude-agentic-starter.git
cd claude-agentic-starter
npm install
cp .env.example .env # then put your ANTHROPIC_API_KEY in .env
npm run chatyou › what's 18 * 1.18, and what time is it in Lima?
claude ›
⚙ calculator({"operation":"multiply","a":18,"b":1.18})
↳ {"result":21.24}
⚙ get_current_time({"timezone":"America/Lima"})
↳ {"timezone":"America/Lima","localTime":"Monday, May 12, 2026 at 5:42:03 PM GMT-5","iso":"2026-05-12T22:42:03.123Z"}
18 × 1.18 = 21.24, and it's 5:42 PM on May 12, 2026 in Lima.
Type reset to clear the conversation, exit to quit.
import { Agent, defaultTools } from 'claude-agentic-starter';
// or just copy src/ into your project and import from there
const agent = new Agent({
tools: defaultTools,
// model: 'claude-sonnet-4-6', // defaults to claude-opus-4-7
// thinking: 'adaptive', // turn on extended thinking (off by default on Opus 4.7)
// effort: 'high', // 'low' | 'medium' | 'high' | 'xhigh' | 'max' (only with adaptive thinking)
// maxIterations: 12, // safety valve against runaway tool loops
});
const reply = await agent.send('What is 2 + 2, and what day is it in Tokyo?', {
onText: (delta) => process.stdout.write(delta),
onToolUse: (call) => console.log('→ tool:', call.name, call.input),
onToolResult: (r) => console.log('← result:', r.content, r.isError ? '(error)' : ''),
});
// Conversation state lives on the instance — call agent.send() again to continue, agent.reset() to clear.
console.log(agent.messages.length);new Agent() reads ANTHROPIC_API_KEY from the environment (via dotenv). Pass your own client (new Agent({ client })) to control retries, base URL, timeouts, etc.
A tool is a name, a description, a Zod object schema, and a run function. The schema is the contract: it's converted to JSON Schema for the API and used to validate the model's input before run is called. Throw inside run to hand the model a recoverable error.
import { z } from 'zod';
import { defineTool, Agent } from 'claude-agentic-starter';
const getStockPrice = defineTool({
name: 'get_stock_price',
description: 'Get the latest closing price for a stock ticker symbol.',
schema: z.object({
symbol: z.string().regex(/^[A-Z.]{1,8}$/).describe('Uppercase ticker, e.g. "AAPL".'),
}),
async run({ symbol }) {
const res = await fetch(`https://example.com/quote/${symbol}`);
if (!res.ok) throw new Error(`No quote for ${symbol} (HTTP ${res.status}).`);
const { close } = await res.json();
return { symbol, close }; // strings are sent as-is; objects are JSON-stringified
},
});
const agent = new Agent({ tools: [getStockPrice] });send(userMessage)
└─ loop, up to maxIterations:
1. stream a model turn (system + tools are cache_control'd)
2. append the assistant turn verbatim ← keeps tool_use / thinking blocks intact
3. switch on stop_reason:
refusal → throw AgentRefusalError
max_tokens → return the partial text
pause_turn → loop again (server-side tool resuming)
tool_use → run each tool, append a tool_result message, loop again
else → return the final text
└─ ran out of iterations → throw MaxIterationsError
Design choices worth knowing:
- Manual loop, not the SDK tool runner. The tool runner is great, but a hand-written loop is what you actually want when you need to gate side effects, add logging, or implement human-in-the-loop approval — and it's the thing worth understanding. Swapping in
client.beta.messages.tool_runner(...)later is straightforward. - Prompt caching. Each request marks the system prompt block with
cache_control: { type: 'ephemeral' }. Since the API renderstools → system → messages, that one breakpoint caches the tool list too. Keep the system prompt stable (no timestamps, no per-request IDs) or the cache is invalidated. Note: the cache only kicks in once the cached prefix is ≥ a few thousand tokens, so a tiny system prompt + 3 tiny tools won't actually populate it — it's wired the right way for when your prompt grows. - Thinking is opt-in. On
claude-opus-4-7, extended thinking is off by default; passthinking: 'adaptive'(plus an optionaleffortlevel) to enable it. There is nobudget_tokens— adaptive thinking +effortreplaced it. - Tool errors are first-class. A thrown error (or an unknown tool, or a schema-validation failure) comes back to the model as
tool_resultwithis_error: trueand a message, so it can fix its input or explain the problem instead of the whole turn crashing. - Loop guard.
maxIterations(default 12) stops a model that keeps calling tools forever — it throwsMaxIterationsErrorinstead of looping until you run out of tokens.
npm run typecheck # tsc --noEmit
npm test # vitest run — mocks the Anthropic client, no API key needed
npm run build # emits dist/ (JS + .d.ts)
npm run ci # all of the aboveThe tests cover the loop end-to-end (plain reply, tool call + result feedback, thrown tool error, unknown tool, max-iterations, refusal, max_tokens, reset()) plus each tool's logic and its conversion to an API tool definition — all against a fake client, so CI needs no secrets.
- Server-side tools (web search, code execution, computer use), MCP, structured outputs, sub-agents, context editing/compaction — all supported by the Messages API; this starter sticks to the user-defined-tool loop. The
claude-apidocs cover the rest. - A web UI / HTTP server —
Agentis transport-agnostic; wrap it in Express/Fastify/Next as you like.
MIT — see LICENSE.