Bhavik Jadav | Hobby Projects
Built end to end with Claude Code

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.

Agent-built system

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.

Next.js 16 Supabase shadcn/ui RLS everywhere react-pdf Vercel
Open the case file →
Runtime AI product

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.

React + Vite FastAPI claude-sonnet-4-5 SSE streaming SQLite cache AWS EC2
Open the case file →
Why these two, side by side

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.

VS

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.

Case file 02 · Runtime AI product

Summary

7 hours total build time — MVP, idea to deployed v1
Agentic spec-driven build — scoped requirements docs fed to Claude Code, one version at a time

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.

v4.0 — streaming + caching, current Live: legalclear.duckdns.org AWS EC2 t3.micro, free tier
Architecture

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.

Browser React + Vite Nginx proxy_buffering off POST /api/ FastAPI backend translate-stream SSE event_generator() SQLite cache sha256(text) → result 90-day expiry 1. check hash Claude API claude-sonnet-4-5 messages.stream() 2. on miss <section> chunks ReportLab PDF report SendGrid emails PDF 3. on request SSE: meta → sections → final
Every request checks the SQLite cache first — only a miss reaches Claude. Nginx sits between browser and backend with buffering explicitly disabled, or none of this streams.
Tech stack

What it's actually built from

Frontend
React 18 + Vite
Fast dev server, no framework overhead for a single-page tool.
Styling
Tailwind CSS
Utility classes for a dense results UI with four distinct zones per card.
Icons
Lucide React
Consistent iconography for risk severities and action buttons.
HTTP
Axios + native fetch
Axios for simple calls, raw fetch + ReadableStream for the SSE endpoint Axios can't stream.
Backend
FastAPI (Python 3.11+)
Async-native, first-class StreamingResponse support.
AI
Anthropic Python SDK
Both sync (Anthropic) and async (AsyncAnthropic) clients, used for different endpoints.
Model
claude-sonnet-4-5
Every analysis call — non-streaming, streaming, and the chunk/party-extraction sub-calls — targets this model.
Cache
SQLite
Zero-ops file-based cache, keyed on a SHA-256 hash of the normalised document text.
PDF
ReportLab
Generates the 4-page branded report — cover, quick verdict, high-risk summary, full translation.
Email
SendGrid
Delivers the same PDF as an attachment via Single Sender Verification.
Reverse proxy
Nginx
TLS termination + streaming-safe proxying (buffering explicitly disabled — see the deployment section below).
Host
AWS EC2, Ubuntu 22.04
t3.micro, free-tier eligible, systemd-managed backend service.
Product evolution

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.

v1
Base build
Paste or upload a document, one blocking request, one complete JSON response rendered all at once. Simple, but a 5–10 minute Claude call on a large document meant a blank loading screen — and Nginx's default 60s proxy_read_timeout would kill the connection long before Claude finished.
v2
Streaming
Added a second endpoint, /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.
v3
Conciseness & UX redesign
The first two versions let Claude write as much as it wanted per field — summaries ran to paragraphs, risk explanations rambled. v3 added hard word-count ceilings to the prompt (verdict: 20 words, risk explanation: 15 words) and redesigned the results page around a single question: "should I be worried about this document?", answerable in the first 10 seconds without scrolling.
v4
Hash-based caching
Every submission still called Claude, even for a document someone else had already translated. v4 added a SQLite cache keyed on a normalised SHA-256 hash of the document text — a cache hit replays the stored result through the same streaming interface with a 150ms per-section delay (so it still feels like analysis, not a suspicious instant response) and shows a "⚡ Instant result" badge. Zero token cost on a hit.
Downloadable files

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.

PDF
Source Lease Agreement
AST — 37 Greyfriars Road

The genuine UK Assured Shorthold Tenancy agreement fed into LegalClear — the input, unmodified.

PDF
LegalClear Analysis Report
Generated by ReportLab, this run

11 pages — cover, quick verdict, what-to-watch-for, and the full 35-section plain-English translation with every risk flag.

Live walkthrough

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.

Upload & input

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.

Live streaming

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.

Completed analysis

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.

Actions — PDF & email

What happens after analysis

An email-delivery attempt and an actual PDF report download.

Infrastructure & operations

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.

Deployment architecture

The full request path in production

Final architecturelegalclear.duckdns.org
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
Compute
EC2 t3.micro, Ubuntu 22.04
Free tier: 750 hrs/month for 12 months, per-account not per-instance — one instance at ~730 hrs/month sits just inside the limit.
Process manager
systemd
The FastAPI backend runs as a proper service (legalclear.service), Restart=always, so a crash self-heals without a manual SSH session.
TLS
Certbot / Let's Encrypt
Free certificate, auto-renewing, verified monthly with certbot renew --dry-run.
DNS
DuckDNS
Free dynamic DNS — necessary because a stopped-then-restarted EC2 instance gets a new public IP every time, and DuckDNS is the piece that gets updated to point at it.
Fallback host
Netlify
Serves a branded maintenance page on a separate maintenance git branch when EC2 is deliberately stopped to save free-tier hours.
The problem free-tier billing creates

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.

legalclear-control

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

▶
Start Server
Calls EC2's 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.
■
Stop Server
Repoints DuckDNS at the Netlify maintenance site first, then calls StopInstances — deliberately in that order, so visitors see a branded "back soon" page instead of a connection-refused error during the stop.
↻
Check Status
Calls 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.

netlify/functions/control.js — the signing corehand-rolled SigV4, no AWS SDK dependency
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}`;
Why hand-roll SigV4 instead of installing 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.
Cost reality

What this actually costs, before and after the free tier

ResourceFree-tier limitAfter 12 months
EC2 t3.micro750 hrs/month for 12 months~$7.50/month
EBS storage (20GB used)30GB free for 12 months~$0.10/GB/month
Data transfer100GB/month outbound freePay per GB beyond that
Netlify (control panel + maintenance page)Free tier, indefiniteFree tier, indefinite
DuckDNSFree foreverFree 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.

Case file 01 · Agent-built system

Summary

16 hours total build time — MVP, idea to deployed v1
Agentic spec-driven build — a nine-file specification, authored entirely by Claude Code

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.

Next.js 16.3 · React 19.2 Supabase Postgres + Auth + Storage + Realtime Vercel, serverless
The four account types

Who's actually using this

A fifth role, Admin, exists purely operationally — provisioned directly in the database, no public registration path.

Shipper
Business Owner
Posts shipment requirements, reviews itemised bids, accepts one, tracks the trip live, confirms payment, rates the driver.
Owns & drives
Individual Owner-Driver
One login handles everything — KYC, truck registration, bidding, execution. No middle layer.
Manages a roster
Fleet Owner
Owns multiple trucks, employs drivers who aren't the account holder. Bids centrally, assigns a specific truck + driver once a bid wins.
Employed, not owner
Fleet Driver
Own login, invite-only (never from the public landing page). Sees assigned trips, executes them — pickup, transit, delivery — never bids themselves.
Architecture

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.

Business Owner Owner-Driver Fleet Owner Fleet Driver Admin (internal) Next.js 16 App Router, Vercel Server Components Server Actions Route Handlers no LLM call, anywhere Supabase Postgres · 25+ tables Row-Level Security Auth · one row / user Storage · KYC + photos Realtime · live GPS SQL + RLS SendGrid invoices, alerts Google Maps geocode, directions Vercel Cron expiry + auto-cancel
No dedicated backend service — Next.js server code talks to Postgres directly, and the database itself enforces who can see what.
Tech stack

What it's actually built from

Frontend
Next.js 16.3 (App Router)
Server Components by default, route handlers double as the API layer — no separate backend service.
UI
Tailwind CSS 4 + shadcn/ui
A generated master design system (via the ui-ux-pro-max skill) persisted once, then referenced per-screen rather than re-derived.
Forms
react-hook-form + Zod 4
Every multi-step wizard (registration, truck onboarding, trip posting) validates client- and server-side off the same schema shape.
Database
Supabase Postgres
25+ tables, enum-typed status columns, generated columns for totals — see the data model highlights below.
Auth
Supabase Auth
One auth.users row per human; a profiles row carries the role and KYC state.
Storage
Supabase Storage
Every KYC document, truck photo, pickup/delivery photo, and payment proof — access-controlled by the same RLS model as the tables.
Real-time
Supabase Realtime
Live GPS position updates during the in_transit trip stage.
Email
SendGrid
Every notification and both trip invoices, gated by a genuinely opt-in per-category preference system.
PDF
react-pdf
Generates the Trip Invoice / Shipment Summary — deliberately never labelled an E-Way Bill (see callout below).
Maps
Google Maps Platform
Geocoding at trip-posting time, Directions for toll-estimate distance, Maps JS for live tracking.
Cron
Vercel Cron
Document-expiry checks and 2-day trip/bid auto-cancellation, running server-side on a schedule.
Hosting
Vercel
Deployed straight from GitHub, serverless functions for every route handler.
Data model, in brief

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.

trips.status — actual forward ordersrc/lib/trips/milestones.ts
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)
Eligibility filtering isn't cosmetic. When a trip posts, the set of drivers who even get notified is computed server-side against five gates: national permit vs. home-state match, truck body type vs. goods nature, capacity vs. weight, document validity, and verification status. The same filter re-runs at bid-submission time — a truck's documents could have expired in the gap between the notification email and the bid attempt.
Not an escrow service, and the product says so everywhere. The platform never custodies funds — payment is bank transfer/UPI, entirely off-platform, with proof-of-payment upload and two-way confirmation as the only tracking layer. The generated PDF is explicitly a "Trip Invoice / Shipment Summary," never an E-Way Bill — that's a legally defined GST document requiring a registered business entity the platform operator doesn't have.
Live walkthrough

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.

TRP00062 — Memnagar (Ahmedabad) → Jewels Circle (Bhavnagar), Gujarat, fragile cartons of electronics, 2,800kg, closed container. Settlement: ₹19,000 freight + ₹1,500 driver allowance + ₹780 actual toll (estimated ₹750, +₹30 delta) + ₹200 loading-labor expense = ₹21,480 total — ₹5,312.5 deposit (25%) and ₹16,167.5 final payment, both confirmed on both sides. Both parties rated each other 5 stars, left in production exactly as it happened.
Public & auth

Landing page and sign-up

Registration & KYC — Business Owner

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.

Registration & KYC — Individual Owner-Driver

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.

Admin portal

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.

Posting the trip

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.

Bidding

Akshay browses, bids, and wins

Deposit & pickup

Deposit confirmed, goods collected

Delivery & settlement

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.

Completion & ratings

Trip closed, both sides rated

Downloadable files

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.

PDF
Trip Acceptance Invoice
INV-TRP00062-E1801B · generated on bid acceptance

Auto-generated the instant Bhavik accepted Akshay's bid — trip details, itemized bid, deposit amount, "Confirmed" status.

PDF
Trip Completion Invoice
INV-TRP00062-7BF59E · generated on settlement

Final settlement: actual toll vs. estimate, the misc expense with receipt, both payments confirmed, full status history — and the explicit not-an-E-Way-Bill disclaimer.

Deep dive

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

0 · The API itself

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.

What client.messages.create() sends, underneath the SDKraw HTTP, for reference — LegalClear never writes this by hand
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"}
The same call, via the SDK — what LegalClear's code actually containsPython, anthropic SDK
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)
FieldWhat it is
modelWhich 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_tokensNot 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.
systemA separate slot from the conversation — the fixed rulebook (Section 2 below). Doesn't count as a "turn."
messagesThe actual conversation — a list of {role, content} turns. LegalClear only ever sends one: a single user turn, rebuilt fresh every request.
Streaming uses the same endpoint, not a different one. Adding 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.

1 · Model & client setup

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.

backend/services/claude_service.pysync client, 30-min timeout
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)
backend/services/stream_claude_service.pyasync client, same timeout budget
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.

3 · Context construction

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.

1
System prompt — fixed, never changes per request
Sent as the 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.
2
User message — reassembled from scratch every request
Not just "the document." It's the JSON schema restated field-by-field (belt-and-braces — repeating structure close to the actual content measurably improves adherence versus relying on the system prompt alone, which is further back in the context window by generation time), followed by Document Name: {document_name}, followed by the full raw document text. One string, one user turn, no message history before it.
3
No context survives the request
There's no session, no thread, no follow-up turn. 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.
USER_MESSAGE_TEMPLATE (non-streaming variant, abridged)claude_service.py:70
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}
Why no RAG, no embeddings, no chunk retrieval at query time? Because rule 9 of the system prompt ("never translate a clause in isolation if it depends on or is modified by another") is only enforceable if the model can see the whole document at once. Retrieval-based context — fetching only the "relevant" paragraphs for a query — would actively break that rule: a termination clause that's modified three pages later needs both pages in context simultaneously, not ranked separately by similarity search. The one place this breaks down is documents too large to fit one call's practical output budget — that's what chunking (Section 6) exists to handle, and it's a genuine, accepted quality trade-off, not a free lunch.
4 · The streaming protocol

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.

What this actually looks like on screen, in order: the progress bar appears within a second of submitting; the <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.
The extraction loopstream_claude_service.py:150
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.

→
FastAPI wraps each yielded dict as a Server-Sent Event
yield f"data: {json.dumps(chunk)}\n\n" inside a StreamingResponse with media_type="text/event-stream".
→
The browser reads it with a raw fetch + ReadableStream
Not EventSource — 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.
→
React appends each section to state as it arrives
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.
5 · Parsing the non-streaming path

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.

_extract_json — strips fences, finds the outermost bracesclaude_service.py:109
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:

The one-shot retryclaude_service.py:398
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.

6 · Large documents — chunking

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.

ConstantValueWhy
_MAX_OUTPUT_TOKENS8192Model's actual output ceiling — a chunk's analysis must fit inside one response.
_MAX_CHUNK_CHARS28,000Split threshold — documents under this go through the single-call path.
_CHUNK_OVERLAP_CHARS2,000Each 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_CHARS800,000Hard 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:

819228,000
What it countsTokensCharacters
Which directionClaude's output (the answer coming back)LegalClear's input (the document going in)
What it controlsA hard API parameter (max_tokens=8192) — Claude cannot generate more than this many tokens in one responseAn internal threshold LegalClear's own code checks — "if the document is longer than this, split it"
Who enforces itAnthropic's API — cuts Claude off mid-generation if it hits this capLegalClear'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.

6b · Watch it happen

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.

Step 1 / 7
Your document — 42,000 characters one continuous string, before any split split at 28,000 chars Chunk 1 chars 0 – 28,000 2,000-char overlap Chunk 2 chars 26,000 – 42,000 Your backend for idx, chunk in chunks: Claude API claude-sonnet-4-5 one call per chunk — never batched sending chunk 1 of 2… response landed — chunk 2 can start now partial_results[] 0 of 2 chunks analysed _extract_parties_from_text() separate call, first 120,000 chars Merge section IDs renumbered sequentially risk counts recomputed from merged risk_flags[] — never trusted from each chunk's own count parties[] attached from the separate call above One merged report streamed to your browser as normal
Watching a 42,000-character document get chunked and analysed…
hover to pause on any step · loops automatically
7 · Caching — replaying a stream that never called Claude

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.

Cache-hit replay — same chunk order, deliberate delaycache flow, translate.py
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"
The 150ms isn't a technicality — it's a UX decision written directly into backend code. A cache hit that returned everything in one network frame would look like a bug to a user who just watched the exact same tool take 45 seconds on the previous document. Pacing the replay to feel "fast, not instant" was worth encoding as a constant rather than leaving to chance.

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.

8 · Error handling

Three failure modes, three different messages

ExceptionMeaningUser sees
APITimeoutError / APIConnectionError / APIStatusErrorAnthropic's API itself is unreachable, rate-limited, or erroring"Claude AI is unavailable right now. Please try again in a few moments."
json.JSONDecodeError / ValueErrorModel 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_KEYMisconfiguration, caught before any network call is attemptedSame 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.

9 · The Claude Console, not just the code

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.

A third thing, off to the side

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.

The actual shift, in one line: a loop is a small system you design once — trigger, skill, goal, verification, memory — that then runs the attempt-check-repeat cycle on your behalf, instead of you supplying that cycle by hand, one message at a time.
Part one — when to build one

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.

1
Does the task repeat? A genuine one-time task doesn't need a loop — just prompt it directly.
2
Is there a clear definition of "done"? The most reliable loops have a way to quantify completion, not just a vague sense of finished — the same discipline as setting a real success metric for a task before starting it.
3
Can you afford to be wasteful? A loop re-prompts itself automatically, which can burn through tokens fast — worth being deliberate about scope if token budget is tight.
4
Does it have the tools it needs? Both to do the work and to check the work — a loop that can write code but can't run it has no way to verify anything it just did.
If a task passes all four, it's a real loop candidate. If it fails even one — it's genuinely a one-off, "done" is too fuzzy to check, token cost matters more than the time saved, or the tools to verify aren't available — a plain prompt is the better tool, not a loop built for its own sake.
Part two — the four building blocks

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.

1 · Trigger

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.

2 · Execution skills

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.

3 · Goal + verification

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

4 · Output + memory

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.

Block 1, in Claude Code — the four real trigger types

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.

Type 1
Turn-based
You direct every step by hand — a normal conversational session. No special command; this is just prompting, one turn at a time, with you as the checker.
Type 2
Goal-based — /goal
You state a completion condition once; Claude keeps taking turns, unprompted, until a separate evaluator model confirms it's met, judges it impossible, or a turn limit hits.
Type 3
Time-based — /loop
Re-runs a prompt on a schedule — fixed interval or one Claude picks dynamically — rather than toward a condition. Good for polling: watch a deploy, babysit a PR, check back later.
Type 4
Proactive — /schedule (Routines)
Runs in the cloud with no open session and no human involvement at trigger time — on a schedule, an incoming API call, or a GitHub event like a new pull request.
/goal — iterate toward a stated conditionactual syntax, Claude Code docs
// 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)
/loop — re-run on an intervalactual syntax, Claude Code docs
// 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.

/schedule — cloud routines, no open session neededactual syntax, Claude Code docs
// 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.

Block 2 — execution skills

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.

.claude/skills/pr-review/SKILL.mdthe real shape of a Claude Code skill
---
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."
Why this matters more than the trigger: the same one-line instruction — "review this PR" — means something different depending on who's asking. A skill is where that difference actually gets written down once, so every loop that calls it inherits the same judgment instead of Claude re-deriving a plausible-sounding standard from scratch on every single run.
Block 3 — goal and verification

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 typeGoalVerification
TechnicalDeploy the site and have it load in under 2 secondsHit the live URL, measure load time, confirm the expected content is present
Less quantifiableDraft a reply for every unread emailEvery unread thread has a draft, and that draft passes a dedicated review skill — not just "looks reasonable"
The most important discipline here isn't a command — it's keeping the maker and the checker separate. Claude Code's built-in /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.
Block 4 — output and memory

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.

The fix doesn't need to be complicated. A plain Markdown file the loop reads at the start of each run and updates at the end — what happened, what worked, what to avoid next time — is enough. The point isn't sophistication, it's that something persists across runs instead of every iteration starting with total amnesia.
One real domain, all four blocks

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.

1 · Trigger

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.

2 · Execution skill

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

3 · Goal + verification

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

4 · Output + memory

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.

This is exactly why the trigger matters as its own decision. A live score genuinely needs to update whether or not anyone has a laptop open watching it — which is a real, concrete reason to reach for a cloud-based trigger rather than a session-bound one, covered next.
A loop that actually built this site

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.

1 · Trigger

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.

2 · Execution skill

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

3 · Goal + verification

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.

4 · Output + memory

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.

This is why verification had to be more than "does a file exist." A screenshot can be technically present and still be wrong — a header stuck mid-frame, a long company name wrapping onto three lines and covering the field below it. The goal only counts as met once both the coverage check and the defect check pass together, which is exactly why this took several iterations instead of one.

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

screenshots/legalclear/shot-list.mdgenerated once, up front — every later pass only reads it
# 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.

.claude/skills/capture-and-verify-screenshots/SKILL.mdthe real shape of the skill this loop calls
---
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.
prompt.md — what actually got fed to the loopthe trigger + goal, in the exact words used
## 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.
The actual command that started itskill + prompt.md, tied together with /loop
// 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.
What this looked like in practice: the first pass caught most stages cleanly, but a handful had the sticky topbar frozen mid-scroll into the shot, and a couple of longer company/document names on FreightNexus's forms wrapped onto a second line and overlapped the field below. Those specific shots — not the whole set — went back through capture-and-check again, and the loop kept going until the retry list was actually empty, not just shorter.
All four blocks, together

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.

Example 1 — /schedule, cloud-based, unattendedtrigger: proactive · runs every alternate Tuesday morning, whether or not anyone's watching
// 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.
Example 2 — the skill the audit actually callswhat block 2 looks like as a real, saved file
---
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.
This is the same four-block shape as the live scoreboard above, just with a different trigger. A cloud /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.
A named technique built on this exact shape

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.

1 · Trigger

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.

2 · Execution skill

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.

3 · Goal + verification

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.

4 · Output + memory

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.

Ralph is best suited to well-scoped work with a genuinely checkable finish line — greenfield builds with automated tests, a refactor with a clear target state, anything where "is this actually done" can be verified mechanically rather than needing a person's judgment call at every step. That's the same condition-one test from the top of this page, just applied to a loop that keeps itself running through its own exit attempts instead of waiting on an external trigger to fire again.

There are two real ways to actually run it — a built-in one inside Claude Code, and the original raw version underneath it.

Option 1 — the built-in pluginStop-hook version, same session
// 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.

docs/csv-import-spec.mdthe file that command above actually points at
# 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.

Option 2 — the original raw loopplain shell, outside Claude Code entirely
// 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."

The same skill and prompt, run as Ralph instead of /loopsame skill, same prompt.md, different stopping mechanism
// 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.

Building on Claude, one level below the product

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.

The one-line version: the Claude Agent SDK hands you Claude Code's own harness as a library you host yourself; Tool Runner is a lighter loop over tools you define, still on your infrastructure; Managed Agents is the same shape with Anthropic hosting the sandbox instead of you.
ApproachWhat it actually isWho hosts itReach for it when
Agent SDKClaude Code's full harness (loop, built-in tools, permissions, hooks, MCP) as a libraryYouYou want file/shell/search tools out of the box, full control
Tool RunnerA thin agentic loop over tools you define yourselfYouA handful of custom tools, no need for the full Code harness
Managed AgentsThe same agent shape, sandboxed and run on Anthropic's infrastructureAnthropicLong-running or scheduled work, minimal ops on your side
The Agent SDK, end to end

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.

The question worth asking before writing any of this: couldn't this just be a prompt? Often, yes — and if it can be, it should be; a prompt is simpler than an agent every time. What a prompt can't do is run when nobody is there to type it. The example below is chosen specifically because there's no chat window open at the moment it needs to fire.
InstallPython or TypeScript
# 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.

oncall_agent.py — sleeps until something's actually wrongno chat window, no human typing the trigger
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.

What the options object actually controls

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.

Tools

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.

System prompt

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.

Permissions

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.

Hooks

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.

The "MCP" that keeps showing up above

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.

Claude Code MCP client MCP MCP MCP Jira MCP server tools: search issues, create issue, transition status GitHub MCP server tools: open PR, read diff, list issues, add review comment Internal database MCP server resource: read-only schema + query tool, scoped to one DB
Same client, same protocol, three unrelated services — none of them needed custom integration code written for Claude specifically.

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 exampleWhat it's for
Jira / Linear MCP serverRead and file tickets, move them through status, from inside a Claude Code session — no tab-switching to triage a bug report
GitHub MCP serverLet 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 serverGive 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 serverPost an incident report, a scheduled routine's summary, or a completed loop's result straight into a channel
A local filesystem MCP serverPoint 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
How to actually connect one, in Claude Code: 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.
Not hypothetical — five useful ones, installed and tested

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.

Add --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.
1 — Playwrightreal local browser control, no auth needed
# 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
2 — Context7real, current library docs — works keyless, a key raises the rate limit
# 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
3 — GitHuba fine-grained personal access token, scoped to just the repos you need
# 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
4 — Firecrawlscrape/search/parse work keyless; crawl/map/agent need a key
# 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
5 — Desktop Commanderreal filesystem + process access on your own machine, no auth
# 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.

Decision, not preference

Picking the right one of the three

1
Need the full Claude Code toolset — files, shell, search — on your own infrastructure? That's the Agent SDK. It's the same harness this very site's Claude Integration tab describes, just embedded in your own program instead of a terminal.
2
Only need a small, fixed set of tools you wrote yourself, with tight control over each turn? That's Tool Runner — lighter than the full SDK, still self-hosted, with hooks for approval and retry built into the loop itself.
3
Don't want to run any infrastructure at all — especially for something long-running or scheduled? That's Managed Agents: Anthropic hosts the sandbox, closer in spirit to a cloud /schedule routine than to a session you keep open yourself.
A concrete rule of thumb: if the task already sounds like "point it at my repo and let it work," reach for the Agent SDK first — it's the same battle-tested harness, just callable from code. Only step down to Tool Runner when the built-in tools are more than the job needs, and only step up to Managed Agents when you specifically don't want to be the one keeping a process alive.
A practical on-ramp

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.

Start here

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.

What are you building? Code, with an agent's help inside your existing dev workflow An agentic application the agent IS the product A workflow, no code automate something that already exists Agentic IDEs / coding agents Claude Code, Codex, Cursor, Windsurf, Kiro, GitHub Copilot Agent frameworks / SDKs Claude Agent SDK, LangGraph, CrewAI, OpenAI Agents SDK, Microsoft Agent Framework No-code automation n8n
Three different jobs, three different tool categories — the sections below follow this same split.
The tool landscape

Three categories, thirteen tools

01

Agentic IDEs & coding agents

Tools that write, edit, and reason across your codebase alongside you — the category LegalClear and FreightNexus were both built in.

VS Code
Microsoft
The base editor most agentic coding tools are built on or extend — including Cursor, Windsurf, and Kiro, and the one Claude Code's own extension runs inside.
editorfree
Claude Code
Anthropic
Agent-first coding tool — terminal, VS Code/JetBrains extensions, desktop app, and a web IDE. Built both products on this site.
CLI + IDEmulti-file
Codex
OpenAI
Claude Code's closest direct sibling — inside ChatGPT, an IDE extension, and a terminal CLI, all sharing one model. Same shape, different maker.
multi-surfaceMCP support
Cursor
Anysphere
A full VS Code fork with AI woven directly into the editing loop. The most broadly adopted tool in this category among individual developers.
VS Code forkmulti-model
Windsurf
formerly Codeium
AI-native IDE. Its agentic system, Cascade, handles multi-file edits autonomously and has a "Memories" feature that learns your codebase's patterns over time.
own IDEautonomous edits
Kiro
AWS
VS Code-based, built around spec-driven development — it turns a request into a structured requirements/design/task spec before generating any code, rather than generating first.
spec-firstVS Code-based
GitHub Copilot
GitHub / Microsoft
The largest user base of any tool in this category, largely through deep integration into GitHub and Microsoft-centric teams' existing workflow.
extensionlargest install base
02

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

Claude Agent SDK
Anthropic
Claude Code's own harness — the agentic loop, built-in tools, permissions, and hooks — packaged as a library you host yourself. See the Agents tab for a full walkthrough.
self-hostedPython + TypeScript
LangGraph
LangChain
The current leader for production, stateful, multi-step agent workflows — persistent state, human-in-the-loop checkpoints, streaming.
statefulproduction-grade
CrewAI
CrewAI, Inc.
Define roles, assign tools, and have a team of agents collaborate in a structured pipeline. The fastest route to a working multi-agent prototype.
multi-agentfast prototyping
OpenAI Agents SDK
OpenAI
A lightweight SDK for shipping an agent quickly — closer to a minimal toolkit than a full orchestration framework.
lightweightship fast
Microsoft Agent Framework
Microsoft
Open, multi-language (.NET and Python) production agent framework — Microsoft's current stack, succeeding the earlier AutoGen project.
.NET + Pythonopen source
03

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.

n8n
n8n GmbH
The dominant no-code option right now — its AI Agent node natively supports LangChain-style tool-calling and memory inside a visual workflow builder.
visual builderno-code
One concrete way to start

Four steps, in order

1
Pick one small, genuine task
Not a hypothetical — something you actually need done this week. A genuine task exposes actual friction; a made-up one doesn't.
2
Pick a tool from the category that matches the task
Coding something → an agentic IDE. Shipping an agent as a product → a framework. Automating something that already exists → n8n. The tool choice follows the task, not the other way round.
3
Write one scoped spec before you prompt
Even a few plain-language bullet points, written down before the first prompt, catches more mistakes than the same thinking done silently mid-conversation. True regardless of which tool you picked.
4
Build in a verification step, not just an output
Ask it to test, run, or check its own work before reporting done — or use a stated condition it has to satisfy (see the Loop Engineering tab). An unverified attempt is a guess wearing a finished coat of paint.
None of this requires the fanciest tool on the list. The four steps above are the actual leverage — the tool just decides how much of steps 3 and 4 gets structured for you versus left to your own discipline.
Where it actually runs

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

1
Terminal / CLI agents
Claude Code, run in a terminal — reads and writes real files, runs real shell commands, stops the moment you close the session.
2
IDE-embedded agents
Cursor, Windsurf, the Claude Code VS Code extension — same idea, living inside your editor instead of a separate window.
3
A local script against an API
Your own Python/Node script calling the Claude API directly, or a LangGraph/CrewAI agent — runs only while the process is alive on your machine.

Deployed — runs independent of you

1
Scheduled cloud agents
Claude Code's /schedule Routines, a cron job, a scheduled GitHub Action — fires on a timer, on infrastructure that isn't your laptop.
2
An agent inside a deployed app
LegalClear itself — the Claude API call is baked into a server process that stays up 24/7 on AWS EC2, waiting for real users to hit it.
3
Event-triggered background jobs
A webhook handler, a serverless function, a GitHub Action on 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:

ExampleWhereWhy
Review this PR for bugs, right nowLocal, on-demandOne-off, only needed while you're at your desk
Keep retrying a failing build while I do something elseLocal, recurring/loop, but still bound to your session staying open
Auto-review every new pull request on a repoDeployed, triggeredHas to fire on GitHub's schedule, not yours
Triage overnight support tickets, summary by 9amDeployed, scheduledNeeds to run while everyone's asleep
A product where every user request calls ClaudeDeployed, always-onReal traffic can arrive any time, from anyone
Tidy up a messy folder of downloadsLocal, on-demandA one-time task on your own files
Watch a site for a restock or price dropLocal, recurringFine as long as it only needs to run while you're active
A daily personal briefing emailed every morningDeployed, scheduledHas to send whether or not your laptop is even open
The deciding question is always the same one. "Does this need to happen even when my computer is off or asleep?" Yes → it has to be deployed somewhere. No → local is simpler, and free — don't reach for a cloud routine or a hosted server for something that only ever needs to run while you're already sitting there.
A term that gets thrown around loosely

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

1
Narrow AI does one kind of thing well. Claude writing code, a vision model reading X-rays, a chess engine, a recommendation feed — each genuinely capable, sometimes beyond human level, strictly inside its own lane. A model brilliant at code doesn't thereby know how to drive, and vice versa.
2
AGI would generalize the way a person does. Learn to cook, then learn to negotiate a lease, then learn a new job — using one underlying general intelligence, not a different specialized model retrained per domain. No AGI system exists yet, by any lab's own account.
3
Being agentic isn't the same as being general. Everything in this site's Agents tab — a loop that runs unattended for hours, a Stop hook, a scheduled cloud agent — is still narrow AI given a longer leash. Persistence and autonomy aren't generality; the model is still doing one class of task, just for longer, with less supervision.

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.

GPT-6 Astra (OpenAI, released September 2026) is the sharpest real-world test case for this whole distinction. OpenAI's own president called it a "generational leap" and floated that it could eventually be seen as the arrival of AGI — pointing to a jump on the ARC-AGI-3 benchmark from roughly 7.8% to 98.6% in about six months. Nvidia's CEO went further and said AGI had arrived.

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.

The honest read: a genuine, verifiable capability jump happened. Whether that constitutes AGI is a live, unsettled argument — not because the improvement isn't real, but because there still isn't an agreed-upon test for what "general" actually has to mean before the label applies. Treat any confident claim either way — including "AGI has arrived" and "AGI is nowhere close" — with the same scrutiny you'd give a benchmark number without knowing what it measured.