feat(crypto): crypto sub-agent + NFT gallery on LangGraph#4
Merged
Conversation
Add a third sub-agent (cryptoAgent) alongside weatherAgent and
chatAgent, dispatched by the router on crypto-keyword messages.
Reuses the weather agent's human-in-the-loop pattern:
ask_crypto_intent (interrupt) → get_crypto_price →
get_fx_rate → confirm_crypto_order.
Tools (backend/tool/crypto/):
- ask_crypto_intent: pure trigger that pauses via interrupt.
Schema now carries currency + amount pre-fill so the LLM can
signal what the user meant ("100 元" → currency=CNY, amount=100).
- get_crypto_price: CoinGecko /coins/markets, 60s in-memory cache,
no key. Free tier rate-limit friendly.
- get_fx_rate (new): frankfurter.app, 60s cache, no key, ECB
data. Used by the LLM to convert non-USD amounts to USD before
confirm_crypto_order.
- confirm_crypto_order: simulated order, no signing, no chain.
Returns a fake receipt with qty = amount_usd / price_at_confirm.
Prompts (backend/prompt/system.ts):
- CRYPTO_AGENT_PROMPT: instructs the LLM to detect the user's
currency from the message (元/RMB/CNY → CNY, $/USD → USD, etc.)
and call get_fx_rate after the card resumes, before placing the
order. ¥ alone is ambiguous — only treat as JPY when the user
wrote JPY/日元/日本円 alongside it, else default to CNY.
- ROUTER_AGENT_PROMPT: extended with crypto keywords (price, buy,
sell, BTC, ETH, 加密货币, 价格, 买入, 卖出, 币, crypto, coin,
token).
Router enum fix: RouteDecisionSchema in router-agent-node.ts was
missing cryptoAgent — the LLM correctly emitted "cryptoAgent"
but parsing threw. Added the enum value + regression test.
Both topologies (USE_SUBGRAPH=true and inlined default) wire
cryptoAgent / cryptoModel + cryptoTools via buildSubgraph() and
buildInlined() in backend/agent.ts.
177/177 tests pass.
Display-only wagmi setup (no signing, no chain) used by the
crypto sub-agent's buy-intent card.
Web3 (app/web3-providers.tsx + lib/wagmi.ts):
- WagmiProvider with mainnet + injected connector, http transport.
- Lazy-init QueryClient + wagmi config via useState to survive
React strict-mode double-invoke (wagmi 3.x).
- Top-level <WagmiProvider> mounted in app/layout.tsx via Web3Providers.
Cards (components/tool-ui/crypto/):
- price-card: CoinGecko data with inline SVG sparkline, 24h
change pill, friendly formatPrice().
- ask-crypto-intent-card: buy/sell toggle, coin select, amount
input. Currency label is LLM-detected (e.g. "AMOUNT (CNY)")
with the LLM's hinted amount pre-filled. Amount input strips
leading minus so the value is never negative. Resume payload
shape: {coin_id, coin_symbol, amount, currency, side}.
- order-receipt-card: SIMULATED badge, qty / price / total / time
grid, order id footer. Etherscan link is disabled.
- connect-wallet-dialog (new): Radix Dialog listing every wagmi
connector. Order button now reads "Connect & buy BTC" when no
wallet is connected and "Confirm buy BTC" once connected.
Resume is queued in component state and flushed when isConnected
flips.
Suggestion chip: "Buy $100 of Bitcoin" added to the opening
picks in app/assistant.tsx.
Toolkit (components/tool-ui/toolkit.tsx): merged weatherToolkit +
cryptoToolkit (ask_crypto_intent, get_crypto_price,
confirm_crypto_order) into a single default export.
Chore: gitignore /.verify (Chrome DevTools MCP screenshots).
package.json: add wagmi@^3.6.20, viem@^2.53.1.
Skipped RainbowKit: it's pinned to wagmi 2.x and we're on 3.6.20.
Custom Radix-Dialog picker is ~120 lines, no new deps, and the
user has no MetaMask in the test environment anyway.
177/177 tests pass. Chrome DevTools verified both USD ("$100")
and CNY ("100 元") flows — LLM correctly detects currency, card
labels match, wallet dialog opens on order.
Backend (tests/backend/tool/crypto/):
- ask-crypto-intent.test.ts: pauses with ui: ask_crypto_intent,
forwards pick and error, defaults message, accepts currency +
amount pre-fill, makes no HTTP calls.
- get-crypto-price.test.ts: hits CoinGecko with vs_currency=usd
by default, accepts custom vs_currency, propagates 4xx/5xx as
serialized error result, normalizes sparkline_in_7d → sparkline.
- get-fx-rate.test.ts (new): calls frankfurter, uppercases codes
for cache key stability, returns same payload on cache hit,
propagates 4xx/5xx.
- confirm-crypto-order.test.ts: generates qty from amount_usd /
price_at_confirm, rejects non-positive inputs, includes UUID
order id + ISO timestamp.
Frontend (tests/frontend/crypto/ask-crypto-intent-card.test.tsx):
- amount input strips leading minus (100 → 100), keeps positives,
allows empty.
- currency detection: CNY / EUR labels render, defaults to USD
when no currency passed.
- amount pre-fills from the LLM's hint.
- wallet flow: "Connect & buy BTC" when disconnected, "Confirm
buy BTC" + truncated address when connected.
Regression (tests/backend/node/router-agent-node.test.ts):
- "routes crypto queries to cryptoAgent" — guards against the
RouterDecisionSchema enum drifting from state.ts.
- safeParse({ next: "cryptoAgent" }) succeeds.
Topology (tests/backend/agent-topologies.test.ts): extended to
cover cryptoAgent node presence in both USE_SUBGRAPH=true and
inlined default graphs.
docs/TODOS.md: new 2026-06-26 entry covering the v2 crypto
agent work (currency detection + wallet picker). Records the
skipped RainbowKit path with reasoning (wagmi 3.x peer-dep
mismatch) and the deferred real-DEX-swap / wagmi 3.x hook
migration follow-ups.
177/177 tests pass, lint clean.
Number arithmetic loses precision the moment it touches money:
100/3 = 33.333333333333336, 0.1+0.2 = 0.30000000000000004.
For a crypto buy-intent card these values land directly in the
receipt the user reads, so the drift is visible and trust-eroding.
New lib/decimal.ts wraps decimal.js (10.6.0):
parseAmount(input) → Decimal | null
Rejects empty, non-finite, non-positive, scientific notation
(1e2), malformed (1.2.3, abc), and amounts > 1e15. Returns
a Decimal that's exact through any precision the user can
type in a normal text field.
formatAmount(v, dp) → string
toFixed(dp) without float drift.
formatQty(v) → string
4dp for qty ≥ 1, 6dp for qty < 1 (satoshi-level trades).
safeDivide(n, d) → Decimal | null
Returns null instead of NaN/Infinity when the divisor is
zero / negative / non-finite — caller surfaces a structured
error instead of writing garbage to the receipt.
Card (ask-crypto-intent-card.tsx):
- Switched amount input from type="number" to type="text"
inputMode="decimal" so we own validation. <input type="number">
silently accepts 1e2 (scientific) and locale-dependent
separators; <input type="text"> plus parseAmount gives
consistent behavior everywhere.
- aria-invalid + red border when amount is non-empty and
parseAmount rejects it. Empty input is neutral, not invalid.
- The old "strip leading minus" hack (replace(/-/g, '')) is
gone — we surface the error visibly instead of mutating user
input behind their back.
Backend (confirm-crypto-order.ts):
- qty = amount_usd / price_at_confirm now goes through
safeDivide. The 100/3 case still serializes to a number
on the wire (Decimal.toNumber is safe under the safety
cap) but the internal computation is exact.
Receipt (order-receipt-card.tsx):
- formatUsd wraps formatAmount so totals don't render as
$33.333333333333336.
Tests:
- tests/lib/decimal.test.ts: 16 cases — parses, rejects (empty,
zero, negative, sci notation, malformed, Infinity, NaN,
overflow), formats, divides, divide-by-zero.
- tests/backend/tool/crypto/confirm-crypto-order.test.ts:
added 100/3 case (no ugly float drift in the wire payload).
- tests/frontend/crypto/ask-crypto-intent-card.test.tsx:
rewrote amount-input cases around the new validation model
(aria-invalid + disabled button) instead of the old
"strip on type" behavior. Added cases for scientific
notation, non-numeric junk, and multiple decimals.
docs/TODOS.md: format pass.
197/197 tests pass, lint clean. package.json: + decimal.js@^10.6.0.
LLM JSON and backend responses can omit fields, return null, or
serialize as NaN/Infinity after a JSON.parse round-trip. The
previous helpers assumed a Decimal | number | string and would
either throw (Decimal constructor on undefined) or render
literal "NaN" in the receipt / card.
formatAmount, formatQty:
- Accept unknown so the type matches "whatever the JSON said".
- Return the em-dash placeholder ("—") for null / undefined /
NaN / Infinity instead of crashing or rendering garbage.
- Still return the same exact value for valid inputs.
safeDivide:
- Accepts null/undefined on either side and short-circuits to
null, matching the existing zero/negative-rejection behavior.
order-receipt-card formatUsd:
- Short-circuits to "—" when amount_usd / price_at_confirm /
qty are missing so the receipt still renders with one blank
cell instead of "$NaN".
Tests: 7 new cases covering null/undefined/NaN/Infinity for
formatAmount, formatQty, and safeDivide. 204/204 pass, lint
clean.
RainbowKit 2.x is pinned to wagmi 2.x — we were on 3.6.20 which
peers-warned and forced a hand-rolled Radix Dialog picker.
Downgrading lets the canonical UI lib render the modal.
package.json:
- wagmi 3.6.20 → 2.19.5
- + @rainbow-me/rainbowkit 2.2.11
lib/wagmi.ts:
Replaces the hand-rolled createConfig + injected connector with
RainbowKit's getDefaultConfig (bundles injected / MetaMask /
Coinbase / Rainbow / WalletConnect). WalletConnect's projectId
is a placeholder — it 403s against Reown's API, but the other
wallets (injected, MetaMask, etc.) don't touch it and work
normally. Swap for a real id from cloud.walletconnect.com when
WC support is actually needed.
app/web3-providers.tsx:
Adds <RainbowKitProvider> inside <WagmiProvider> +
<QueryClientProvider>. Theme uses light/dark from useTheme.
Lazy-init QueryClient so React strict-mode double-invoke doesn't
share state across requests.
components/tool-ui/crypto/ask-crypto-intent-card.tsx:
Replaces the inline <ConnectWalletDialog> with a useEffect that
flushes a queued resume once isConnected flips. The order
button now:
- connected: resume immediately
- disconnected: queue the resume, call openConnectModal() from
RainbowKit. When the user picks a wallet and connects, the
wagmi account hook flips isConnected, our effect fires, and
the queued pick resumes the interrupt.
components/tool-ui/crypto/connect-wallet-dialog.tsx: DELETED
(replaced by RainbowKit's modal — ~120 lines of custom picker
and its test scaffolding gone).
tests/frontend/crypto/ask-crypto-intent-card.test.tsx:
Mocks useConnectModal from @rainbow-me/rainbowkit. Adds two new
cases:
- 'opens the RainbowKit connect modal when Confirm is pressed
with no wallet' — verifies the button click triggers
openConnectModal.
- 'does not open the connect modal when already connected' —
verifies the connected path skips the modal entirely.
206/206 tests pass, lint clean. Chrome DevTools verified: order
button click opens RainbowKit's picker modal ('连接钱包') with
Rainbow / Base / MetaMask / WalletConnect entries. Visual saved
to .verify/rainbowkit-modal.png.
Replaces simulated crypto orders with a real wallet-aware swap flow
backed by CoW Protocol (no API key) and Alchemy (server-side key).
Backend
- confirm_crypto_order is now a pure intent pause: it validates the
LLM's intent and returns {status: awaiting_user, intent}. The
frontend card subsumes the old ask_crypto_intent form, pulls real
Alchemy balances, fetches a CoW quote, and signs EIP-712 on click.
ask_crypto_intent (and its card + tests) are removed.
- get_token_balances added but kept out of CRYPTO_TOOLS — the LLM has
no wallet address in its context. Exists for programmatic use.
- get_swap_quote folded into the card (no separate backend tool).
- CRYPTO_AGENT_PROMPT: hard fiat rule ("buy $100 of BTC" is rejected
— no fiat on-ramp), explicit price-query vs trade paths, and the
rule that the card is the source of truth for wallet address + chain
(the LLM must never ask for either).
Frontend
- confirm-card.tsx: reads wagmi for address/chain, fetches Alchemy
balances with native-first sort + 3-layer logo fallback (Alchemy →
chain emblem → CoinCap), resolves the LLM's intent with sensible
defaults, debounces CoW quote fetch (400ms + AbortController),
signs EIP-712 on click. Replaces ask-crypto-intent-card +
order-receipt-card.
- price-card: formatPrice exported; no-fraction set for JPY/KRW/CLP/
etc., CNY uses bare ¥ glyph, CoinGecko attribution footer.
- RainbowKit with 7 wallets: MetaMask, Coinbase, Rainbow, Safe,
Binance, Bitget, WalletConnect. WalletConnectBoundary catches the
React 19 QR-modal crash and falls back to wagmi-only.
Alchemy
- /api/alchemy/[...path] edge proxy: per-chain RPC (validates against
lib/alchemy/networks.ts catalog + ALCHEMY_DISABLED_NETWORKS) and
portfolio/* for the global Portfolio API. Reads ALCHEMY_API_KEY
server-side only; browser never sees the key.
- /api/alchemy/status reports configured: boolean.
- app/alchemy admin page renders the catalog grouped by L1/L2/testnet
with per-network Test buttons.
Plumbing
- lib/wagmi.ts: routes every chain transport through /api/alchemy/<slug>
so CORS is the proxy's problem.
- lib/alchemy/{networks,portfolio}.ts: static catalog + Portfolio
normalizer.
- lib/swap/cow-config.ts: CoW settlement contract + per-chain API
endpoints + EIP-712 domain/types.
- lib/tokens/catalog.ts: 4-token MVP (USDC/WETH/USDT/WBTC) × 3 chains
with CoinGecko id + per-chain address map.
- patches/cuer@0.0.3: border=0 → 1. The QR generator rejects border=0
at runtime; React 19's stricter DOM validation escalates the resulting
crash to a render error. Patch is the minimum visual change until
upstream ships the fix.
- pnpm-lock.yaml: dep bump (next, wagmi pinned to 2.19.5, langgraph,
better-auth, react-email, resend).
Tests: 297 passing.
Known follow-ups
- docs/APIS.md confirm_crypto_order section still describes the OLD
signature; the get_swap_quote section describes a tool that was
removed when the card took over. Rewrite to match the new contract.
- pnpm patchedDependencies only covers the direct-dep instance of
cuer; RainbowKit's transitive copy resets on pnpm install. The
ErrorBoundary is the safety net; long-term fix is a postinstall
hook or pinning RainbowKit to a version that ships a fixed QR lib.
The previous self-custody DEX swap (CoW + Alchemy) is replaced by the new atomic-tool split in the next commit.
The single confirm_crypto_order tool + confirm-card monolith is split into three atomic, multi-turn tools with one user decision point each: connect_wallet (one-shot authorization, auto-resumes on first connected render — ref-guarded against Strict Mode double-invoke), place_crypto_order (quote + sign + submit), and get_order_status (poll the quote). Each tool is one ToolMessage the LLM can reason about; the model writes a one-sentence prose reply between them. The place-order card is fully simulated against Mock Coin (10,000 MC hardcoded balance, MC-priced gas converted at the live ETH/USD price, total MC spent shown in the receipt) — no real signing, no real on-chain transaction. The 'message' field on place_crypto_order and get_order_status is now composed by the LLM at call time so the prose next to the card reflects user intent instead of a fixed string. System prompt adds the CJK/Latin spacing rule for thread titles.
Width-aware truncated hex display used by the atomic crypto cards. Only shortens when the value actually overflows; full value shown on hover tooltip. One button copies the full (un-truncated) value to the clipboard, with a legacy document.execCommand fallback for insecure contexts (the chat runs on http://localhost during dev where navigator.clipboard is sometimes gated).
Vite + Playwright harness mounts all three atomic cards in a single browser (jsdom misses CSS layout — a real Chromium catches that the place-crypto-order preview fits inside the tool-call chrome). Stubs wagmi with a per-spec mock wallet helper (default address 0x1af12147C80F6d7A57BF7eC11985a2F2a7630977) and RainbowKit. Specs cover the connect → place → status flow + atomicity + Mock Coin visual + the regression that the cancel-confirm UI is no longer present. Also adds a frontend unit test for the connect-wallet card that was missed in the prior split commit.
Route every tool-call part through ToolFallback so the status icon, duration, and human-readable label show even when the tool has a custom renderer. ToolFallback now accepts a pre-rendered `toolUI` prop and defaults to open when one is provided; without it, the original args/result collapse panels stay intact. Snake-case tool names are humanized in the trigger (`connect_wallet` → `Connect Wallet`). Card top-level `my-2` is dropped across the four chrome-wrapped cards because the content wrapper already adds `pt-1 pb-2`.
Drop the Strict-Mode-guarded auto-resume — it skipped the user's
'Use a different wallet' choice. The connected view now shows a
header with the linked address plus a footer of Cancel (left) and
a segmented control (right): `Use this wallet` resumes with
{address, chainId}; the chevron half opens a portal-rendered menu
whose only item is `Use a different wallet` → `openAccountModal`.
Cancel resumes with {error: 'cancelled'}.
The dropdown is portaled to `document.body` to escape
`ToolFallbackContent`'s `overflow-hidden` clipping used for the
collapsible animation. Position is recomputed on resize and scroll
via the trigger's getBoundingClientRect.
Adds a NO INVESTMENT ADVICE section: no buy/sell/hold recommendations, no price-direction predictions, decline cleanly when asked, no pre-empting user intent, no promotional language. Applies across all reply languages.
TOOLS.md inventories every backend tool + frontend card (11 active, 1 dormant). INTERRUPT.md expands the example section from one (ask_location) to four, adding the crypto trade-flow interrupts (connect_wallet, place_crypto_order, get_order_status) with their resume payload shapes.
Rule #8: tool-ui buttons are text-only — no Lucide icon prefix even with gap-2. Architecture tree, Backend graph prose (two→three sub-flows), Environment vars, and Frontend runtime section all updated to cover the crypto sub-agent that landed earlier (subgraph + interrupt cards + wagmi/RainbowKit + Alchemy proxy).
The container's ToolFallbackContent already provides pt-1 pb-2 padding, so card-level vertical margin is redundant. weather-card.tsx still carries mx-2 on the error div — separate axis, left alone.
Was aspect-4/3 max-w-md (~448×336px card), sized to mimic the weather widget. But the skeleton is also used by the price card (much smaller), so the page inflated then collapsed every time a price query landed. Drop the chrome — a single inline row with a spinner + label works for any card size and doesn't trigger layout shift.
Wire tool names now match the rest of the toolset (ask_location, geocode_location, get_weather). JS exports stay camelCase (searchWeb, fetchUrl) per the existing askLocationTool -> ask_location pattern. Cascading references updated: CHAT_AGENT_PROMPT, CLAUDE.md, README.md, docs/APIS.md, docs/TOOLS.md.
When a tool call enters requires-action (interrupt or approval), paint a soft conic arc that travels around the card's 1.5px border and pulses gently. CSS targets the actual card via [data-slot$="-card"] so the outer collapsible isn't lit up; mask+conic punches a hole in the middle so only the border ring shows. Theme via --glow-warm / --glow-bright / --glow-length on :root, animated through a typed @Property --glow-angle. Thread.tsx forwards className="tool-call-glow-host" only when status.type === "requires-action", so other tool states stay untouched. Reduced-motion users get a static border.
The crypto sub-agent's swap flow is fully simulated now (Mock Coin balance, live CoinGecko USD pricing, synthesized order uid). Several doc pages still described the old CoW-based path that was dropped, plus TODOS.md had v1 / v2 task lists from earlier architectures. Resync: - docs/APIS.md: delete get_swap_quote section, delete Swap path section, rewrite the Crypto tools intro + place_crypto_order description around Mock Coin + CoinGecko. - docs/INTERRUPT.md / docs/TOOLS.md: reword get_order_status — synthesizes status because the uid is synthetic, not because 'we never hit CoW'. - docs/TODOS.md: drop the 2026-06-25 / 2026-06-26 crypto entries (resolved, superseded by current architecture). - .env.example: reword CRYPTO_REAL_SWAP — was 'live Uniswap V3 swap path / confirm_crypto_order with mode=real', neither accurate. - CLAUDE.md: fix ALCHEMY_API_KEY description — Alchemy is for the wallet portfolio view, not for eth/usd conversion (that path is CoinGecko). - cspell.json: drop 'cow' / 'cowswap' entries; no code or docs reference them anymore.
…ents CoW was the EVM DEX aggregator wired into the swap flow before it was dropped in a8a3121 (CoW / Alchemy scaffolding removed). Several tool descriptions and code comments still named it as the reason for the simulated path or as a future routing target — now misleading since there is no CoW integration anywhere in the codebase. - get-order-status.ts: reword tool description + body comment — 'we never hit CoW /orders/{uid}' → 'the status is fabricated rather than queried from any chain'. Same meaning to the LLM. - get-token-balances.ts: reword the Alchemy chain-mapping comment (no longer 'chains CoW is on') and the EIP-55 ponytail note (no longer 'matters for re-routing into CoW'). - order-status-card.tsx / toolkit.tsx / coingecko.ts: drop stale CoW mentions from comments.
Adds a read-only NFT holdings query to the crypto sub-agent. Resolves an address from the user's message or the most recent connect_wallet ToolMessage, hits Alchemy Portfolio API nfts/by-address across Ethereum / Arbitrum / Optimism / Base / Polygon, filters airdrop/claim-bait by name regex on top of Alchemy's own spam filter, and returns image URLs + contract metadata for the gallery card. The card groups by collection (contract + chain) with per-group 'Show N more' pagination, sorts largest collections first, and opens a fullscreen lightbox on tile click. Broken images fall back to an ImageOffIcon (the inline detail-view variant was tried and rolled back — lightbox keeps the chat scroll context intact).
Layered improvements to the get_NFT_holdings card on top of the initial implementation. Backend (get-nft-holdings.ts): - Normalize contentType, totalSupply, floorPriceEth from the Alchemy Portfolio response. Floor coerces from both number and legacy string shapes; non-finite / non-positive → null. Prompt (system.ts): - NFT HOLDINGS FLOW now auto-fires connect_wallet when no address is available in the message and no prior connect_wallet ToolMessage exists. Replaces the prior "ask the user to paste one" reply, which was a friction point. Constants (constants.ts): - NFT_GALLERY_COLLAPSED_STORAGE_KEY = "nft-gallery:collapsed-groups" for the persisted Set of group keys the user has collapsed. Card (nft-gallery-card.tsx): - Grouping: by collection (network + collectionName), not by contract. Multiple contracts that share a name (e.g. "APE NFT TICKETS" redeployed under 3 addresses) now land in one group; the header shows a chip per contract with its truncated address and token count. - Collapse persistence: group keys stored in localStorage; user collapses survive page reload. - Lightbox: two-column grid with full metadata (token id, floor, total supply, contract with copy-to-clipboard, OpenSea link). Renders via React Portal to document.body so it sits above the Thread scroll context. - Video: NftImage now branches on contentType (or img-onError retry) to render a <video> element. muted + autoPlay + loop by default; controls bar shows up only in the lightbox. OpenSea returns .mov URLs for some collection covers (e.g. "Base is for builders") — the same img→video fallback handles it. - Skeleton: animate-pulse placeholder behind tiles/lightbox while media loads. - Object-fit: cover on every media element so the area is filled without stretching. - Close button: aligned with collection name in the lightbox header row instead of absolute top-right. - Chip UI: contract addresses in the group header use the existing AddressOrHash component (click-to-copy), with showCopyButton off on the chip variant to avoid noise.
Audit follow-up: get_NFT_holdings output shape omitted contentType/totalSupply/floorPriceEth, and the tool + card were missing from CLAUDE.md's inventory; TOOLS.md row also had misaligned columns. README was missing the crypto sub-agent and ALCHEMY_API_KEY. INTERRUPT.md and TODOS.md are intentionally untouched (NFT is a non-interrupt tool and the work is done).
Prior builds left the catch-all proxies unauthenticated; any website's JS could create / list / delete threads or burn Alchemy compute units. withAuth reads the session row from Postgres through drizzle/postgres-js, which needs Node net — the LangGraph and Alchemy catch-alls lose runtime = edge on this commit. /api/auth/[...all] is the only route that stays open (it's the login endpoint itself); OPTIONS preflight also stays open so the browser can issue the authed request.
Required for lib/auth/config.ts to load under pnpm test now that routes import auth.api.getSession (added in the prior commit). Without this, every pnpm test run from a fresh clone throws "BETTER_AUTH_SECRET is required" at module load. The file holds only a fake test secret; no production data. .gitignore gains a single exception for .env.test (the blanket .env* rule stays).
Add rule #9 to CLAUDE.md covering: every app/api/**/route.ts must be wrapped in withAuth (exceptions: auth catch-all + OPTIONS preflight), the withAuth HOC needs Node so runtime must be nodejs / unset, and the test mock pattern (vi.hoisted getSession + next/headers + auth/config mocks). Update the "Frontend runtime" + "Things to know" sections to drop the obsolete 'proxy hardcodes runtime = edge' claim — it's nodejs now and that's a deliberate trade-off for the auth gate.
Swap the hardcoded bg-primary-foreground/50 fill for a linear-gradient(var(--glow-warm) → var(--glow-bright)) so the 30s countdown bar matches the project-wide tool-call glow. Bump --glow-warm / --glow-bright to 1.0 alpha and --glow-length to 75deg so the new bar reads clearly against the Accept Swap button background.
4 tasks
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this lands
The full
cryptoAgentsub-agent on top of the inlined LangGraph topology:price queries, simulated swap against Mock Coin, and a 5-chain NFT gallery.
Plus a follow-up security audit that gates every API route through
withAuthand locks down the catch-all proxy runtimes.Backend (LangGraph)
backend/tool/crypto/:get_crypto_price— CoinGecko public, 60s in-memory cache.get_fx_rate— Frankfurter (kept for the chat agent, not the trade flow).get_NFT_holdings— Alchemy Portfolionfts/by-address, 5 networks, name-regex spam filter,pageKeypagination (capped at 20 pages).connect_wallet— interrupt-driven; resumes with{ address, chainId }from wagmi.place_crypto_order— interrupt-driven simulated swap; auto-funds 10,000 MC; live CoinGecko USD; slippage + gas picker; one Accept button.get_order_status— interrupt-driven; synthesizes a status for the demo path.app/api/alchemy/[...path]/route.ts+/statushealth check, sourced from a static catalog inlib/alchemy/networks.ts. OptionalALCHEMY_DISABLED_NETWORKSdeny-list. Browser never sees the key.backend/prompt/system.ts):CRYPTO_AGENT_PROMPT— three flows (price / NFT / trade), one-tool-per-turn, no-fiat-amounts HARD rule, no-investment-advice HARD rule.ROUTER_AGENT_PROMPT— picksweatherAgent/chatAgent/cryptoAgentper turn.Frontend (assistant-ui)
app/web3-providers.tsx,lib/wagmi.ts). Wallet state is global; the cards read it via hooks — they never receive the wallet through tool args.components/tool-ui/crypto/:price-card— one row per id.connect-wallet-card— explicit confirm flow; closes the second-connect friction bug.place-crypto-order-card— quote, 30s price-poll countdown, slippage + gas, Accept Swap. The 30s drain bar now uses--glow-warm→--glow-brightso it matches the project-wide tool-call glow.order-status-card— status check.nft-gallery-card— grouped by collection, video+image media with<img>→<video>fallback (OpenSea returns.movcovers), fullscreen lightbox viacreatePortaltodocument.body, per-group collapse persisted tolocalStorageunderNFT_GALLERY_COLLAPSED_STORAGE_KEYinlib/constants.ts.ToolFallbackContentchrome around every toolkit render (no double-borders inside cards).AssistantRuntimeProviderno-icon rule (CLAUDE.md rule feat: CI/CD + single-image Docker + issue templates #8).API security (follow-up)
Prior builds left the LangGraph + Alchemy catch-all proxies unauthenticated and on
runtime = "edge". Two real problems:POST /api/threads(create / list / delete) orPOST /api/alchemy/portfolio/*(enumerate wallet balances) — these were effectively public RPCs.runtime = "edge", which collides withwithAuth:auth.api.getSessionreads the session row from Postgres throughdrizzle/postgres-js, and thepostgres-jsdriver needs Nodenet. Edge throwsFailed to get sessionand the user sees 500.Fix:
app/api/[..._path]/route.ts— wrapped inwithAuth; cookie + Authorization forwarded upstream so LangGraph can identify the calling thread.runtimechanged fromedge→nodejs. SSE body stays aReadableStream(test assertsres.body instanceof ReadableStreamso future edits don't accidentally buffer and break streaming).app/api/alchemy/[...path]/route.ts— wrapped inwithAuth.runtimechanged fromedge→nodejs(default for App Router).app/api/alchemy/status/route.ts— wrapped inwithAuth.CLAUDE.mdrule [Feat]: Email verification has no result page between verify and login redirect #9 — everyapp/api/**/route.tshandler must be wrapped inwithAuth(exceptions:auth/[...all]andOPTIONSpreflight). The rule documents the runtime, the test mock pattern, and the reason for the edge trade-off..env.testnow commits a fakeBETTER_AUTH_SECRETso the auth module can load underpnpm test;.gitignoreadds a single!.env.testexception under the existing.env*rule.The
/api/threads/*and/api/auth/*routes were already correct (the former is wrapped; the latter is the auth catch-all).Tests (TDD)
tests/backend/tool/crypto/(red→green for every new tool, including pagination, spam filter, missing API key, error propagation).tests/frontend/address-or-hash.test.tsx.tests/lib/alchemy/{networks,portfolio}.test.ts,tests/lib/prices/coingecko.test.ts,tests/lib/decimal.test.ts.tests/api/alchemy/{proxy,status,portfolio,auth}.test.ts— the 3 originals gained the auth mock inbeforeEach;auth.test.tsis new and covers the 401 path.tests/api/langgraph-proxy.test.ts— new: 401 path, basic proxy, cookie forwarding, SSE body identity (regression guard against anyone buffering the stream).tests/e2e/crypto-trade-flow.spec.ts+ Playwright harness undertests/e2e/.Docs (CLAUDE.md rule #1)
docs/APIS.md—get_crypto_price,get_fx_rate,get_NFT_holdings,connect_wallet,place_crypto_order,get_order_status, Alchemy proxy contract.docs/TOOLS.md— every tool + matching card, with the same backend path / frontend card split.docs/INTERRUPT.md— per-interrupt payload shape, inlined vs subgraph mode, "Adding a new interrupt-driven tool" checklist.CLAUDE.md— crypto sub-agent architecture, Alchemy env vars,ALCHEMY_API_KEYdescription,tool-ui/crypto/card list, rule [Feat]: Email verification has no result page between verify and login redirect #9 (withAuth gate) + updated LangGraph proxy runtime note.README.md— Features bullet for the crypto sub-agent,ALCHEMY_API_KEYenv row, TOOLS/INTERRUPT doc links.Diff stat
93 files changed, +22601 / -129.
Test / merge checklist
pnpm lint(oxlint + oxfmt --check) — seestyle(e2e): prettier-wrap signaturesandstyle(crypto): tighten NFT gallery header spacingcleanup commits.pnpm test— 9 new tool tests + frontend card tests + Alchemy proxy tests + newtests/api/langgraph-proxy.test.ts+ newtests/api/alchemy/auth.test.ts.docs/APIS.mdis the source of truth for theget_NFT_holdingsoutput shape (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).app/api/**/route.tsis now behindwithAuthper rule [Feat]: Email verification has no result page between verify and login redirect #9; the catch-all proxies switched toruntime = "nodejs"to allow that.@langchain/core@1.2.1subgraph run-map bug workaround is still inmemory/).Things to know
place_crypto_ordersynthesizes an order, no signing, no broadcast.NEXT_PUBLIC_CRYPTO_REAL_SWAP=trueflips to a dormant Uniswap V3 path (wagmi hooks live in the React tree, so a server-side router alone can't reach them).get_token_balancesis implemented + tested but not wired intoALL_TOOLS— the LLM has no way to know the user's address (it lives in wagmi on the frontend). Dormant.LIB/wagmi.tschains: Ethereum, Arbitrum, Optimism, Base, Polygon — matches the 5 chains the NFT gallery scans. WalletConnect projectId is required for Binance / Bitget QR fallback; MetaMask / Coinbase work without it.ALCHEMY_API_KEYis server-only; the browser hits/api/alchemy/*instead ofg.alchemy.comdirectly. Both routes are nowwithAuth-gated.cspell.jsonno longer carriescow/cowswapentries (refactor commit367fef5).runtime = "edge"; it's nownodejssowithAuthcan hit Postgres. If edge matters here in the future, route the session through a header set in middleware (see rule [Feat]: Email verification has no result page between verify and login redirect #9 for the trade-off).🤖 Generated with Claude Code