Skip to content

Repository files navigation

claude-agentic-starter

CI

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

Quick start

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 chat
you › 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.

Use it as a library

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.

Defining your own tool

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] });

How the loop works (src/agent.ts)

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 renders tools → 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; pass thinking: 'adaptive' (plus an optional effort level) to enable it. There is no budget_tokens — adaptive thinking + effort replaced 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_result with is_error: true and 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 throws MaxIterationsError instead of looping until you run out of tokens.

Develop

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 above

The 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.

Not in scope (on purpose)

  • 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-api docs cover the rest.
  • A web UI / HTTP server — Agent is transport-agnostic; wrap it in Express/Fastify/Next as you like.

License

MIT — see LICENSE.

About

Minimal, production-shaped TypeScript starter for building agentic apps on the Claude API — a streaming tool-use loop with a loop guard, prompt caching, Zod-typed tools, full stop_reason handling, an interactive CLI, and tests against a mocked client. Default model claude-opus-4-7. ~300 lines you can read and own.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages