A frontend-only group-expense splitter with a multimodal chat assistant that reads receipts.
- TWC
TWC is a group-expense splitter that runs entirely in your browser. Create a
group, log who paid for what, pick a per-expense split rule, and read a minimized
"who owes whom" settlement. Money is stored as integer minor units (cents / yen /
won) in the expense's native currency and only crosses a float boundary at FX conversion
— always ending in Math.round. Settlement math asserts Σ balances === 0.
It also understands receipts. Open the chat assistant (⌘K), drop in a photo plus
free-form notes, and a vision-capable model (Claude 4.x or GPT-5 / GPT-4.x) runs an
agentic tool loop — resolving member names, looking up FX rates, asking you about
ambiguous payers — and hands back draft expenses for you to review and accept. The
draft pipeline terminates in the same addExpense action you'd hit with the form.
Zero backend, zero auth, zero secrets at rest on a server. TWC deploys as a static
bundle to GitHub Pages. Real LLM providers are bring-your-own-key: you paste an
Anthropic or OpenAI key into Settings, it lives in your localStorage, and it is
redacted from exports and never leaves the browser except as a request header. A
Cloudflare Worker proxy is the documented escape hatch if a shared-key mode is ever
needed — not built today.
Living alongside the deployed app is twc-agent/, a
local-only, single-user variant where Claude Code is the input
surface instead of the in-browser chat. Same domain logic (money
invariants, split math, settlement), same 9-currency allow-list. Two
things change:
- No in-browser LLM and no API keys. Claude Code runs in your terminal against your Pro / Max subscription.
- State lives as JSON on your filesystem under
twc-agent/data/groups/*.json. Claude reads / edits / writes those files with its native tools; the Vite app is just a viewer + light editor.
cd twc-agent
claudeStart Claude Code from inside twc-agent/, not the TWC repo root.
Claude treats its current working directory as the project root, so
starting it here sandboxes it to this subfolder — it can't Read /
Edit / Write anything in the parent TWC (../src, the deployed app,
this README). The sandbox is cwd-based, not permission-based, so stay
in twc-agent/ for the whole session. If Claude ever tries to reach
../, exit and re-launch from the right directory.
Once Claude is running, use the slash
commands — /twc-new-group,
/twc-add-expense, /twc-import-receipt, /twc-settle — to build a
group. Run npm run dev (from inside twc-agent/, in another
terminal) to view the result in the browser. Full walkthrough:
twc-agent/README.md.
- Use
twc-agent/when you want the group to be yours — on your disk, no keys pasted into a webpage, no shared-origin risk. - Use the deployed TWC at randyharrogates.github.io/twc when you want the browser-only BYO-key experience.
The two projects are independent: twc-agent/ has its own
package.json, node_modules, .claude/ skill and slash commands, and
no imports cross the boundary.
The four modes all honor the invariant Σ shares === expense.amountMinor via the
largest-remainder method (pennies routed to the earliest participants).
| Mode | Input | Behavior |
|---|---|---|
even |
participants list | Equal shares; remainder cents distributed deterministically. |
shares |
integer weights per participant | Proportional to weights, then largest-remainder. |
percent |
percentage per participant, Σ = 100 | Proportional to percent, then largest-remainder. |
exact |
explicit minor-unit share per participant | Sum must equal amountMinor or validation rejects. |
The allow-list is frozen in src/lib/currency.ts; do not hardcode
codes, symbols, or decimals elsewhere.
| Code | Name | Symbol | Minor decimals |
|---|---|---|---|
| SGD | Singapore Dollar | S$ | 2 |
| MYR | Malaysian Ringgit | RM | 2 |
| USD | US Dollar | $ | 2 |
| KRW | Korean Won | ₩ | 0 |
| JPY | Japanese Yen | ¥ | 0 |
| TWD | New Taiwan Dollar | NT$ | 0 |
| EUR | Euro | € | 2 |
| GBP | British Pound | £ | 2 |
| THB | Thai Baht | ฿ | 2 |
All providers are wired via direct fetch — no SDK. Models and prices live in
src/lib/llm/models.ts; each remote entry carries a
lastVerifiedIso and CI fails once an entry is >365 days old.
| Provider | Models | Streaming | Tool use | Thinking / reasoning |
|---|---|---|---|---|
| Anthropic | Claude Haiku 4.5, Sonnet 4.6, Opus 4.7 | ✔ | ✔ | Optional extended thinking via thinking: { budget_tokens } |
| OpenAI | GPT-5, GPT-5 mini, GPT-4.1, GPT-4.1 mini, GPT-4o mini | ✔ | ✔ | Intrinsic reasoning on GPT-5 family (reasoning_effort, max_completion_tokens) |
| Local | Any tag your server exposes (qwen2.5-vl:7b, llama3.2-vision:11b, etc.) |
✔ | ✔ | Whatever the model does — TWC just forwards the OpenAI-compat call |
The Local provider points at any OpenAI-compatible /v1/chat/completions endpoint
you run yourself (Ollama, LM Studio, vLLM, llama.cpp server). See
Run with Ollama (local model) below for the
full walkthrough — origin allow-list, browser compatibility, security trade-offs.
| Rail | Detail |
|---|---|
| Rate limiter | 10 requests / minute and 100 / hour (defaults, configurable). Persists across reloads so refresh cannot bypass. |
| Spend caps | Per-day and per-month µUSD caps checked against costTracker before every send. |
| Image consent | One-time per-provider modal before the first image upload; explains what bytes are sent. |
| Magic-byte MIME check | JPEG / PNG / WebP enforced by both declared MIME and actual header bytes. |
| Preflight | Estimates tokens and rejects over-context-window or >5 MB encoded-image requests without spending a quota. |
| Image bytes in memory only | Uploaded images live in an in-memory Map; persisted messages keep base64: '' and render as placeholders after reload. |
| API keys redacted | Keys never appear in request bodies, logs, or exportState() — only on the request headers. |
flowchart LR
Composer[Composer / slash cmds]
Policy[evaluatePolicy]
RL[rateLimiter]
PF[preflight]
RT[runTurn loop]
AC[AgentClient.sendTurn]
A[AnthropicClient]
O[OpenAIClient]
L[LocalClient — OpenAI-compat]
TR[tools/registry]
T1[add_member]
T2[resolve_name]
T3[resolve_payer]
T4[lookup_fx_rate]
T5[submit_drafts]
Store[(Zustand store twc-v1)]
Settle[Settlement view]
Composer --> Policy --> RL --> PF --> RT
RT <--> AC
AC --> A
AC --> O
AC --> L
RT --> TR
TR --> T1 & T2 & T3 & T4 & T5
T5 --> Store --> Settle
Money invariant. Every amount in TWC is an integer minor-unit in its own currency.
The only sanctioned float boundary is FX conversion in src/lib/currency.ts, which
always ends in Math.round. Every proportional split preserves
Σ shares === expense.amountMinor via largest-remainder; every settlement asserts
Σ balances === 0 in base-currency minor units and throws on violation.
You can point TWC at any OpenAI-compatible local server (Ollama, LM Studio, llama.cpp server, vLLM) instead of paying for Anthropic or OpenAI. Receipts and chat content never leave your machine — unless you choose a remote OpenAI-compat endpoint, in which case it leaves on your terms. Cost goes to zero, and you get to run open-weights families (Qwen, Llama, Mistral, DeepSeek) without a billing relationship.
Download from https://ollama.com/download. Install, then verify:
ollama --versionUse ≥ 0.5. Older versions don't respond to Chrome's Private Network Access
preflight, so the browser silently refuses to connect even with OLLAMA_ORIGINS
set correctly.
| Model | Pull command | Notes |
|---|---|---|
qwen2.5-vl:7b |
ollama pull qwen2.5-vl:7b |
Fastest, solid OCR on receipts. |
llama3.2-vision:11b |
ollama pull llama3.2-vision:11b |
Meta's VLM, larger context window. |
minicpm-v:8b |
ollama pull minicpm-v:8b |
Good multilingual receipts. |
Pick one. You can swap the model name in Settings → Local later without reinstalling TWC.
Ollama refuses cross-origin browser requests by default. You must set
OLLAMA_ORIGINS to the exact origin TWC is loaded from.
# If running TWC locally (npm run dev):
OLLAMA_ORIGINS="http://localhost:5173" ollama serve
# If using the published URL from Chrome:
OLLAMA_ORIGINS="https://randyharrogates.github.io" ollama serve
# Both (Chrome users who toggle):
OLLAMA_ORIGINS="http://localhost:5173,https://randyharrogates.github.io" ollama serve
⚠️ Never useOLLAMA_ORIGINS=*. That lets any webpage you visit — ad frames, untrusted sites, anything — query your Ollama, consume your GPU, and submit prompts in your name. Always pin to TWC's exact origin.
LM Studio: set "CORS origins" in its server settings to the same value.
llama.cpp server: pass --api-allow-origin <url>.
- Open Settings → Providers → Local (it's the third panel in the Providers tab).
- Base URL:
http://localhost:11434/v1/chat/completions - Model name: the tag you pulled — e.g.
qwen2.5-vl:7b. Use the exact string fromollama list. - Context window (tokens): match your Ollama config (Ollama default is
32768). If TWC's value is lower, preflight will refuse oversized requests; if higher, the server will silently truncate. - Max output tokens:
4096is a reasonable default for receipt parsing. - Supports vision: tick this if you pulled a vision-capable model
(
qwen2.5-vl,llama3.2-vision,minicpm-v,llava). Leave unchecked for text-only models — flipping it for a text model will produce errors when you send a receipt photo. - API key: leave blank. Most local servers don't require one and TWC will
omit the
Authorizationheader entirely. - Click Save Local settings, then switch the Active provider dropdown at the top of the panel to Local.
- Open the chat (⌘K), grant the one-time Local image consent prompt the first time you attach a receipt, and send.
Mixed content (HTTPS page → http://localhost) is the load-bearing constraint:
| Browser | From https://github.io |
From http://localhost:5173 |
|---|---|---|
| Chrome / Edge | ✅ Works | ✅ Works |
| Firefox | ⚠ Often blocked by mixed content | ✅ Works |
| Safari | ❌ Blocked by mixed content | ✅ Works |
If you hit the mixed-content block, run TWC locally with npm run dev
(see the Quick start section below) — same app, same data, no HTTPS↔HTTP
conflict. Or put Ollama behind a local HTTPS proxy (Caddy, nginx) or expose it
over Cloudflare Tunnel with HTTPS, then paste the https://... URL into
Settings.
HTTPS tunnels (Caddy / nginx / Cloudflare Tunnel) only work when running
TWC locally. The deployed github.io build's CSP only permits connect-src
to loopback addresses and the two BYO-key providers (Anthropic, OpenAI).
Pointing at https://your-tunnel.example.com from the deployed URL will be
blocked by CSP. Run npm run dev (or fork and ship your own CSP) to use an
HTTPS local endpoint.
| Symptom | Likely cause |
|---|---|
| DevTools console: Refused to connect … violates Content Security Policy | The TWC bundle in your browser pre-dates the CSP update. Hard-reload the page (⌘⇧R / Ctrl+Shift+R) to pick up the new build, or check that your Base URL is http://localhost, 127.0.0.1, or [::1] — custom hosts aren't allowed by TWC's CSP. |
LocalEndpointUnreachableError toast |
Mixed content. See the table above. |
403 Forbidden from the server |
OLLAMA_ORIGINS (or LM Studio's CORS list) doesn't include TWC's origin. |
Parse error toast on every turn |
The model isn't honoring the tool schema. Try qwen2.5-vl:7b or a larger model. |
| Send button stays disabled | Local provider needs both Base URL and model name set in Settings → Providers → Local. |
Ollama replies with model not found |
Run ollama list and copy the exact tag (including :tag suffix) into the Model name field. |
Threat. https://randyharrogates.github.io fetching http://localhost:11434
is mixed content. Firefox and Safari usually block it silently; Chrome/Edge
permit it.
Action (choose one).
- Easiest: use Chrome or Edge.
- Cross-browser: run TWC locally instead of the published URL.
- Advanced: put Ollama behind a local HTTPS proxy or a Cloudflare Tunnel
with HTTPS, then paste the
https://...URL into Settings.
Threat. OLLAMA_ORIGINS=* lets any webpage query your Ollama.
Action. Pin to exactly the origin you use (see the CORS step above).
Threat. A malicious link or copy-pasted value could try to make TWC fetch unintended URLs (internal services, LAN scans).
TWC's built-in mitigation. A URL allow-list in
src/lib/llm/localClient.ts and
src/components/SettingsDialog.tsx. Only
HTTPS, http://localhost, http://127.0.0.1, or http://[::1] are accepted.
Anything else is rejected at save time with an inline error.
Action. Paste only your own local server URL or a trusted HTTPS endpoint you
run. If a tutorial tells you to paste a third-party http:// URL, refuse.
Threat. randyharrogates.github.io is shared with every other GitHub Pages
site under the same user. Sibling pages can read TWC's localStorage. With the
Local provider and no API key, there's nothing secret to leak — but if
OLLAMA_ORIGINS includes the github.io origin, sibling pages can also call your
Ollama.
Action. If shared-origin matters to you, run TWC from http://localhost:5173
instead of the published URL, and set
OLLAMA_ORIGINS=http://localhost:5173. Sibling GitHub Pages sites have no way
into a local origin.
Threat. Chrome requires a CORS preflight with
Access-Control-Request-Private-Network: true for cross-origin fetches to
localhost. Older servers that don't respond correctly fail silently.
Action. Use Ollama ≥ 0.5, LM Studio ≥ 0.3.4, or llama.cpp server with
--api-allow-pna.
Threat. A crafted receipt image could try to steer a smaller local model into misusing tools.
TWC's built-in mitigation (provider-agnostic).
- Mutating tools (
add_member,submit_drafts) requirePermissionPrompterapproval before they run. - Plan mode physically removes the mutating tool definitions from the prompt — the model can't emit them.
- Every tool call re-validates its input against a Zod schema; malformed
payloads surface as
parseErrortoasts, never as silent state changes.
Action. Stick to reputable model families (Qwen, Llama, Mistral, DeepSeek official quants). Keep plan mode on for ambiguous receipts and review drafts before clicking Accept.
npm install
npm run dev # http://localhost:5173/twc/
npm run test # vitest run (34 test files)
npm run build # tsc -b && vite build
npm run lint # eslint .
npm run preview # serve dist/ locallyPrerequisites
- Node ≥ 20 and npm.
- A modern browser.
localStorageandcrypto.randomUUID()are required. - An Anthropic or OpenAI API key if you want the chat assistant; without one, the app still runs as a plain expense splitter.
Get an Anthropic API key
- Create or sign in at console.anthropic.com.
- Generate a key under API Keys.
- Paste it into Settings → Providers → Anthropic in the app.
TWC sends requests directly from your browser using the
anthropic-dangerous-direct-browser-access: true header. The key goes on
x-api-key, never in the request body.
Get an OpenAI API key
- Create or sign in at platform.openai.com.
- Generate a key under API keys.
- Paste it into Settings → Providers → OpenAI in the app.
Reasoning models (GPT-5 family) use max_completion_tokens and reasoning_effort
instead of max_tokens / temperature — TWC handles the switch for you based on
isReasoningModel(id).
Open with ⌘K (or Ctrl+K). Attach up to a small number of JPEG / PNG / WebP
receipts, type free-form notes, and send. The assistant runs an agentic loop with a
32-iteration default cap that mirrors claw-code's DEFAULT_AGENT_MAX_ITERATIONS.
Dispatched pre-send by
slashCommands.ts.
| Command | Effect |
|---|---|
/plan | /plan toggle |
Toggle plan mode (same as Shift+Tab). |
/plan on | /plan off |
Set plan mode explicitly. |
/model |
Show the active model. |
/model <id> |
Switch the active model; also sets the provider. |
Shift+Tab in the composer toggles plan mode. Under plan mode, add_member and
submit_drafts are physically removed from the tool list — the model cannot emit
those calls because the schemas aren't sent. A PLAN MODE (active) block is also
appended to the system prompt. When a plan-mode turn ends, the last-assistant bubble
gets an Execute this plan button; clicking it re-runs the last user text with both
mutating tools re-enabled, without touching the global plan-mode setting.
Primary specs come from
primaryToolSpecs(group, planMode); executor via
createAgentExecutor(group, deps).
| Tool | Purpose |
|---|---|
add_member |
Add a new member to the active group. Mutating — goes through PermissionPrompter. |
resolve_name |
Fuzzy-match a name to an existing member id. |
resolve_payer |
Ask the user (via PayerPromptDialog) to disambiguate which member paid. Interactive, not mutating. |
lookup_fx_rate |
Prompt the user (via RateInputDialog) for an FX rate, write it to the group rate cache. Interactive. |
submit_drafts |
Emit a final array of expense drafts that hydrate into DraftCards. Gated by plan mode. |
PendingBubble renders a phase label next to the streaming cursor, driven by the
onPhase callback threaded through runTurn. A live elapsed-time readout ticks so
long tool loops don't feel frozen; the final elapsedMs persists on the assistant
message alongside usage / modelId.
| Phase kind | Surfaced as |
|---|---|
starting |
starting… |
thinking |
thinking… |
calling_tool:<name> |
resolving name…, looking up FX rate…, preparing drafts…, etc. |
tool_done:<name> |
Bubble flips back to the next phase or to streaming text. |
Both providers ship in BYO-key mode. You paste a key you control; TWC stores it in
your browser's localStorage under twc-v1 at settings.apiKeys.{anthropic|openai}.
- Entry surface.
Settings → Providers → <provider>. Keys are masked unless you click Reveal, and auto-re-mask on blur.APIKeyHelpPanelships the mandated three pieces: generation instructions, security disclosure, clear-key guidance. - Redaction.
exportState()replacesapiKeysvalues with empty strings. Keys never appear in request bodies,console.log, or any diagnostic output — only as request headers. - Clear a key.
Settings → Providers → Clear key, or deletesettings.apiKeys.<provider>via DevTools → Application → Local Storage →twc-v1.
If a shared-key mode is ever needed, a ~60-line Cloudflare Worker can inject the key
server-side and forward to Anthropic / OpenAI. ChatPanel.tsx instantiates the
provider directly today
(provider === 'anthropic' ? new AnthropicClient({...}) : new OpenAIClient({...}));
adding a proxy mode means extending that conditional with a third branch that
constructs a ProxyClient against the worker URL. Not built now; documented here.
TWC is published at https://randyharrogates.github.io/twc/. Before pasting an API key into a stranger's webpage, you deserve to know how the project handles it.
Threat model in one paragraph. There is no server and no auth. Requests go directly
from your browser to Anthropic / OpenAI with your own API key on the x-api-key /
Authorization header. TWC has no telemetry, no analytics, no third-party scripts
beyond Google Fonts (CSS only — the CSP forbids third-party script sources). npm audit
is green; every dependency is pinned; no dangerouslySetInnerHTML. All image parsing
runs through a magic-byte verifier before encoding.
The shared-origin risk. randyharrogates.github.io is a single origin shared by
every GitHub Pages site under the user. localStorage is origin-scoped, so a sibling
page at randyharrogates.github.io/<other-site>/ can read TWC's twc-v1 entry. If your
API key is stored in plaintext, that sibling page can exfiltrate it.
Passphrase vault. To close that gap, TWC includes a passphrase-based vault (opt-in)
that encrypts settings.apiKeys.<provider> at rest. Implementation:
- PBKDF2-SHA256 with a per-user 16-byte random salt, 600,000 iterations — using the same PBKDF2 + AES-GCM parameters as a prior project.
- AES-GCM 256-bit key, 12-byte random IV per encrypted value.
- Tagged-string ciphertext:
enc.v1.<iv_base64>.<ct_base64>. Anything that doesn't start withenc.v1.is plaintext. - The passphrase is never persisted. Only a salt, iteration count, and a small
probe (a known plaintext encrypted with the derived key) are saved. The probe lets
unlock(passphrase)verify a candidate passphrase without checking against real key material. - The derived
CryptoKeylives only inKeyVault's private memory for the session. Lock the tab (or close it) and the key disappears; the next send prompts for the passphrase again.
What the vault protects against. A sibling shared-origin site can read the ciphertext
blob from localStorage, but it cannot decrypt without your passphrase. That is the only
meaningful mitigation available to a pure-frontend app on a shared origin. For stronger
isolation, deploy under a custom domain you control — then the origin is no longer shared.
What the vault does not protect against. XSS on randyharrogates.github.io/twc/
itself (e.g. via a compromised npm dependency that runs inside TWC's page context) can
read the derived CryptoKey from memory while the vault is unlocked. Treat unlock as a
session permission: unlock, send, lock when you step away.
Clear or wipe.
- Clear a key: Settings → Providers →
Clear key, or DevTools → Application → Local Storage →twc-v1→ removesettings.apiKeys.<provider>. - Wipe the vault: Settings → Security →
Wipe vault. This deletes the passphrase meta AND all encrypted keys. Forgetting a passphrase is unrecoverable by design; a backdoor would defeat the encryption.
Source: src/lib/crypto.ts,
src/lib/keyVault.ts,
src/components/SecurityPanel.tsx.
Directory layout
src/
├── lib/ Pure logic. No React, no DOM, no localStorage.
│ ├── currency.ts 9-code allow-list, parseAmountToMinor, formatMinor, convertMinor
│ ├── splits.ts even / shares / percent / exact with largest-remainder
│ ├── settlement.ts Σ balances === 0 invariant
│ ├── policy.ts evaluatePolicy — spend caps, allowed providers, image consent
│ ├── rateLimiter.ts 10/min + 100/hr token bucket, persists
│ ├── validation.ts Zod-ish schemas for form input
│ ├── fuzzy.ts Fuzzy member-name matcher used by resolve_name
│ └── llm/
│ ├── agent.ts AgentClient interface + runTurn loop
│ ├── anthropicClient.ts, openaiClient.ts
│ ├── models.ts 8 models, µUSD pricing, lastVerifiedIso
│ ├── cost.ts µUSD math, exactly 2 Math.round boundaries
│ ├── preflight.ts Token estimation + context-window check
│ ├── prompt.ts buildAgentSystemPrompt
│ ├── conversation.ts pruneHistory at 60% of context window
│ ├── schema.ts Zod + toJsonSchema (strict)
│ └── tools/ registry, add_member, resolve_name, resolve_payer,
│ lookup_fx_rate, submit_drafts
├── state/
│ ├── store.ts Zustand + persist, twc-v1, version 7
│ └── imageCache.ts In-memory Map — never persisted
├── components/
│ ├── chat/ ChatPanel, Composer, MessageList, DraftCard, PendingBubble,
│ │ TokenCostBar, ToolUseBubble, slashCommands
│ └── ui/ Button, Input, Dialog, NumberInput — zero domain knowledge
└── test/ 34 Vitest files, mirrors src/** one-to-one
Each subdirectory has its own CLAUDE.md with local rules — read the one closest to
the file you're editing.
| Command | What it does |
|---|---|
npm run dev |
Vite dev server at http://localhost:5173/twc/. |
npm run build |
tsc -b && vite build → dist/. |
npm run test |
vitest run (34 files, mirrors src/**). |
npm run test:watch |
Vitest in watch mode. |
npm run test:ui |
Vitest UI. |
npm run lint |
eslint . |
npm run preview |
Serve the built dist/ locally. |
Tests live in src/test/ and mirror src/lib/ and src/state/
one-to-one (lib/money.ts → test/money.test.ts). Rules of thumb:
- Name tests as behavior, not function shape.
- No snapshot tests for logic — assert exact values.
- FX and split-rounding tests must cover both 0-decimal (JPY / KRW / TWD) and 2-decimal (USD / EUR / etc.) currencies.
- LLM-chat tests include at least one 0-decimal case so prompt/schema regressions break CI.
- Every provider-client test asserts the API key never appears in the request body.
- GitHub Pages via
.github/workflows/deploy.ymlon push tomain. - Vite
base: '/twc/'makes the bundle path-portable under the repo's Pages subpath. - Build artifact is
dist/; the workflow uploads it as a Pages artifact and deploys.
No secrets are required in CI — there is no backend and no shared API key. Users bring their own keys at runtime.
- Conventional Commits.
feat:/fix:/refactor:/chore:/test:/docs:. No Claude / Anthropic attribution in commit messages or PR bodies (TWC rule). - Per-directory
CLAUDE.mdrulebooks — read the closest one before editing. The rootCLAUDE.mdlinks each nested rulebook. - Money invariant is non-negotiable. Integer minor units everywhere; floats only
at the FX-conversion boundary in
src/lib/currency.ts;Σ sharesandΣ balancesinvariants apply. - Never commit API keys.
.envfiles with real keys, a pasted key in a test fixture, or a leaked header in a log are all bugs. - See
CHANGELOG.mdfor a version-by-version log.
TWC's agent runtime is heavily inspired by
claw-code — its ConversationRuntime
and turn-loop semantics shaped the AgentClient interface, the runTurn driver, the
32-iteration iteration cap, and the preference for direct fetch over vendor SDKs in
anthropicClient.ts / openaiClient.ts. Where claw-code's Rust/CLI patterns didn't
fit TWC's browser-only, frontend-only constraints, the adaptation is called out in
design notes; otherwise the patterns are reused as-is.
MIT © 2026 Randy Chan. See the LICENSE file for the full text.



