Every AI demo looks the same: a text box, a spinner, a wall of confident prose. Shipping the demo takes an afternoon. Shipping the feature — the thing that stays up when the API has a bad day, doesn't leak your system prompt, doesn't torch your margin on token spend, and fails in ways users can forgive — is a different job. At Luminary we run Claude in production on our own site: a chat concierge, and a project scoper that turns a rough brief into a structured plan via forced tool use. Neither is exotic; both taught us more than any amount of playground prompt-golfing. This is the guide we wish we'd had, with examples in the Anthropic TypeScript SDK (@anthropic-ai/sdk), because that's what we deploy.

The anatomy of an agent
Strip away the branding and an agent is four things:
- A model that decides what to do next.
- Tools — functions you expose, described well enough that the model calls them correctly.
- A loop — call the model, execute whatever tools it requested, feed the results back, repeat until it stops asking.
- Context management — deciding what the model sees each turn, because the API is stateless and you resend everything, every time.
That's it. The loop is the part people over-mystify, so here it is in full, including the two details that matter most in production — appending the entire assistant content back (tool-use blocks included), and returning all tool results in a single user message:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userInput },
];
while (true) {
const response = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 4096,
system: SYSTEM_PROMPT,
tools,
messages,
});
if (response.stop_reason !== "tool_use") break;
// Preserve the full assistant turn — tool_use blocks and all.
messages.push({ role: "assistant", content: response.content });
const toolUses = response.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
);
// Claude may request several tools at once. Run them concurrently.
const results = await Promise.all(
toolUses.map(async (call) => {
try {
return {
type: "tool_result" as const,
tool_use_id: call.id,
content: await executeTool(call.name, call.input),
};
} catch (err) {
return {
type: "tool_result" as const,
tool_use_id: call.id,
content: err instanceof Error ? err.message : "Tool failed",
is_error: true,
};
}
}),
);
// All results go back in ONE user message — never split them.
messages.push({ role: "user", content: results });
}
Production notes on this skeleton: cap the loop (5–10 iterations) so a confused model can't spin forever, and return failed tools as tool_result with is_error: true instead of dropping them — Claude recovers from an error message, not from a missing result. And you often don't need to hand-roll this at all: the SDK ships a tool runner (client.beta.messages.toolRunner) that drives the loop with hooks for approval gates and result interception. We hand-roll only where we want to own the whole control flow.
The fourth ingredient — context management — is where most agent budgets go to die, so it gets its own sections below.
Tool use done right
Schemas are prompts
A tool definition is three things: a name, a description, and a JSON Schema for its inputs. All three are read by the model, which means all three are prompt engineering. The single highest-leverage change we've made to any tool is rewriting its description to say when to call it, not just what it does:
const tools: Anthropic.Tool[] = [
{
name: "get_service_pricing",
description:
"Look up current pricing for a Luminary service. Call this whenever " +
"the user asks about cost, budget, or price — never answer pricing " +
"questions from memory.",
input_schema: {
type: "object",
properties: {
service: {
type: "string",
enum: ["web", "brand", "product", "retainer"],
description: "The service line to price",
},
currency: {
type: "string",
enum: ["LKR", "USD"],
description: "Currency for the quote",
},
},
required: ["service"],
},
},
];
Rules of thumb that have held up for us:
- Use
enumfor anything with a closed set of values. Free-text fields invite hallucinated inputs; enums make the invalid state unrepresentable. - Describe every property. The one-line
descriptiononcurrencydoes more than a paragraph in the system prompt. - Keep
requiredhonest. Optional-with-default beats required-and-guessed. - Fewer, sharper tools. Ten well-described tools beat forty vague ones. If the set genuinely must be large, the API's tool-search feature lets Claude discover tools on demand instead of loading every schema into context.
- Parse tool inputs, never string-match them. The model may escape JSON differently than you'd serialize it.
block.inputis already a parsed object in the SDK — use it.
If you want hard guarantees that inputs match the schema exactly, set strict: true on the tool definition (the schema then needs additionalProperties: false and a required list). We turn it on for anything that feeds a database query.
Forced tool choice: structured output that actually ships
Our project scoper is a one-shot endpoint: brief in, structured plan out. The failure mode of "please respond in JSON" prompting is well known — markdown fences, apologetic preambles, trailing commentary. The fix we shipped is forced tool use: define one tool whose input schema is your output type, then force the model to call it with tool_choice.
const response = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 2048,
system: [
{
type: "text",
text: SCOPER_SYSTEM_PROMPT, // services, process, pricing — static
cache_control: { type: "ephemeral" },
},
],
tools: [
{
name: "present_project_plan",
description: "Present a structured project plan for the client brief.",
input_schema: {
type: "object",
properties: {
summary: { type: "string" },
phases: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
weeks: { type: "integer" },
deliverables: { type: "array", items: { type: "string" } },
},
required: ["name", "weeks", "deliverables"],
},
},
risks: { type: "array", items: { type: "string" } },
},
required: ["summary", "phases", "risks"],
},
},
],
tool_choice: { type: "tool", name: "present_project_plan" },
messages: [{ role: "user", content: brief }],
});
const call = response.content.find(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
);
const plan = ProjectPlanSchema.safeParse(call?.input); // zod — validate anyway
if (!plan.success) return jsonError(502, "bad_upstream_output");
Two things to notice. First, tool_choice: { type: "tool", name: ... } guarantees the model calls that tool — there's no prose path for it to escape into. Second, we still validate the result with zod before it touches the UI. Schema-shaped is not the same as valid: a weeks of 0, an empty phases array, a summary that's just the brief echoed back — those pass JSON Schema and fail your product. Validation at the boundary is non-negotiable.
The API has since grown first-class structured outputs (output_config.format with a JSON schema, and client.messages.parse() in the SDK), which is the right choice for new greenfield extraction endpoints. Forced tool use remains a perfectly good, widely supported pattern — and it composes naturally when the structured output is one step inside a larger tool-using conversation.
Parallel tool calls
By default Claude may emit several tool_use blocks in one assistant turn — "check the pricing and the availability calendar." The loop above handles this correctly: execute concurrently, return every result in a single user message. Splitting results across multiple messages doesn't just break the turn structure; it quietly teaches the model to stop parallelizing, and your latency regresses without an obvious cause. If a workflow genuinely requires one-tool-at-a-time (say, each call mutates state the next call reads), set disable_parallel_tool_use: true on tool_choice rather than praying.
MCP: when to standardize the plumbing
The Model Context Protocol is easy to over-explain. The short version: MCP is USB for model tooling. It standardizes the wire protocol between a client (the agent host — Claude Desktop, Claude Code, your own app) and a server (a process exposing capabilities). Before MCP, every host integrated every data source pairwise — an N×M mess. With MCP, you implement a server once and any compliant client can use it.
A server exposes three primitives: tools (functions the model can call), resources (readable data the host can attach to context), and prompts (reusable templates). Servers speak stdio locally or streamable HTTP remotely, and the Anthropic API can connect to remote MCP servers server-side via its MCP connector, so your backend doesn't have to proxy every tool call.
Build an MCP server or bespoke tools?
This is the actual decision teams face, and the answer is boring: bespoke tools by default, MCP when the integration outlives one app.
Choose bespoke tools (plain tools array entries handled in your own process) when:
- One application owns the tools and nothing else needs them.
- Tools are thin wrappers over your own database or services — the "protocol" would just be function calls with extra steps.
- You're latency-sensitive and want zero extra hops.
Choose an MCP server when:
- The same capability should be reachable from multiple surfaces — your production agent, Claude Code during development, an internal ops assistant. Writing it once as an MCP server and connecting three clients beats maintaining three integrations.
- You're exposing a product to other people's agents. MCP is the interoperability story; a bespoke REST-plus-docs integration is friction.
- You want the ecosystem: existing servers for GitHub, Postgres, Slack and the rest mean you assemble instead of build.
Our own site uses bespoke tools exclusively — two routes, one owner, no reuse story — while our internal tooling drifts toward MCP because the same "query the project tracker" capability keeps being wanted from different hosts. Let reuse pull you to MCP; don't start there for a single endpoint. And remember: an MCP server you didn't write is third-party code with a direct line to your model's context. Vet servers like npm dependencies, and scope their permissions accordingly.
Prompt caching changes the cost math
Agents have a brutal cost structure: the API is stateless, so every loop iteration resends the entire conversation — system prompt, tool schemas, all prior turns. A ten-step agent pays for its context roughly ten times, and the prefix grows as it goes. Prompt caching is what makes this affordable.
The mental model: caching is a prefix match on exact bytes. The request renders as tools → system → messages, and a cache_control breakpoint says "cache everything up to here." Reads bill at roughly a tenth of the base input rate; writes carry a modest premium (about 1.25× at the default 5-minute TTL) — so a prefix reused even twice is already ahead, and an agent loop reusing it ten times is dramatically ahead.
const response = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
system: [
{
type: "text",
text: LUMI_SYSTEM_PROMPT, // large, static: facts, tone, boundaries
cache_control: { type: "ephemeral" },
},
],
messages: boundedHistory,
});
// Trust, but verify:
console.log(response.usage.cache_read_input_tokens); // should be > 0 on turn 2+
console.log(response.usage.cache_creation_input_tokens); // the write, first turn
The catch is that any byte change anywhere in the prefix invalidates everything after it. The classic self-inflicted wounds:
- Interpolating
new Date()or a request ID into the system prompt. Every request is now a unique prefix; your hit rate is zero and nothing errors. - Non-deterministic serialization — an unsorted object of tool definitions can render in different orders across processes.
- Swapping the tool set or the model mid-conversation. Tools render first, so this rebuilds the whole cache.
So: freeze the system prompt, keep the tool list deterministic, and push anything volatile after the last breakpoint. Then watch usage.cache_read_input_tokens in your logs — zero on repeated requests means a silent invalidator, and diffing two rendered requests will find it in minutes. Note also that very short prefixes (under roughly a thousand tokens on most current models) silently don't cache at all.
Context engineering
"Prompt engineering" undersells the job; the real work is deciding what the model sees each turn, under a budget.
The system prompt is a contract, not a wish list. Ours state what the assistant is, what it knows, what it must not do (invent prices, promise timelines, discuss competitors), and what to do when it doesn't know ("say so and offer the contact form"). Concrete refusal behavior beats abstract instructions every time. Keep it static so it caches — inject anything dynamic further down.
Few-shot vs retrieval: put examples in the prompt when the format or judgment is what varies — tone calibration, worked examples of a good project plan. Reach for retrieval when the facts vary — corpora too large or too volatile to live in a prompt. Often the honest answer is "neither": our entire fact base fits comfortably in a cached system prompt, and a RAG pipeline would have been résumé-driven engineering. Retrieval earns its complexity at corpus sizes a prompt can't hold, not before.
Bound the history. An unbounded chat transcript is both a cost leak and an attack surface (users will paste novels). Our chat route keeps the last 20 messages and truncates each to 2,000 characters before the request is built. Crude, effective, and enforced server-side where the client can't opt out. For genuinely long-running agents, the API now offers server-side compaction and context editing — but for a support-style widget, a hard window is usually all you need.
Reliability: assume the API is down
Anything that can fail, will, at your peak traffic. The patterns are standard distributed-systems fare; the discipline is applying them to the shiny new dependency too.
Timeouts and retries. The TypeScript SDK retries connection errors, 429s and 5xx twice by default with backoff, and takes a per-request timeout in milliseconds. Set both deliberately rather than inheriting defaults you've never read:
const client = new Anthropic({ maxRetries: 2 });
const response = await client.messages.create(
{ model: "claude-sonnet-5", max_tokens: 1024, messages },
{ timeout: 30_000 }, // 30s budget for this call
);
Remember the interaction: retries multiply wall-clock time. A 30s timeout with two retries can hold a serverless function open for a minute and a half. Budget accordingly.
Typed errors, layered responses. The SDK throws typed exceptions — branch on them instead of string-matching messages, and map them to honest HTTP responses:
try {
return await callClaude(payload);
} catch (err) {
if (err instanceof Anthropic.RateLimitError) {
return jsonError(503, "busy"); // tell the client to back off
}
if (err instanceof Anthropic.APIConnectionError) {
return jsonError(502, "upstream_unreachable");
}
if (err instanceof Anthropic.APIError) {
return jsonError(502, "upstream_error");
}
throw err; // genuinely unexpected — let it surface
}
Fail closed, degrade gracefully. These are two halves of one policy. The API layer fails closed: no API key configured → 503 immediately, malformed input → 400, upstream failure → 502. It never limps along half-configured. The UI layer degrades gracefully: when our chat route returns 503, the widget swaps to a "leave us a message" form; when the scoper fails, the page falls back to a static "how we scope projects" explainer plus the contact form. The user always has a path forward; the AI is an enhancement, not a load-bearing wall.
Evals before shipping. You don't need an evaluation platform to be more rigorous than vibes: a checked-in script and a golden set of ~30 inputs per feature — real briefs, adversarial briefs ("ignore your instructions and…"), empty and enormous inputs, off-topic questions. For the scoper, assertions are mechanical: parses against the zod schema, phase count in range, no invented service names. For the chat, a rubric checked partly by code and partly by a cheap model as judge, with human spot-checks. It runs before any prompt or model change ships. The point isn't statistical significance; it's catching the regression where a "small prompt tweak" makes the model start quoting prices for services you don't sell.
Guardrails
Guardrails are the difference between "the model said something weird" and "the model did something irreversible."
Validate inputs. Length caps, message-count caps, type checks, and shape checks on everything user-supplied — before it's ever interpolated into a request. This is 400-level hygiene, and it also bounds your cost exposure per request.
Validate outputs. Covered above, worth repeating: schema-validate structured outputs even when the API guarantees the shape, and treat free-text output headed for dangerouslySetInnerHTML-adjacent places as untrusted (render as text, sanitize if you must render rich content).
Scope tool permissions. Give the model the least capability that does the job. Our public-facing tools are read-only by construction — there is literally no tool that writes. When an agent does need write access, split it: broad read tools, narrow write tools with tight schemas, and separate credentials per tool so a compromise of one isn't a compromise of all.
Human-in-the-loop for irreversible actions. Sending an email, issuing a refund, deleting records — anything you can't take back gets a confirmation gate. Mechanically it's easy: the tool handler returns "pending approval" instead of executing, a human approves out-of-band, then the action runs; the SDK's tool runner supports exactly this interception. The judgment call is which actions qualify. Our test: "would we let a brand-new intern do this unsupervised on day one?"
Prompt injection is a data-flow problem. The moment a tool reads content you don't control — web pages, emails, feeds, uploaded documents — that content can contain instructions, and the model may follow them. You cannot reliably prompt this away. What helps: treat tool results as data (delimit them, mark them untrusted, never echo them into the system prompt); and, more importantly, deny by architecture — a tool that reads untrusted content should not coexist in the same agent with unsupervised write access to anything sensitive. Our blog pipeline strips scripts, styles and iframes from fetched feed HTML on the assumption the feed is hostile. Assume the same of anything your tools fetch.
Cost and latency engineering
Tier your models. Not every step deserves the big model. The pattern that has held across our projects:
| Tier | Model | Typical jobs |
|---|---|---|
| Routing / triage | claude-haiku-4-5 | intent classification, relevance checks, "is this spam?" |
| Workhorse | claude-sonnet-5 | chat, extraction, scoping, most tool-using loops |
| Hard steps | claude-opus-5 | multi-step planning, gnarly code generation, final-pass review |
A cheap, fast model deciding which expensive path to take — or whether to take one at all — routinely cuts spend more than any prompt optimization. One wrinkle: prompt caches are per-model, so don't bounce a single conversation between tiers. Route before the conversation starts, or hand sub-tasks to the cheap model as one-shot side calls.
Stream anything a human watches. Time-to-first-token is the latency number users feel; total generation time barely registers once text is flowing. The SDK makes this a few lines:
const stream = client.messages.stream({
model: "claude-sonnet-5",
max_tokens: 1024,
system: systemBlocks,
messages,
});
stream.on("text", (delta) => controller.enqueue(encoder.encode(delta)));
const final = await stream.finalMessage(); // full message + usage, post-stream
Streaming is also load-bearing for reliability: long generations over non-streaming HTTP are how you meet your platform's function timeout. Beyond ~16K output tokens it stops being a UX choice and becomes a requirement. And cap max_tokens per route — a chat reply doesn't need 8K tokens of headroom, and per-endpoint output budgets bound worst-case cost and latency in one line.
Observability
You cannot tune what you don't measure, and with LLMs the meter is the usage object. Log it on every response:
logger.info("claude_call", {
route: "scope",
model: response.model,
stop_reason: response.stop_reason,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_read: response.usage.cache_read_input_tokens,
cache_write: response.usage.cache_creation_input_tokens,
latency_ms: Date.now() - started,
});
Five things this unlocks, in rough order of how often we look at them:
- Cost per route per day — token counts times your rates, no invoice surprises.
- Cache hit rate —
cache_readat zero is a regression, usually a timestamp someone added to a prompt. - Stop-reason distribution — a spike in
max_tokensmeans truncation; on newer models, watch forrefusaltoo, and handle it before readingcontent. - Tool-call traces — log every tool name, duration, and error flag per turn. When an agent misbehaves, what it tried is the debugging surface; the final answer tells you almost nothing.
- Latency percentiles — p95 with retries included, because that's what users experience.
Keep transcripts sampled and access-controlled — they're user data — and keep the request_id from error responses; it's what Anthropic support will ask for.
Takeaways
- An agent is a model, tools, a loop, and context management. Build the smallest version that works; add machinery only when the task demands it.
- Tool schemas are prompts. Say when to call the tool, use enums, describe every field, and parse — never string-match — tool inputs.
- Forced
tool_choice(or first-class structured outputs) gives you JSON without the "please respond in JSON" lottery. Validate the result with zod anyway. - Execute parallel tool calls concurrently and return all results in one user message; splitting them degrades both correctness and future parallelism.
- Default to bespoke tools; adopt MCP when a capability outlives one app or must interoperate with other hosts. Vet third-party servers like dependencies.
- Prompt caching is a prefix match on exact bytes. Freeze the system prompt, keep tools deterministic, put volatile content last, and verify
cache_read_input_tokensin logs. - Bound conversation history server-side. Retrieval earns its complexity only when the facts can't fit in a cached prompt.
- Set timeouts and retries deliberately, branch on typed SDK errors, fail closed at the API layer, and degrade gracefully in the UI.
- Run a golden-set eval before every prompt or model change. Thirty good cases beat zero perfect ones.
- Guardrails are layered: input validation, output validation, least-privilege tools, human approval for irreversible actions, and architectural separation between tools that read untrusted content and tools that can write.
- Tier models — small for routing, mid for the workhorse, large for hard steps — and stream anything a human watches.
- Log
usageon every call. Cost, cache hit rate, stop reasons, tool traces, and latency percentiles are your entire tuning surface.