# CLAUDE.md — Tendril > Web ingestion platform (search / scrape / crawl / map) running on macOS Apple Silicon. > Built to beat Firecrawl on three axes: **stealth**, **access to authenticated pages**, and > **deterministic extraction quality**. This file is the project's persistent context. Read it fully before making any change. **Production host:** `m3u96a` (Mac, M3, 96 GB unified memory) — single node, self-hosted. **Public endpoint:** `https://www.ten-dril.com` via ngrok reserved domain. --- ## Table of contents | § | Section | |---|---| | 0 | Name & identity | | 1 | Product thesis | | 2 | Architecture — three-tier router | | 3 | Tier 0 spec — HTTP | | 4 | Tier 1 spec — WKWebView pool | | 5 | Tier 2 spec — real Safari | | 6 | Proxy & identity layer | | 7 | Session profiles | | 8 | Extraction engine (deterministic) | | 9 | Non-HTML content | | 10 | Crawl frontier | | 11 | Cache & storage | | 12 | Data model | | 13 | Queue & job lifecycle | | 14 | API contracts | | 15 | Error taxonomy | | 16 | Deployment — m3u96a + ngrok | | 17 | macOS prerequisites | | 18 | Observability | | 19 | Performance targets | | 20 | Testing | | 21 | Code conventions | | 22 | SDKs & docs | | 23 | Repository layout | | 24 | Roadmap | | 25 | Anti-patterns | | 26 | Legal & ethical | --- ## 0. Name & identity Codename: **Tendril** — the climbing shoot that latches on and grows. Domain: `ten-dril.com`. If the name changes, update: `package.json` names, Swift bundle IDs (`com.tendril.worker`), Redis key prefix (`tdr:*`), Postgres schema name, ngrok config, API key prefix (`tdr_live_` / `tdr_test_`), LaunchAgent labels, and the User-Agent string. --- ## 1. Product thesis Firecrawl runs in the cloud, on datacenter IPs, in headless Chromium. Tendril runs on a **residential machine**, in **WebKit** (Safari's actual engine). **There is no local LLM in this system.** Extraction is deterministic: parsers, selectors, and structural inference. This is a deliberate choice with real consequences, so be honest about them: - ✅ Predictable cost, predictable latency, reproducible output. The same page always yields the same result — you can write golden-file tests against extraction, which is impossible with a sampling model. - ✅ No 20 GB resident model, no 40 s cold start, no GPU contention with the WebView pool. - ❌ No zero-shot extraction from arbitrary prose. If a user wants "the CEO's name" from an unstructured about-page, deterministic parsing will not find it reliably. The gap in ❌ is covered by **BYOK** (§8.7): the user supplies their own LLM API key, Tendril passes the cleaned markdown through and returns the result. Tendril never pays for inference and never stores the key beyond the request. So the three real differentiators are: 1. **Browser fidelity.** WebKit on macOS ARM behind a residential IP is not a headless Chromium signature. Sites that block Firecrawl outright often do not block Tendril at all. 2. **Authenticated pages.** Tier 2 drives a real Safari profile with real logged-in sessions. No cloud service can offer this. 3. **Extraction you can test.** Deterministic output, versioned rules, golden files. Enterprise buyers care about reproducibility more than about magic. **Accepted constraint:** one machine, one IP, no native horizontal scaling. Tendril is not built to crawl 10M pages/day. It is built to crawl 100k pages/day *that nobody else can reach*, with output you can regression-test. --- ## 2. Architecture — three-tier router The core of the system is an **escalation router**. Always start at the cheapest tier; escalate only on evidence of failure. ``` ┌──────────────┐ request ──────────► │ Router │ └──────┬───────┘ │ shouldEscalate() ┌─────────────────────┼─────────────────────┐ ▼ ▼ ▼ ┌─────────┐ ┌───────────┐ ┌──────────────┐ │ TIER 0 │ │ TIER 1 │ │ TIER 2 │ │ undici │─fail────►│ WKWebView │─fail──►│ Safari │ │ HTTP │ │ pool │ │ safaridriver │ └────┬────┘ └─────┬─────┘ └──────┬───────┘ │ │ │ └─────────────────────┴─────────────────────┘ │ raw HTML + metadata ▼ ┌──────────────────┐ │ Extraction engine │ └──────────────────┘ ``` | | Tier 0 | Tier 1 | Tier 2 | |---|---|---|---| | Engine | undici | WKWebView | Safari + safaridriver | | Latency p50 | 50 ms | 500 ms | 3-8 s | | Concurrency | 200 | 24 | **1** | | JS execution | no | yes | yes | | Cookies/session | per-request | per-profile | real user profile | | Share of traffic | 80% | 18% | 2% | | Cost per page | ~0 | ~15 MB·s RAM | a human-scale amount of time | **Escalation is one-way and capped.** A request escalates at most twice. Every escalation is recorded with its reason so you can see, per domain, which tier actually works — and then pin that domain in `domain_hints` so future requests skip straight to it. The pinning table is what makes the system fast over time; without it you pay the escalation cost forever. --- ## 3. Tier 0 spec — HTTP (`packages/fetcher-http`) ### 3.1 Client configuration `undici.Agent` with: ```ts { connections: 64, pipelining: 1, // pipelining breaks on many CDNs, leave at 1 keepAliveTimeout: 30_000, keepAliveMaxTimeout: 120_000, connect: { timeout: 8_000, rejectUnauthorized: true }, maxRedirections: 5, // handled manually, see 3.3 bodyTimeout: 20_000, headersTimeout: 10_000, } ``` ### 3.2 Header fidelity Header **order** is a fingerprint. Send them in Safari's order, not alphabetically, and not in the order a JS object happens to iterate: ``` Host Accept text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language en-US,en;q=0.9 (or match the target's likely locale) Accept-Encoding gzip, deflate, br User-Agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ... + " Tendril/1.0 (+https://www.ten-dril.com/bot)" Connection keep-alive Sec-Fetch-Dest document Sec-Fetch-Mode navigate Sec-Fetch-Site none Sec-Fetch-User ?1 Upgrade-Insecure-Requests 1 ``` The bot suffix in the UA is non-negotiable (§26). Stealth here means *not looking broken*, not lying about who you are. TLS: Node's default cipher order differs from Safari's, which is a JA3-visible signal. If a target fingerprints TLS, that target belongs on Tier 1 anyway — do not try to fix it at Tier 0. ### 3.3 Redirect handling Handle redirects manually rather than letting undici follow them, because you need to: - record the full chain in `metadata.redirects[]` (users need it for canonicalization); - re-run SSRF validation on **every** hop, not just the first (§16.5); - detect meta-refresh and JS redirects in the body, which undici cannot see; - stop on a redirect loop (same normalized URL twice) with `ERR_REDIRECT_LOOP`. ### 3.4 Escalation decision `shouldEscalate(response): TierDecision` — a pure function, unit tested, no I/O. | Signal | Threshold | Weight | |---|---|---| | Status 403 / 429 / 503 | — | decisive | | Body < 2 KB with empty `#root`, `#__next`, `#app`, `[ng-version]` | — | decisive | | Body contains `challenge-platform`, `cf-browser-verification`, `_Incapsula_`, `px-captcha`, `datadome` | — | decisive | | Text/HTML ratio | < 0.05 | strong | | `