Ideas, built and shipped.
This is a working record of two products built end-to-end with Claude Code — a document-intelligence SaaS that calls the Claude API live in production, and a four-sided logistics marketplace built entirely by an agentic coding session, with no LLM in its runtime path at all. Every screen ahead was captured from the running apps, not mocked for the occasion.
A pan-India truck tendering marketplace — four account types, KYC-gated onboarding, bid-based shipment tendering, live execution tracking, and manual two-way payment confirmation, running against a 25+ table Postgres schema with full RLS.
Upload a contract, NDA, or lease. Claude reads it end-to-end, streams back a plain-English breakdown clause by clause, and flags the terms that actually matter — HIGH, MEDIUM, NOTE — before generating a branded PDF report and emailing it out.
People conflate "built with AI" and "powered by AI" — they're different claims, and this pairing makes the difference concrete. One of these products was built entirely inside Claude Code sessions and never calls an LLM once it's running. The other has Claude in its request path every time someone hits Submit. Both are legitimate uses of the same underlying model — they just sit on opposite sides of the build-time/run-time line.
FreightNexus — Claude at build time
Zero anthropic imports anywhere in the codebase. Every schema,
RLS policy, wizard, and state machine was authored by a Claude Code agent working from a
9-file spec. Claude was the engineer, not a feature — the shipped product runs on Postgres
and TypeScript alone.
LegalClear — Claude at runtime
Every document analysis is a live call to claude-sonnet-4-5.
The product's entire value proposition — reading a contract and finding the risk — is
the model's output. Take the API key away and the app has nothing left to sell.
Summary
A legal-document translator that reads what you paste or upload — Terms of Service, NDAs, leases, employment contracts — and returns a section-by-section plain-English breakdown with every risky clause flagged by severity, streamed live and backed by an actual Claude call on every uncached request.
Request path, end to end
Two moving parts in front of the model: Nginx has to get out of the way of a streaming response, and the backend has to survive a 5–10 minute Claude call without the proxy or the browser giving up on it.
What it's actually built from
fetch + ReadableStream for the SSE endpoint Axios can't stream.StreamingResponse support.Anthropic) and async (AsyncAnthropic) clients, used for different endpoints.Four shipped versions, same running app
Each version was a separate, scoped requirements document fed to Claude Code with an explicit "do not rebuild, only add this" instruction — a deliberate discipline that kept a single-session build from regressing earlier work.
proxy_read_timeout would kill the connection long before Claude finished./api/translate-stream, that asks Claude to emit XML-tag-delimited JSON fragments (<meta>, <section>, <final>) instead of one blob, and forwards each as a Server-Sent Event the moment it's complete. First results now appear in 5–10 seconds regardless of document size. Required disabling Nginx's response buffering — see the Claude Integration tab for exactly why.The actual documents, not screenshots of them
The source contract analysed in the walkthrough below, and the report LegalClear generated from it — both previewable inline or downloadable.
The genuine UK Assured Shorthold Tenancy agreement fed into LegalClear — the input, unmodified.
A genuine document, analysed live
Every screenshot below was captured against the live deployed server at
legalclear.duckdns.org,
hitting the live Claude API (TEST_MODE=false, not the app's mock mode) and
analysing AST – 37 Greyfriars Road, a genuine tenancy/lease-style document, end to end.
Paste it, or upload the file
The landing page and the two ways to submit a document — paste raw text, or upload the actual file. The "Powered by Claude AI" badge is not marketing copy here; it's accurate.




Watching Claude write, section by section
This is the actual streaming pipeline mid-flight, run against the live production server — the rotating status text and progressive section rendering described in the Claude Integration tab, caught on camera against a genuine 35-section Claude Sonnet run.


The finished report
The finished result on this genuine 35-section tenancy agreement, run against the live production server (legalclear.duckdns.org): a one-line verdict, 4 named parties correctly extracted, 9 HIGH + 20 MEDIUM + 6 NOTE risk flags tallied, the top risks surfaced above the fold, and the full section-by-section translation below.








What happens after analysis
An email-delivery attempt and an actual PDF report download.


Keeping a $0/month server alive — and controllable from a phone
LegalClear runs on a single AWS EC2 t3.micro instance
inside the AWS Free Tier's 750-hour/month allowance. That allowance is per account, not
per instance, and free only for the first 12 months — which shapes almost every operational decision
below, right down to a purpose-built mobile control panel whose entire job is turning the server off
when nobody's using it.
The full request path in production
User → https://legalclear.duckdns.org
|
DuckDNS (free dynamic DNS — the IP behind
this name changes every time EC2 restarts)
|
┌────────────┴────────────┐
| |
EC2 running? EC2 stopped?
| |
▼ ▼
Nginx Netlify
(TLS via Certbot/ (static maintenance
Let's Encrypt, page — see below)
streaming-safe)
|
┌───┴────────────┐
▼ ▼
React build /api/* → FastAPI (systemd service, port 8000)
(static files |
served by Nginx) ┌────┴────┐
▼ ▼
Claude API SendGrid
legalclear.service), Restart=always, so a crash self-heals without a manual SSH session.certbot renew --dry-run.maintenance git branch when EC2 is deliberately stopped to save free-tier hours.750 hours a month means the server can't just stay on forever
A single EC2 instance running continuously uses roughly 730 hours a month — inside the 750-hour allowance, but with almost no margin, and only for the account's first 12 months. After that it's roughly $7.50/month indefinitely. Two options followed from this: stop the instance during genuinely idle periods (stopping pauses the hour counter entirely; only terminating loses data), or accept the eventual small monthly cost. The operational tooling below exists to make the first option practical without needing a laptop.
A pocket-sized on/off switch for a live AWS server
A second, separate, private repository — deliberately never merged into the main LegalClear codebase — containing a single-page mobile web app that starts and stops the EC2 instance and repoints DNS accordingly, with no laptop, terminal, or AWS Console required. Installable to a phone home screen like a native app.
What it does
StartInstances, polls for up to 90 seconds until AWS assigns a fresh public IP (start-up is asynchronous — there's no instant answer), then pushes that IP to DuckDNS. The domain goes live the moment DNS propagates.StopInstances — deliberately in that order, so visitors see a branded "back soon" page instead of a connection-refused error during the stop.DescribeInstances and renders a live badge — running (pulsing green), stopped (red), or checking (blue) — plus the current public IP.How it's built
A static HTML/CSS/JS page (no framework — the whole UI is one index.html)
deployed to Netlify, backed by one Netlify serverless function,
netlify/functions/control.js.
That function does the actual work: it hand-implements AWS Signature Version 4 —
HMAC-SHA256 request signing — in plain Node.js using only the built-in
crypto module, calling the raw EC2 HTTP API directly rather than
pulling in the full AWS SDK, which would be considerable weight for three actions.
Password gate is a client-side session check; the actual security boundary is that AWS credentials live only as Netlify environment variables, never in the shipped code, and the repository itself is private.
function getSignatureKey(key, dateStamp, regionName, serviceName) { const kDate = hmac('AWS4' + key, dateStamp); const kRegion = hmac(kDate, regionName); const kService = hmac(kRegion, serviceName); return hmac(kService, 'aws4_request'); } // ... builds a canonical request, hashes it, derives the signing key above, // and signs a plain GET to ec2.{region}.amazonaws.com — StartInstances, // StopInstances, or DescribeInstances, selected by the button that was tapped const authorization = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}`;
aws-sdk?
A Netlify Function has a cold-start cost proportional to its bundle size, and this function needs
exactly three EC2 read/write calls. Signing a request by hand in ~40 lines against Node's built-in
crypto keeps the whole function dependency-free — nothing to
npm install, nothing to go stale.


What this actually costs, before and after the free tier
| Resource | Free-tier limit | After 12 months |
|---|---|---|
| EC2 t3.micro | 750 hrs/month for 12 months | ~$7.50/month |
| EBS storage (20GB used) | 30GB free for 12 months | ~$0.10/GB/month |
| Data transfer | 100GB/month outbound free | Pay per GB beyond that |
| Netlify (control panel + maintenance page) | Free tier, indefinite | Free tier, indefinite |
| DuckDNS | Free forever | Free forever (requires login every ≤90 days to stay active) |
Post-free-tier options on the table: keep paying the ~$8–10/month, or move to AWS Lightsail (~$3.50/month) or a comparable DigitalOcean/Hetzner box (~$4–6/month) — none of which require re-architecting anything, since the deployment is a plain Ubuntu VM with systemd and Nginx, not anything AWS-proprietary.
Summary
A pan-India, mobile-first truck tendering marketplace. Not a dispatch app — a bidding marketplace: a shipper posts a shipment, eligible verified drivers bid on it with an itemised price, and the shipper picks the bid that suits them, not just the first one that shows up. Zero lines of this call an LLM at runtime; every table, policy, and screen was authored by Claude Code working from a nine-file specification.
Who's actually using this
A fifth role, Admin, exists purely operationally — provisioned directly in the database, no public registration path.
Everything runs on one Postgres instance
No separate application server. Next.js server actions and route handlers talk directly to Supabase, and every table is protected by Row-Level Security rather than application-layer access checks — the database itself refuses a query that shouldn't be allowed, not just the UI that would have hidden the button.
What it's actually built from
ui-ux-pro-max skill) persisted once, then referenced per-screen rather than re-derived.auth.users row per human; a profiles row carries the role and KYC state.in_transit trip stage.The state machine that runs every trip
A trip moves through 14 explicit statuses from posting to completion — not because the domain needs 14 states, but because every action either side takes (confirm the deposit, verify pickup photos, approve the final amount) is deliberately its own resting state so it shows as its own row on both parties' status timeline, rather than an invisible background flag.
new → bid_accepted → deposit_pending_payment → deposit_pending_confirmation
→ en_route_to_pickup → pickup_photos_pending_verification → in_transit
→ arrived_pending_signoff → delivery_photos_pending_verification
→ final_expenses_pending_submission → final_amount_pending_approval
→ final_payment_pending_payment → final_payment_pending_confirmation → completed
branches: cancelled (from `new` through pickup, penalty rules depend on stage)
expired (zero bids after 2 days, auto-transitioned by Vercel Cron)
One trip, start to finish
Two accounts on the live production Supabase project, starting from a completely empty database — Bhavik Jadav as the business owner and Akshay Makwana as the individual driver, both registered from scratch with genuine KYC documents and admin-approved one at a time, before a single shipment moves. Every screen below is the desktop view, captured live.
Landing page and sign-up





Bhavik registers from zero
A 4-step wizard: account, government ID, a passport-style photo, and a final review — genuine document photos uploaded, nothing mocked.





Akshay registers himself, then his truck
A longer wizard than the shipper's: driver KYC (government ID + driving licence + photo), then a three-part truck sub-wizard — details, five documents, four photos — all in one account.








A human reviews both accounts before anything can move
No auto-approval anywhere. Every government ID, driving licence, and truck document sits in one manual review queue until an admin explicitly approves it.




Business owner posts TRP00062
A live 5-step wizard: route, goods details, timeline, receiver + deposit, and a final review — geocoded live against Google Maps. Addresses render as city/state headline with the full geocoded line underneath, never a raw pincode.






Akshay browses, bids, and wins




Deposit confirmed, goods collected






Delivered, tolls trued up, final amount approved
The toll true-up in action: estimated ₹750 vs. actual ₹780, plus a genuine ₹200 loading-labor expense — folded into one shipper approval.






Trip closed, both sides rated

Both genuine invoices, generated automatically
Not screenshots of PDFs — the actual files, generated automatically at two different points in the actual TRP00062 trip lifecycle and fetched straight from production Storage. Preview inline or download.
Auto-generated the instant Bhavik accepted Akshay's bid — trip details, itemized bid, deposit amount, "Confirmed" status.
How LegalClear actually talks to Claude
This is the one part of the site written for people who read code, not just screenshots. Every excerpt below is copied verbatim from the live repository — no paraphrasing of logic, only trimmed for length where noted. FreightNexus has no equivalent section: it makes zero calls to the Anthropic API at runtime, by design (see the Overview tab).
Yes, it's an actual HTTP API — here's the endpoint
Before any of LegalClear's own code: what "calling Claude" concretely means.
It's one HTTP endpoint — POST https://api.anthropic.com/v1/messages —
authenticated by an API key in a request header. LegalClear never builds that HTTP request by hand;
it calls Anthropic's official Python SDK (pip install anthropic), and the
SDK's client.messages.create(...) method is that POST request,
wrapped so the caller works with Python objects instead of raw JSON and headers.
POST https://api.anthropic.com/v1/messages x-api-key: sk-ant-... anthropic-version: 2023-06-01 content-type: application/json { "model": "claude-sonnet-4-5", "max_tokens": 8192, "system": "You are LegalClear, an expert legal document analyser...", "messages": [ {"role": "user", "content": "Analyse the following legal document...\n\nDocument Name: ...\n\nLegal Document Text:\n..."} ] } ← non-streaming: one complete JSON response, after the full analysis finishes // add "stream": true to the same request — same endpoint, same headers, one new field — // and the response becomes a series of Server-Sent Events instead of one JSON body: { ... "stream": true } event: message_start data: {"type": "message_start", "message": {...}} event: content_block_delta data: {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "You"}} event: content_block_delta data: {"type": "content_block_delta", "delta": {"type": "text_delta", "text": " are"}} ... one content_block_delta event per streamed piece of text ... event: message_stop data: {"type": "message_stop"}
import anthropic client = anthropic.Anthropic(api_key="sk-ant-...") response = client.messages.create( model="claude-sonnet-4-5", max_tokens=8192, system="You are LegalClear, an expert legal document analyser...", messages=[ {"role": "user", "content": user_message} ], ) print(response.content[0].text) # the model's reply, as plain text # the streaming equivalent — .stream() instead of .create(), same arguments: with client.messages.stream( model="claude-sonnet-4-5", max_tokens=8192, system="You are LegalClear, an expert legal document analyser...", messages=[ {"role": "user", "content": user_message} ], ) as stream: for text_chunk in stream.text_stream: # one call per content_block_delta event above print(text_chunk, end="", flush=True)
| Field | What it is |
|---|---|
| model | Which Claude model answers this request — claude-sonnet-4-5 for every call LegalClear makes. Every valid model ID, its price per million tokens, and its own max-output ceiling are listed on Anthropic's models overview page — claude-sonnet-4-5 is a legacy model there now (the current lineup has moved on to Claude Sonnet 5 and beyond), but still listed as available and still what this pinned integration calls. |
| max_tokens | Not a target length — a hard ceiling on how many tokens the model is allowed to generate before being cut off. Claude can and does stop earlier once it naturally finishes; unused budget is neither generated nor billed. LegalClear sets 8,192: large enough that a full section-by-section analysis never gets truncated mid-JSON (which would break the parser), while still bounding the worst case if a response ever ran unexpectedly long. |
| system | A separate slot from the conversation — the fixed rulebook (Section 2 below). Doesn't count as a "turn." |
| messages | The actual conversation — a list of {role, content} turns. LegalClear only ever sends one: a single user turn, rebuilt fresh every request. |
stream: true to that same request tells the API to send the reply back as it's generated — a series of
data: {...} Server-Sent Events — instead of one complete JSON response at
the end. The SDK exposes this as client.messages.stream(...), an async
context manager you iterate over chunk by chunk (Section 4 covers exactly how LegalClear consumes
it). Same URL, same auth, same request body shape — one boolean is the entire difference between
"wait for the whole answer" and "get it as it's written."
The official reference for every field, every model name, every error code, and every SDK (Python, TypeScript, and others) is Anthropic's own API documentation at platform.claude.com — that's the actual spec; everything below is what LegalClear specifically built on top of it.
Two clients, two endpoints, one model
LegalClear runs two separate integration paths against the
same model, claude-sonnet-4-5 — a synchronous batch path
(claude_service.py) and an async streaming path
(stream_claude_service.py). Both exist because the streaming path was
added in v2 without touching the original — the batch endpoint is kept as a fallback and for the
large-document chunking pipeline, which doesn't stream sub-chunks individually.
def _create_claude_client(api_key: str): sdk_timeout = anthropic.Timeout( timeout=1800.0, # 30 minutes — matches the longest realistic document connect=30.0, read=1800.0, write=60.0, pool=10.0, ) return anthropic.Anthropic(api_key=api_key, timeout=sdk_timeout)
def _create_async_claude_client(api_key: str): return anthropic.AsyncAnthropic( api_key=api_key, timeout=anthropic.Timeout(timeout=1800.0, connect=30.0, read=1800.0, write=60.0, pool=10.0), )
A 30-minute SDK timeout looks
extreme until you remember it has to survive worst case: MAX_DOCUMENT_CHARS
defaults to 50,000 in the env template, but the actual safeguard in code is 800,000 characters
(_MAX_DOCUMENT_CHARS) before hard truncation — a genuinely huge contract,
chunked into ~28 pieces, each needing its own full model round-trip.
Where the actual legal judgment lives
This is the entire product's intelligence, in one string. It does three jobs at once: sets the analytical stance (assume unfair terms until proven otherwise), fixes an output contract strict enough to parse reliably, and — added in v3 — caps every field's length so the result reads as a verdict, not an essay.
You are LegalClear, an expert legal document analyser. Your role is to translate legal documents into plain English and identify risks for the person being asked to agree to the document (referred to as "the user"). CRITICAL STREAMING INSTRUCTION: You must output your analysis section by section. For each section you find, output a complete JSON object on its own line, wrapped in <section> tags like this: <section>{"section_id": 1, "title": "...", "category": "...", "original_excerpt": "...", "plain_english": "...", "risk_flags": [{"severity": "HIGH", "title": "...", "explanation": "..."}]}</section> Before the first section, output document metadata wrapped in <meta> tags: <meta>{"document_name": "...", "verdict": "...", "parties": [...], "summary": "..."}</meta> After ALL sections, output a final summary wrapped in <final> tags: <final>{"overall_risk_level": "LOW|MEDIUM|HIGH", ...}</final> RULES: 1. Read the ENTIRE document before outputting anything 2. Output <meta> first, then each <section>, then <final> 3. Every section JSON must be complete and valid — never break a section across lines ... 5. Categorise every clause: Your Rights | Company Rights | Your Obligations | Company Obligations | Termination | Liability & Disputes | Data & Privacy | Payment & Fees | Intellectual Property | Other ... 7. Never soften language around genuinely harmful clauses 8. Always flag: arbitration, data selling, auto-renewal, unilateral modification, liability waivers, jurisdiction clauses 9. Cross-reference dependent clauses — never translate one in isolation if it depends on or is modified by another ... 11. Approach every document with the assumption it may contain unfair terms OUTPUT BREVITY RULES — strict, followed exactly: - verdict: EXACTLY 1 sentence, max 20 words. The single most important thing the user needs to know. Direct and honest. - summary: EXACTLY 1 sentence, max 25 words. No risk language — just scope. - plain_english (per section): Max 3 sentences. What it does, who it affects, the practical implication. No padding. - risk flag explanation: Max 15 words. Be direct. No padding or softening.
How the request actually gets built — every single call, from zero
LegalClear has no memory between requests and no retrieval step — there's no vector store, no embedding search, no prior turns to load. Every analysis is a cold, single-turn call with exactly two pieces of context: a fixed system prompt (the analytical rules and output contract, identical on every request) and a user message built fresh from three things — a restated JSON schema, the document's name, and the raw document text, concatenated directly into one string. That's the entire context window. No summarization pass, no chunk retrieval at query time — the model reads the whole document in one shot, in one turn, every time.
system parameter, not a message. Carries the persona ("You are LegalClear..."), the 11 numbered interpretation rules, the output format contract, and the per-field word-count ceilings. Anthropic's API treats this as a separate slot from the message list — it doesn't count against message history, and it's the same string byte-for-byte on every single call this service ever makes.Document Name: {document_name}, followed by the full raw document text. One string, one user turn, no message history before it.messages.create() is called with a single {"role": "user", ...} entry — the one exception is the one-shot JSON-parse retry (Section 5 below), which appends a single primed assistant turn to bias reformatting, then throws the whole exchange away either way.Analyse the following legal document and return a JSON object conforming to this exact schema:
{
"document_name": "string",
"verdict": "string — EXACTLY 1 sentence, max 20 words...",
"parties": [{ "name": "string", "role": "string", "description": "string" }],
"summary": "string — EXACTLY 1 sentence, maximum 25 words...",
"sections": [{
"section_id": "integer",
"title": "string",
"category": "one of: Your Rights | Company Rights | ...",
"original_excerpt": "string — max 300 chars, truncate with ellipsis",
"plain_english": "string — Maximum 3 sentences...",
"risk_flags": [{ "severity": "HIGH | MEDIUM | NOTE", "title": "...", "explanation": "..." }]
}],
"overall_risk_level": "LOW | MEDIUM | HIGH",
...
}
Document Name: {document_name}
Legal Document Text:
{document_text}
Processing the response in chunks — so there's always something to look at
The whole point of this section: a 38-section lease takes Claude roughly
four minutes to fully analyse. A blank screen for four minutes reads as broken, not thorough. So
LegalClear never waits for the complete response — it processes Claude's output as it's being
written, section by section, and hands each finished piece to the browser the instant it's
ready. The single biggest constraint this creates: a top-level JSON object can't be usefully parsed
until its closing brace arrives — you can't safely render "the first two sections" out of a
half-finished blob. The fix is to change what Claude is asked to emit, not to parse JSON harder: wrap
each independent unit in a plain-text tag pair, and scan the growing text buffer for complete
<tag>...</tag> pairs on every chunk, parsing and emitting each
one the instant it closes — regardless of how much of the response is still unwritten.
<meta> block — document name,
verdict, parties — lands around 5–10 seconds in, so the "At a Glance" card is populated long
before analysis is done; then one section card mounts roughly every few seconds as each
<section> tag closes, each with its own risk flags, so the "What to
Watch Out For" panel and the live stat counters climb continuously rather than jumping from 0 to 38
at the very end. The user is reading genuine analysis of their own document from around the 10-second
mark — not staring at a spinner until minute four.
buffer = "" async with client.messages.stream( model="claude-sonnet-4-5", max_tokens=8192, system=SYSTEM_PROMPT, messages=[{"role": "user", "content": user_message}] ) as stream: async for text_chunk in stream.text_stream: buffer += text_chunk # extract a complete <meta> block the moment it closes meta_match = re.search(r'<meta>(.*?)</meta>', buffer, re.DOTALL) if meta_match: meta_data = json.loads(meta_match.group(1).strip()) yield {"type": "meta", "data": meta_data} buffer = buffer[meta_match.end():] # drop consumed text # drain every complete <section> block in this chunk (there may be >1) while True: section_match = re.search(r'<section>(.*?)</section>', buffer, re.DOTALL) if not section_match: break section_data = json.loads(section_match.group(1).strip()) yield {"type": "section", "data": section_data} buffer = buffer[section_match.end():] yield {"type": "heartbeat", "data": {}} # every chunk, keeps proxies alive
The <final> block is
extracted once after the stream ends, since it's the model's summary of everything it just wrote —
it can only be correct once every section exists.
yield f"data: {json.dumps(chunk)}\n\n" inside a StreamingResponse with media_type="text/event-stream".fetch + ReadableStreamEventSource — the frontend needed a POST body (the document text), which EventSource can't send. api.js manually splits on \n, buffers partial lines, and dispatches each parsed event to onMeta / onSection / onFinal / onCached / onError callbacks wired up in App.jsx.setStreamingSections(prev => [...prev, section]) — each new card mounts with a CSS entrance animation, and the Summary Card's stat counters, top-risk pills, and "What to Watch Out For" panel all derive live from the same growing array, no separate re-fetch.Claude doesn't always return clean JSON — plan for it
The batch endpoint asks for a single JSON object with no markdown fences,
no preamble. It usually complies. "Usually" is not a production error-handling strategy, so every
response goes through a defensive extractor before json.loads ever sees it.
def _extract_json(text: str) -> dict: text = text.strip() # strip ```json ... ``` fences anywhere in the text, even mid-string text = re.sub(r"```(?:json)?", "", text) text = text.strip() # find the OUTERMOST { ... } — robust to any preamble/postamble Claude adds start = text.find("{") end = text.rfind("}") if start != -1 and end != -1 and end > start: text = text[start : end + 1] return json.loads(text)
If that still fails to parse, there's exactly one retry — not a loop, a single deliberate second attempt with the model's own (empty) turn primed to bias it toward compliance:
try: extra = [{"role": "assistant", "content": "I will now return only the valid JSON object with no additional text:"}] raw = _call(extra_messages=extra) # same user message, one extra primed turn return _extract_json(raw) except (APITimeoutError, APIConnectionError, APIStatusError): raise RuntimeError(_CLAUDE_API_ERROR_MESSAGE) except Exception: raise RuntimeError(_CLAUDE_PARSE_ERROR_MESSAGE)
Pre-filling an assistant
turn with the start of the desired response is a documented Anthropic API technique for steering output
format — here it's used defensively, only on retry, rather than on every call, since it costs an
extra round trip and the first attempt succeeds the overwhelming majority of the time.
What happens past ~28,000 characters
Claude's own context window could handle far more text than this in one call — the chunk size here is chosen to keep each individual analysis pass fast and its output comfortably under the 8,192-token output ceiling, not because the model runs out of room to read.
| Constant | Value | Why |
|---|---|---|
| _MAX_OUTPUT_TOKENS | 8192 | Model's actual output ceiling — a chunk's analysis must fit inside one response. |
| _MAX_CHUNK_CHARS | 28,000 | Split threshold — documents under this go through the single-call path. |
| _CHUNK_OVERLAP_CHARS | 2,000 | Each chunk after the first re-includes its predecessor's tail, so a clause split across the boundary still has context on both sides. |
| _MAX_DOCUMENT_CHARS | 800,000 | Hard safeguard — beyond this the document is truncated rather than chunked indefinitely. |
Two of these numbers — 8192 and 28,000 — get compared often, but they're not directly related:
| 8192 | 28,000 | |
|---|---|---|
| What it counts | Tokens | Characters |
| Which direction | Claude's output (the answer coming back) | LegalClear's input (the document going in) |
| What it controls | A hard API parameter (max_tokens=8192) — Claude cannot generate more than this many tokens in one response | An internal threshold LegalClear's own code checks — "if the document is longer than this, split it" |
| Who enforces it | Anthropic's API — cuts Claude off mid-generation if it hits this cap | LegalClear's own logic — nothing external enforces 28,000, it's just the number chosen |
A token is roughly ¾ of a word, so 8,192 tokens of output is closer to ~30,000+ characters of English text if it were the same kind of text. But it isn't: 28,000 is how much raw document text goes in, and 8,192 is how much structured analysis (JSON, translations, risk flags) can come back out. There's no formula linking them — 28,000 is an empirical, conservative threshold for "the largest input that reliably produces an analysis fitting under the real 8,192-token ceiling," tested rather than calculated, with margin to spare.
Chunk boundaries prefer a newline, then a space, over a hard character cut —
text.rfind("\n", start, end) before falling back to
text.rfind(" ", start, end) — so a split never lands mid-word or, ideally,
mid-sentence. Each chunk is analysed independently, section IDs are renumbered sequentially after
merging, and — deliberately — the merged risk counts are recomputed from the actual merged
risk_flags arrays rather than trusted from each chunk's self-reported
count, because per-chunk counts from the model are "approximate or inconsistent across chunks," per
the code's own comment. Party extraction runs as a separate, smaller call over the first 120,000
characters, since parties are usually named early and a full second pass isn't needed.
Splitting a document and sending it to Claude, chunk by chunk
This mirrors translate_document() in
claude_service.py exactly — a plain for idx, chunk in
enumerate(chunks) loop. Chunks are sent to Claude one at a time, in order,
never in parallel — the second chunk's request doesn't start until the first one's response has
fully landed. Hover the diagram to pause on any step; move away to resume.
The instant result has to still feel like analysis
The cache key is a SHA-256 hash of the document text after whitespace and case normalisation, so "the same NDA with different line endings" still hits. The interesting part is what happens on a hit — the frontend has no separate "cached result" code path, so the backend has to replay the stored result through the exact same SSE shape a live call would produce.
meta_chunk = {"type": "meta", "data": {...}}
yield f"data: {json.dumps(meta_chunk)}\n\n"
await asyncio.sleep(0.15) # 150ms — instant would read as broken, not fast
for section in cached_result["sections"]:
yield f"data: {json.dumps({'type': 'section', 'data': section})}\n\n"
await asyncio.sleep(0.15)
# ...final chunk, then a dedicated 'cached' event the frontend uses only to
# show the ⚡ Instant result badge — everything else is indistinguishable from live
yield f"data: {json.dumps({'type': 'cached', 'data': {'is_cached': True}})}\n\n"
Resilience is explicit throughout: the cache write after a live call is
wrapped in its own try/except with a comment stating a cache failure "must
never cause a user-facing error," and a cached result missing any of
document_name / sections /
overall_risk_level is treated as a miss and silently falls through to a
live Claude call rather than replaying something broken.
Three failure modes, three different messages
| Exception | Meaning | User sees |
|---|---|---|
| APITimeoutError / APIConnectionError / APIStatusError | Anthropic's API itself is unreachable, rate-limited, or erroring | "Claude AI is unavailable right now. Please try again in a few moments." |
| json.JSONDecodeError / ValueError | Model responded, but the text isn't valid JSON even after extraction — triggers the one-shot retry before surfacing | "Claude AI returned an invalid response. Please try again or split the document into smaller sections." |
| No ANTHROPIC_API_KEY | Misconfiguration, caught before any network call is attempted | Same as the API-unavailable message — deliberately doesn't leak "the server isn't configured correctly" to an end user |
On the streaming path specifically, a 60-second client-side heartbeat timeout
(paired with the backend's own 5-second ping events) distinguishes "Claude
is still thinking" from "the connection actually died" — if 60 seconds pass with zero bytes, even
pings, the frontend gives up and shows a timeout message rather than a silently frozen progress bar.
The account actually paying for this
Everything above is the code's side of the story. This is the other side — the actual Anthropic Console for the account LegalClear runs on: live spend, genuine token volume, and the actual request log, one row per API call this document analysis made.



claude-sonnet-4-5 requests — model, input/output tokens, streaming, service tier
Stop prompting Claude. Start looping it.
Not a product, and not a Claude API case study like the two above — a practical breakdown of a real shift in how Claude Code gets used day to day. A plain prompt runs once and stops. A loop is a saved, reusable structure that keeps re-prompting Claude on its own, repeatedly, until a goal is verifiably done — without you typing the next instruction yourself each time.
Not every task deserves a loop
A loop costs real setup time and real tokens to run unattended. Before building one, four conditions are worth checking — if a task fails any of them, a plain one-off prompt is the right tool, not a loop.
Every loop that actually works has all four
A loop isn't just "a prompt that repeats." Leave any one of these four pieces out and it either never starts, never finishes, burns tokens without improving, or forgets everything it learned between runs.
What starts the loop
The event that kicks it off — a fixed interval, a schedule, or a command you run by name. No trigger, no loop — it's still just a prompt waiting to be typed.
What actually does the work
Saved, reusable, already-tested instructions the loop calls on each pass. A loop orchestrates; skills are what do the real task, the same way every time.
What "done" means, and how to check it
These two can't be separated — a goal with no way to verify it is just a wish. The check is what turns "I think it's done" into "it's actually done."
What it produces, and what it remembers
The output is obvious — a file, a deploy, a message. The memory is the part most people skip: without it, every run starts from zero and repeats the same mistakes.
How a loop actually gets started
Claude Code has genuine, shipped commands for this, not a metaphor. Four distinct types cover how a loop actually gets triggered — turn-based, goal-based, time-based, proactive — and the exact command syntax below is pulled from Anthropic's own Claude Code documentation, not paraphrased from memory.
/goal/loop/schedule (Routines)// Sets a completion condition. Claude takes a turn immediately, and after // each turn a separate small fast model checks the condition against the // transcript: not yet met -> another turn runs; met -> goal clears; judged // impossible -> goal clears with a reason, no infinite loop. /goal all tests in test/auth pass and the lint step is clean // A well-formed condition names one measurable end state, how Claude // should prove it, and a bound so it can't run forever: /goal Refactor the data-fetching layer in Dashboard.tsx until Lighthouse performance score is >= 92 and LCP is under 1.8s as shown by the Lighthouse CLI output. Do not change the public API of any hooks. Stop after 10 turns. /goal // no argument -> shows status: condition, turns run, token spend /goal clear // cancels it (aliases: stop, off, reset, none, cancel)
// Interval + prompt -> runs on that fixed cron-backed schedule /loop 5m check if the deployment finished and tell me what happened // Prompt only, no interval -> Claude re-picks the delay (1min-1hr) each time, // based on what it just observed -- short waits while something's active, // longer once it goes quiet /loop check whether CI passed and address any review comments // No prompt at all -> runs Claude Code's built-in maintenance prompt // (finish pending work, tend the current PR, clean up) -- or your own // .claude/loop.md if one exists in the project /loop // A loop can hand off into a goal once its own condition fires: /loop every 24h "Check GitHub for issues labeled 'bug'. If one exists, use /goal to implement a fix until all local tests pass and push the branch."
Two concrete constraints worth knowing before reaching for either: a
/loop expires automatically 7 days after creation, and both commands are
session-scoped by default — closing the session stops them, unless you move to
/schedule below.
// Conversational setup -- Claude asks what repo, prompt, and trigger you want /schedule // Or describe it directly: a recurring routine... /schedule daily PR review at 9am // ...or a one-off that fires once and deletes itself /schedule in 2 weeks, open a cleanup PR that removes the feature flag // Managing existing routines /schedule list // see all routines /schedule update // change one, e.g. set a custom cron interval /schedule run // trigger one immediately, outside its schedule
Routines are the "proactive" type because they don't need a session open at all — they run as full cloud sessions on Anthropic's infrastructure, triggered by a schedule, an HTTP POST to a per-routine endpoint, or a GitHub event such as a new pull request. This is the version suited to genuinely unattended work: nightly issue triage, deploy verification, docs-drift checks.
A loop without battle-tested skills behind it just guesses
The trigger only decides when a loop runs. Skills decide how it does the actual work — and this is the block that most determines whether a loop is trustworthy enough to leave running unattended.
A skill in Claude Code is a saved, reusable set of instructions — a Markdown file with a short frontmatter block (a name and a description Claude uses to decide when it applies) followed by the actual instructions in plain language. Once saved, it runs the same deliberate way every time, instead of Claude improvising fresh from a bare instruction with no memory of how you actually want that kind of task done.
--- name: pr-review description: Review a pull request for correctness, test coverage, and style against this repo's own conventions. Use when asked to review a PR or check a diff before merging. --- Check the diff against these rules, in order: 1. Every new function has a test exercising its actual failure case, not just the happy path. 2. No new dependency without a one-line justification in the PR description. 3. Match this repo's existing naming and error-handling conventions — don't introduce a new pattern for something already solved elsewhere. Leave inline comments for anything that fails a rule. Summarize with a clear verdict: APPROVE, REQUEST CHANGES, or COMMENT — never a vague "looks mostly fine."
A goal with no way to check it isn't a goal, it's a hope
These two are inseparable by design: a goal states what "done" looks like, and verification is the rule that actually confirms it. Neither one works alone — a goal nobody checks just drifts, and a check with no goal has nothing to check against.
For technical tasks this is straightforward — a load-time threshold, a test
suite passing, a lint step being clean. For less quantifiable tasks it takes real deliberate work:
bridging the abstract to the verifiable — turning "is this good" into something a
loop can actually check, like a review skill that has to output a concrete verdict (APPROVE
/ REQUEST CHANGES) instead of an open-ended opinion.
| Task type | Goal | Verification |
|---|---|---|
| Technical | Deploy the site and have it load in under 2 seconds | Hit the live URL, measure load time, confirm the expected content is present |
| Less quantifiable | Draft a reply for every unread email | Every unread thread has a draft, and that draft passes a dedicated review skill — not just "looks reasonable" |
/goal evaluator deliberately
checks rule compliance against your stated condition, not whether the output is actually
good — it can't judge subjective quality, only whether the hard constraint you wrote holds.
For anything where "did this actually work" needs real judgment, the stronger pattern is two agents:
one drafts the change, a separate one — that didn't write it — verifies it. A single agent grading
its own work tends to miss exactly what it was already blind to.
The part every loop needs and almost nobody builds
The output is the obvious half — a file, an updated codebase, a deployed site, a message sent somewhere. The non-obvious half, and the one most loops skip, is memory. A loop with no memory starts from zero on every single run — no record of what it tried last time, what failed, or what it already learned — which means no improvement between runs, and tokens spent re-discovering the same dead end it hit last time.
A live game-score website, mapped to all four blocks
Not a coding-tool example — a real-world one. Say you run a website that shows live scores for a sports league. During a match window, the score changes every few minutes and the site needs to reflect that without anyone manually editing a page. Here's the same four blocks from above, filled in for that exact job.
Every 10 seconds, only during live matches
A /loop on a short fixed interval, active only inside each match's
actual start/end window — no point polling a score feed at 3am when nothing's being played.
/update-scoreboard
Reads the official score feed, compares it against what the site currently shows, and — only if it actually changed — rewrites the scoreboard widget and pushes the update live.
The live page matches the real score, always
Verified by re-fetching the official feed after every publish and confirming the number on the site equals the number the feed reports — not "we published something," but "it's correct."
The published page, and the last-known score
The output is the live scoreboard update itself. The memory is just the last score it already published — so a quiet check that finds nothing new costs almost nothing, instead of re-publishing an identical page every single pass.
Verifying every screenshot on this very site
Not a hypothetical — this is the actual loop used to get the LegalClear and FreightNexus screenshots on this site production-ready. The brief was simple to say and easy to get wrong in practice: capture every real stage of both products' user journeys, with nothing broken in frame — no sticky header or footer floating mid-screenshot, no field with text so long it wraps and clips. A single capture pass never gets all of that right; this had to run, check itself, and fix its own misses across several iterations.
Turn-based, run on demand inside the build session
Not scheduled, not polling — a one-off, deliberately run each time the screenshot set needed a pass, since this only needed to happen while the site's content was actively being built, not on an ongoing cadence afterward.
/capture-and-verify-screenshots
Drives a real browser through both apps' full journeys, saves every screenshot to disk, then inspects each one — page structure and pixels both — for the specific defects that actually kept recurring.
Every stage captured, nothing broken in frame
Two conditions, both checked mechanically: every row in that app's shot-list.md
actually has a matching file, and no file has a sticky header/footer overlapping content or a text
field wrapped/clipped from overflow.
The screenshot files, plus a running list of what still fails
The output is the corrected screenshots themselves. The memory is a short list of which specific files failed which specific check last pass — so the next pass fixes exactly those, instead of re-auditing every screenshot from zero each time.
"The shot list" isn't ambient knowledge Claude is expected to infer — it's a plain file, generated once by Claude before the first capture pass and then treated as fixed, that the skill reads every single pass afterward. It's the actual source of truth for what "every stage" means.
# LegalClear — required screenshots | Filename | Stage | |------------------------------------|-------------------------------------------| | 01-landing.png | Landing page, logged out | | 02-paste-input.png | Paste-text input, ready to submit | | 03-upload-input.png | File-upload mode, a real PDF attached | | 04-streaming-mid.png | Mid-stream, section cards appearing live | | 05-results-viewport-top.png | Finished report, scrolled to top | | 05-results-viewport-bottom.png | Finished report, scrolled to bottom | | 06-email-modal.png | Email-report modal, address entered | | 07-pdf-download-success.png | Post-download success toast | A row here with no matching file in screenshots/legalclear/ after a pass is exactly what "not covered yet" means — nothing fuzzier than that.
FreightNexus has its own equivalent file at
screenshots/freightnexus/shot-list.md, with rows for its own journey instead
— registration, KYC review, posting a trip, bidding, execution, settlement. Same format, same rule:
a row is only "done" once its named file exists and passes the defect checks below.
--- name: capture-and-verify-screenshots description: Drive LegalClear and FreightNexus through their real user journeys in a headless browser, capture a screenshot at every stage listed in that app's shot-list.md, then verify each one against a set of layout defect checks. Re-run for any stage that fails either check. --- 1. Open screenshots/<app>/shot-list.md for whichever app is named in the prompt. Every row is a required screenshot — its filename and which stage of the journey it has to show. This file is the definition of "covered," not a suggestion, and it is never generated or edited by this skill — only read. 2. For each row: navigate the real running app to that exact state, wait for the page to finish loading and animating, then capture a full-page screenshot to screenshots/<app>/ using that row's exact filename from shot-list.md. 3. After every stage is captured, open each screenshot and check it against every one of these, specifically: - Does a sticky header or footer overlap page content instead of sitting cleanly at the top/bottom edge? - Does any text field, badge, or label wrap onto extra lines or get visually clipped because the content is longer than the layout was sized for? - Is the page in a genuinely finished state (no spinner, no half-rendered skeleton, no cut-off animation mid-transition)? 4. Any screenshot that fails a check goes on a retry list, tagged with which specific check it failed. Do not silently re-capture everything — only the stages that actually failed, and only re-check the specific condition each one failed, not the entire checklist over again for shots that already passed. 5. Repeat step 2-4 for everything still on the retry list. Stop only when the retry list is empty — every stage present, every one of them clean.
## Task Run /capture-and-verify-screenshots for LegalClear and FreightNexus. ## Definition of done Every row in both screenshots/legalclear/shot-list.md and screenshots/freightnexus/shot-list.md has a matching screenshot on disk, and every one of those screenshots is clean: no sticky header or footer sitting in the middle of the frame, and no field or label with text long enough to wrap or get clipped. ## Verification Don't just confirm a file exists at each path — actually look at each image. A screenshot that exists but has a floating header overlapping the page, or a company name wrapped onto three lines covering the field beneath it, still counts as a miss. Re-capture only what actually failed, and keep going until every single one is right.
// Claude re-picks the delay itself between passes — no fixed interval // makes sense here, since "how long until the next retry pass" depends // entirely on how many screenshots just failed, not a clock /loop using the capture-and-verify-screenshots skill, follow the task, definition of done, and verification steps in prompt.md exactly. Keep re-running until the retry list is empty.
A schedule, and the skill it actually calls
Putting the four blocks together concretely — the same shape each time, filled in differently depending on which trigger fits the task.
// Trigger: a cloud routine, fires every alternate Tuesday morning // — a fixed calendar cadence, not tied to any one release or session // Skill called: /audit-prod-endpoints — reads every appsettings entry // for the Production environment and checks each service endpoint URL // Goal + verification: zero Production endpoint URLs contain "np" or // "sandbox" — checked by scanning every config value, not sampling some // Output + memory: a pass/fail report, plus a list of any offending // keys; a log of what was scanned each run, so a repeat offender // across runs is visible, not just this run's result /schedule every alternate Tuesday morning: run /audit-prod-endpoints against the Production appsettings config. Report it if any service endpoint URL contains "np" or "sandbox" — those are non-prod environment markers that should never appear in a Production config. List every offending key in the report.
--- name: audit-prod-endpoints description: Scan the Production appsettings config for service endpoint URLs that still point at a non-prod environment. Runs on a recurring schedule as a standing safety check on Production config. --- Read every key in the Production appsettings file whose value looks like a URL (contains "http://" or "https://", or ends in a known service-host suffix). For each one, check whether it contains the substring "np" or "sandbox" anywhere in the hostname. If any match is found, report it — the exact key name and the offending URL, never just a count. A clean scan with zero matches is the expected result on every run.
/schedule routine instead of a session-bound
/loop — because a standing config-safety check needs to run reliably on
its own calendar, not depend on someone having a terminal open at the right moment. The skill and
the goal-plus-verification discipline underneath are identical either way: do the check the same
deliberate way every time, and never call something safe without actually scanning for it.
The Ralph technique — the same four blocks, a different trigger mechanism
Everything above is the general pattern. "Ralph" is a specific, named way of running it — a real, documented technique (with an actual Anthropic-published plugin) that keeps Claude Code working on its own by intercepting its exit attempts, instead of waiting for a fresh trigger to fire again. The name comes from Ralph Wiggum, as a nod to the "keep trying regardless of setbacks" spirit behind it.
A loop that blocks its own exit
The original version is a plain shell loop outside Claude Code, relaunching it with the same prompt file each pass. A newer built-in version uses a Stop hook instead — it intercepts the moment Claude tries to end its turn and, if the work isn't actually done, blocks that exit and feeds the same prompt straight back in, all inside one continuous session.
The same prompt file, every single pass
Not a different instruction each round — the identical saved prompt, re-read fresh every iteration. Consistency across dozens of passes is the point: the same deliberate instructions, not a slightly different one each time.
A completion promise, not a vibe
The loop keeps running until Claude explicitly outputs an agreed-upon signal — commonly the literal word "complete" — when it judges the task genuinely finished. No completion promise, no exit; a vague sense of "probably done" doesn't count, matching the same discipline as every goal above needing a real, checkable end state.
The file system itself is the memory
Each iteration starts with a completely fresh context window — no conversation history carried forward. Instead, Claude reads its own prior work straight off disk to figure out what's already done and what's left, which is a far more literal version of "memory" than a log file: the codebase on disk is the record of progress.
There are two real ways to actually run it — a built-in one inside Claude Code, and the original raw version underneath it.
// One-time: add Anthropic's plugin marketplace, then install it /plugin marketplace add anthropics/claude-code /plugin install ralph-wiggum@claude-code-plugins // Then, in any session — point it at a prompt and a completion promise /ralph-loop "implement the CSV import feature per docs/csv-import-spec.md, with tests passing" --completion-promise "complete" --max-iterations 40
This writes a small state file, then re-feeds that same prompt to itself every
time Claude tries to stop — inside one continuous session — until Claude actually outputs
complete, or the iteration cap is hit. The cap isn't optional polish; it's the
real safety net if the completion promise never fires.
The prompt only points at docs/csv-import-spec.md by
name — the actual done-criteria live in that file, not the command. This is the same shape as a skill's
frontmatter: a short pointer that fires the work, and a separate saved file that defines what "done"
really means.
# CSV import feature — spec ## What it does Add a `POST /imports/csv` endpoint that accepts an uploaded CSV file, validates every row, and inserts the valid ones into the `contacts` table. Invalid rows are skipped, not fatal to the whole upload. ## Required columns `name` (non-empty string), `email` (must contain "@"), `signup_date` (ISO 8601, e.g. 2026-01-15). ## Behavior on bad rows - Missing or malformed column -> skip the row, don't insert it - Duplicate email already in `contacts` -> skip, don't overwrite - The response body lists how many rows were inserted, how many were skipped, and the line number + reason for each skipped row ## Definition of done - `pytest tests/test_csv_import.py -v` passes, covering: a clean file, a missing-column row, a bad-email row, a duplicate-email row, and an entirely empty file - Manually run once against `fixtures/sample_contacts.csv` and confirm the row counts in the response match the fixture by eye Only reply with the word "complete" once both of those are true — not after writing the code, after actually verifying it runs.
Notice the file ends the same way every skill and goal on this page has to — with a real, checkable finish line, not "implement CSV import" left to interpretation. That's what makes the completion promise trustworthy instead of a guess.
// prompt.md holds the one instruction Claude re-reads every pass // task list, done criteria, and "reply with COMPLETE when finished" while true; do cat prompt.md | claude -p --dangerously-skip-permissions // each pass is a brand-new session -- no chat history carries over, // so prompt.md has to point Claude back at its own prior work on disk done
No Claude Code plugin involved — just a shell loop relaunching a fresh
claude process each pass, with the file system doing all the remembering. This
is the version the technique was named after, and it's still the one to reach for outside Claude Code
or in a CI box that just needs a loop, not a feature.
The same screenshot-verification skill from earlier in this tab works here too
— the only thing that changes going from a plain /loop to Ralph is the
stopping condition: a fixed word Claude has to actually say, instead of "the retry list is empty."
// Once the plugin's installed, this points /ralph-loop at the exact same // skill and prompt.md used earlier — the work doesn't change, only how // the loop knows when to stop does /ralph-loop using the capture-and-verify-screenshots skill, follow prompt.md exactly. Reply with "complete" only once every row in both apps' shot-list.md files has a matching screenshot, and every screenshot is clean. --completion-promise "complete" --max-iterations 15
The --max-iterations 15 cap matters here for a real
reason, not just as boilerplate: if a genuinely broken screenshot check kept the retry list non-empty
forever, this stops it from re-capturing the same failing shot in an unbounded loop instead of
eventually surfacing that something's actually wrong.
Three ways to build an agent on Claude
Everything in the Loop Engineering tab was about looping inside Claude Code — a session you already have open. This tab is one layer underneath that: how to build a standalone agent of your own, running on your own infrastructure or Anthropic's, callable from your own code instead of a terminal. Three real options exist, and picking the right one matters more than the code that follows.
| Approach | What it actually is | Who hosts it | Reach for it when |
|---|---|---|---|
| Agent SDK | Claude Code's full harness (loop, built-in tools, permissions, hooks, MCP) as a library | You | You want file/shell/search tools out of the box, full control |
| Tool Runner | A thin agentic loop over tools you define yourself | You | A handful of custom tools, no need for the full Code harness |
| Managed Agents | The same agent shape, sandboxed and run on Anthropic's infrastructure | Anthropic | Long-running or scheduled work, minimal ops on your side |
The fastest real agent you can run
The Agent SDK is the same harness that runs every Claude Code session — the agentic loop, built-in tools, permission handling — packaged as a Python or TypeScript library instead of a terminal app. You write a prompt and a short options object; the SDK runs the actual think-call-tool-observe-repeat loop for you. No hand-written "check if a tool was called, run it, feed the result back in" logic.
# Python pip install claude-agent-sdk export ANTHROPIC_API_KEY=your-api-key # TypeScript / Node.js 18+ npm install @anthropic-ai/claude-agent-sdk export ANTHROPIC_API_KEY=your-api-key
The task: a production service emits error-rate metrics continuously. Nobody is watching a dashboard at 3am. An agent checks that metric on a timer, and only when it actually sees a real spike does it wake up, dig through the codebase and recent deploys for a likely cause, and file a report — the rest of the time it does nothing at all.
import asyncio, time from claude_agent_sdk import query, ClaudeAgentOptions async def check_and_investigate(): error_rate = fetch_current_error_rate() # your own metrics call, not Claude if error_rate < THRESHOLD: return # the normal case: nothing to do, nobody notified async for message in query( prompt=f"Error rate just hit {error_rate}. Check the last 3 deploys and " "the relevant service logs, find the likely cause, and file a " "report to #incidents with your best guess and the evidence.", options=ClaudeAgentOptions( allowed_tools=["Bash", "Grep", "Read"], permission_mode="acceptEdits", model="claude-sonnet-5", ), ): print(message) while True: asyncio.run(check_and_investigate()) time.sleep(60) # check again in a minute, whether or not anyone's watching
Nothing here could be "just a prompt," because there's no moment where a person is sitting at a keyboard to type one — the trigger is a metric crossing a line, not a person deciding to ask. That's the actual dividing line between prompting Claude and building an agent: not how many tool calls happen, but whether anyone needs to be present to kick it off.
The building blocks under the one-liner
The whole SDK surface comes down to a handful of concepts — the same ones that already showed up as the "four building blocks" of a loop, just at the level of a single agent process instead of a saved recurring routine.
What the agent is actually allowed to do
Built in, ready to use: Read, Write,
Edit, Bash, Glob,
Grep, WebSearch,
WebFetch — plus anything you add yourself as a custom tool or an MCP
server. allowed_tools is the actual allow-list.
The role the agent plays every single run
A short, persistent instruction — "You are a senior Python developer," for instance — that shapes every decision the agent makes in that run, the same way a skill's frontmatter shapes what a Claude Code loop does with a given trigger.
Where a human stays in the loop, and where they don't
permission_mode="acceptEdits" auto-approves file changes;
"ask" pauses for a human on everything. This is the exact same trust
decision as choosing between a session-bound /loop and an unattended
/schedule — how much gets to run without anyone watching.
Code that runs before or after any tool call
A function you register that fires around tool use — logging, an approval gate, a retry. This is literally the same mechanism the Ralph technique's Stop hook uses to intercept Claude's own exit attempt and feed the same prompt back in.
MCP — one protocol instead of one-off integrations
MCP (Model Context Protocol) is an open standard for connecting Claude to external tools and data — Jira, GitHub, a database, an internal API — without every single one needing its own bespoke integration. A service that wants Claude to work with it exposes an MCP server; any MCP client (Claude Code, Claude Desktop, another AI app entirely) can then talk to it the same standard way. One protocol, many servers, many clients — the same relationship LSP has to code editors: write the server once, and every compatible editor gets it for free.
What a server actually exposes falls into three kinds: tools (functions Claude can call — "create a Jira issue"), resources (data Claude can read — a file, a set of database rows), and prompts (reusable prompt templates the server itself provides). A single server can expose any mix of the three.
| Concrete example | What it's for |
|---|---|
| Jira / Linear MCP server | Read and file tickets, move them through status, from inside a Claude Code session — no tab-switching to triage a bug report |
| GitHub MCP server | Let Claude open PRs, read diffs, and respond to review comments as a real step in an agent's own workflow, not just via git CLI calls |
| A database MCP server | Give Claude scoped, read-only query access to your own schema — the exact shape a monitoring agent on the Agents tab above would use to investigate a real incident |
| Slack MCP server | Post an incident report, a scheduled routine's summary, or a completed loop's result straight into a channel |
| A local filesystem MCP server | Point an MCP client that isn't Claude Code itself (another AI app) at a folder on your machine, the same way Claude Code already can natively |
claude mcp add <name> <url-or-command>
registers a server — many official ones are a single command like this. For Atlassian's own Jira and
Confluence server specifically, follow the setup at
atlassian.com/platform/rovo-mcp
rather than a hand-typed URL, since the exact endpoint is Atlassian's to keep current. Run
/mcp inside a session to see connected servers and complete OAuth sign-in
for any that need it. Once connected, its tools just show up alongside Claude Code's built-in ones —
no separate syntax to learn to actually use them.
Five useful MCP servers worth installing
Past the concept, here's a genuinely useful starter set — installed, connected,
and verified working. Each one below is a single claude mcp add command plus a
concrete prompt to try that proves it's actually being used, not just answering from memory.
--scope user to every command below if the goal is
"available in every session on this machine," not just the one project you happened to be in when you
ran it. The default scope is local to a single project — easy to miss, and the most common reason a
server that worked yesterday seems to have vanished today.
# Install claude mcp add --scope user playwright npx -- @playwright/mcp@latest # Try it — watch for browser_navigate and browser_take_screenshot # as actual visible tool calls before the final answer Use Playwright to go to https://example.com and take a screenshot
# Install (keyless, rate-limited) claude mcp add --scope user context7 npx -- -y @upstash/context7-mcp # With a free key from context7.com — note -e, not --header: # this is a local process, not an HTTP server, so the key is passed # as an environment variable, not a request header claude mcp add --scope user context7 npx -e CONTEXT7_API_KEY=your-key -- -y @upstash/context7-mcp # Try it — watch for resolve-library-id then get-library-docs Use Context7 to get the latest React useEffect documentation and show me a code example
# One-time: GitHub -> Settings -> Developer settings -> # Personal access tokens -> Fine-grained tokens -> Generate new token # Give it an expiration and scope it narrowly -- don't grant everything # Install claude mcp add --scope user --transport http github \ https://api.githubcopilot.com/mcp/ \ --header "Authorization: Bearer your-github-pat" # Try it Use the GitHub MCP to show me my open pull requests
# Install (keyless tier) claude mcp add --scope user --transport http firecrawl \ https://mcp.firecrawl.dev/v2/mcp # With a free key from firecrawl.dev, for the full tool set claude mcp add --scope user --transport http firecrawl \ https://mcp.firecrawl.dev/v2/mcp \ --header "Authorization: Bearer your-firecrawl-key" # Try it Use Firecrawl to scrape https://anthropic.com/news and give me a summary
# Install -- runs its own setup script rather than a plain # claude mcp add, and registers itself at the user level by default npx @wonderwhy-er/desktop-commander setup # Try it Use Desktop Commander to list the files in my home directory
One habit worth building for all five: Claude can sound like it used a
tool without actually calling it. Watch the terminal for the named tool call appearing before the final
text answer — or just ask directly afterward, "which MCP tool did you just call?"
— since that's genuinely in its own context to report back accurately.
Picking the right one of the three
/schedule routine than to a session you keep open yourself.Getting started building with AI
Not a case study, and not a ranking — a map of what's actually out there right now, and one concrete way to start. This space moves fast enough that "best" changes month to month, so treat the tools below as genuine, current options to try, not a permanent verdict.
Which of these are you actually trying to do?
The tool question only makes sense after this one. All three paths below are "building with AI" — they're just building different things.
Three categories, thirteen tools
Agentic IDEs & coding agents
Tools that write, edit, and reason across your codebase alongside you — the category LegalClear and FreightNexus were both built in.
Agent frameworks & SDKs
For when the agent itself is the product you're shipping, not a tool that helps you write one — you write the agent's logic in code, using one of these to handle the underlying plumbing (state, tool-calling, multi-agent coordination).
No-code automation
For wiring an agent into a workflow without writing an application at all — the right starting point for testing whether an idea is worth a full build.
Four steps, in order
Running your own agents — local, on your machine, or deployed and running without you
Every tool above runs in one of two places, and the choice isn't about which tool is "better" — it's about one question: does this need to keep working when your computer is asleep? If no, run it locally. If yes, it needs to be deployed somewhere that stays on.
Local — bound to your machine
Deployed — runs independent of you
/schedule Routines, a cron job, a scheduled GitHub Action — fires on a timer, on infrastructure that isn't your laptop.pull_request.opened — sleeps until triggered by something external, then runs and goes quiet again.A few concrete examples, work and outside of it, mapped to where each would actually need to run:
| Example | Where | Why |
|---|---|---|
| Review this PR for bugs, right now | Local, on-demand | One-off, only needed while you're at your desk |
| Keep retrying a failing build while I do something else | Local, recurring | /loop, but still bound to your session staying open |
| Auto-review every new pull request on a repo | Deployed, triggered | Has to fire on GitHub's schedule, not yours |
| Triage overnight support tickets, summary by 9am | Deployed, scheduled | Needs to run while everyone's asleep |
| A product where every user request calls Claude | Deployed, always-on | Real traffic can arrive any time, from anyone |
| Tidy up a messy folder of downloads | Local, on-demand | A one-time task on your own files |
| Watch a site for a restock or price drop | Local, recurring | Fine as long as it only needs to run while you're active |
| A daily personal briefing emailed every morning | Deployed, scheduled | Has to send whether or not your laptop is even open |
AI vs. AGI — a distinction worth actually getting right
Every tool, agent, and example on this entire site — Claude Code, the Agent SDK, LegalClear, FreightNexus, all of it — is narrow AI. That's not a knock on any of it; narrow AI is what every real, working AI system in production actually is today. AGI is a different, still-hypothetical thing, and the difference isn't "how smart" — it's "how general."
The clearest way to see the line in practice is a real, current example — one that's actively being argued about as this is written.
Independent researchers pushed back hard, and the pushback is the actually useful part. The benchmark jump is real and substantial — nobody disputes that. What's disputed is whether a benchmark score is the same thing as general intelligence: critics pointed to undisclosed test settings, and AI researcher Ethan Mollick's framing stuck for a reason — today's best systems are "jagged AGI", meaningfully better than humans in some areas and clearly worse in others, not a single across-the-board leap to human-style generality. OpenAI itself, notably, stopped short of claiming Astra actually is AGI — that framing came from commentary around the launch, not the model's own system card.









