diff --git a/.env.example b/.env.example
index 63b2706..99f2ad8 100644
--- a/.env.example
+++ b/.env.example
@@ -61,4 +61,32 @@ RESEND_FROM_EMAIL=onboarding@resend.dev
# Comma-separated API keys. The pool picks one at random per request;
# 401/403 responses remove that key from the pool and retry.
# Get free keys at https://jina.ai/reader after sign-up.
-JINA_API_KEYS=
\ No newline at end of file
+JINA_API_KEYS=
+
+# Alchemy RPC — https://dashboard.alchemy.com/
+# Server-only. Used by app/api/alchemy/[...path] to proxy JSON-RPC
+# requests. Never reaches the browser (no NEXT_PUBLIC_ prefix).
+ALCHEMY_API_KEY=
+# Optional. Comma-separated Alchemy network slugs the proxy will REJECT
+# (turn-off list). The proxy accepts every network in the static catalog
+# (lib/alchemy/networks.ts) by default — set this to disable specific
+# chains in production (e.g. testnets). Leave empty to enable all.
+ALCHEMY_DISABLED_NETWORKS=
+
+# Crypto real-swap — feature flag for routing `place_crypto_order`
+# through a live DEX path. When unset / "false" (default), the card
+# stays in SIMULATED mode (hardcoded Mock Coin balance, no signing,
+# no on-chain broadcast). Set to "true" only after wiring a real DEX
+# router + signer (currently dormant — wagmi hooks live in the React
+# tree, so a server-only toggle won't reach them). Exposed to the
+# browser (NEXT_PUBLIC_) on purpose.
+NEXT_PUBLIC_CRYPTO_REAL_SWAP=
+
+# WalletConnect / Reown projectId — https://dashboard.reown.com
+# Bundled into the browser (NEXT_PUBLIC_) on purpose: it's a public
+# dapp identifier, not a secret (Reown docs: "this is a public
+# identifier"). The free tier covers 100k connections / month. Required
+# for WalletConnect-based wallets (binanceWallet, bitgetWallet) to
+# expose their mobile-QR fallback; injected wallets (MetaMask, Coinbase,
+# Rainbow, Safe) work without it.
+NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
\ No newline at end of file
diff --git a/.env.test b/.env.test
new file mode 100644
index 0000000..e3fcf68
--- /dev/null
+++ b/.env.test
@@ -0,0 +1,13 @@
+# Postgres — used by vitest under NODE_ENV=test.
+# @next/env reads this file when NODE_ENV=test (along with .env.test.local).
+DATABASE_URL_TEST=postgresql://FireTable@localhost:5432/langgraph_app_test
+
+# Jina pool — fake keys for unit tests. The pool rotation / failover logic
+# is exercised against `vi.stubGlobal("fetch", ...)`, so no real network.
+JINA_API_KEYS=jina_test_key_1,jina_test_key_2
+
+# Better Auth — fake secret for unit tests. Required for the auth module
+# to load; withAuth HOC tests mock getSession() directly so the secret
+# value is never validated against real signatures.
+BETTER_AUTH_SECRET=test_secret_aabbccddeeff00112233445566778899
+BETTER_AUTH_URL=http://localhost:3000
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b1a1c34..2ad52b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,11 +32,22 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.test
!.env.example
# vercel
.vercel
+# local visual verification screenshots
+/.verify
+# ad-hoc screenshots/logs dropped at repo root during manual testing
+/*.png
+/*.jpg
+/*.jpeg
+/*.log
+/test-results/
+/playwright-report/
+
# typescript
*.tsbuildinfo
next-env.d.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index d84fed8..58b1850 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,7 +51,11 @@ Copy `.env.example` to `.env.local` and fill in:
- `LANGCHAIN_API_KEY` — sent as `x-api-key` by the proxy; leave blank for local dev.
- `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` — graph id, must match a key in `langgraph.json` (`agent`).
- `NEXT_PUBLIC_LANGGRAPH_API_URL` — optional. If set, the browser skips the `/api` proxy and talks to LangGraph directly. Leave unset to use the in-app proxy.
-- `USE_SUBGRAPH` — backend graph topology toggle. `true` uses compiled `weatherAgent` / `chatAgent` subgraphs; unset (default) uses the inlined version that flattens them into the parent graph. The inlined default is a workaround for the `@langchain/core@1.2.1` `EventStreamCallbackHandler` "Run ID not found in run map" bug that LangGraph JS subgraphs trigger — see `memory/langgraph-subgraph-run-map-bug.md`.
+- `ALCHEMY_API_KEY` — server-only, used by `app/api/alchemy/[...path]` to proxy JSON-RPC. Required for the wallet's portfolio view (per-chain token balances via Alchemy Portfolio API).
+- `ALCHEMY_DISABLED_NETWORKS` — optional comma-separated Alchemy network slugs the proxy will reject. Default deny-list lives in `lib/alchemy/networks.ts`.
+- `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` — Reown projectId, required for WalletConnect-based wallets (Binance, Bitget) to expose their mobile-QR fallback; injected wallets (MetaMask, Coinbase) work without it.
+- `NEXT_PUBLIC_CRYPTO_REAL_SWAP` — feature flag for the live Uniswap V3 swap path. Unset/`false` keeps `place_crypto_order` in SIMULATED mode (Mock Coin balance, no signing, no broadcast). Set `true` to enable the real path (currently dormant).
+- `USE_SUBGRAPH` — backend graph topology toggle. `true` uses compiled `weatherAgent` / `chatAgent` / `cryptoAgent` subgraphs; unset (default) uses the inlined version that flattens them into the parent graph. The inlined default is a workaround for the `@langchain/core@1.2.1` `EventStreamCallbackHandler` "Run ID not found in run map" bug that LangGraph JS subgraphs trigger — see `memory/langgraph-subgraph-run-map-bug.md`.
- `NEXT_PUBLIC_USE_SUBGRAPH` — frontend mirror of `USE_SUBGRAPH`. Required because Next.js only inlines `NEXT_PUBLIC_*` vars into the browser bundle; the frontend reads this to decide whether to render the interrupt-UI card (`InterruptUI`) or the inline tool-call card.
LangGraph CLI also reads `.env.local` (`langgraph.json` → `env: ".env.local"`) and pins Node 22.
@@ -67,42 +71,53 @@ backend/
agent/
chat-agent.ts chatAgent compiled subgraph (USE_SUBGRAPH=true path)
weather-agent.ts weatherAgent compiled subgraph (USE_SUBGRAPH=true path)
+ crypto-agent.ts cryptoAgent compiled subgraph (USE_SUBGRAPH=true path)
node/
call-model-node.ts "agent" node — calls the model, appends AI reply
rename-thread-agent-node.ts "renameThreadAgent" node — generates + persists the title
- router-agent-node.ts "routerAgent" — picks weatherAgent vs chatAgent per turn
+ router-agent-node.ts "routerAgent" — picks weatherAgent / chatAgent / cryptoAgent per turn
after-agent-node.ts "afterAgent" — touches threads.last_message_at
- prompt/system.ts CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, ROUTER_AGENT_PROMPT, RENAME_THREAD_PROMPT
- tool/ ask_location, geocode_location, get_weather, searchWeb, fetchUrl
+ prompt/system.ts CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, CRYPTO_AGENT_PROMPT, ROUTER_AGENT_PROMPT, RENAME_THREAD_PROMPT
+ tool/ ask_location, geocode_location, get_weather, search_web, fetch_url
+ tool/crypto/ get_crypto_price, get_fx_rate, get_token_balances, get_NFT_holdings, connect_wallet, place_crypto_order, get_order_status
langgraph.json CLI config: graph id, node version, env file
app/ Next.js App Router
layout.tsx Root layout, fonts, TooltipProvider
page.tsx Renders in a full-viewport
assistant.tsx Builds useLangGraphRuntime; chooses /api vs direct URL
- api/[..._path]/route.ts Edge catch-all proxy to LANGGRAPH_API_URL
+ web3-providers.tsx wagmi/RainbowKit QueryClient + WagmiProvider wrappers
+ api/[..._path]/route.ts Node catch-all proxy to LANGGRAPH_API_URL (withAuth-gated)
+ api/alchemy/[...path]/route.ts Server-only JSON-RPC proxy to Alchemy (with key + per-network disabled list)
+ api/alchemy/status/route.ts Returns Alchemy key health + disabled-network list
globals.css Tailwind v4 entry
components/
assistant-ui/ Chat primitives (thread, attachment, markdown, reasoning, tool-fallback, tool-group, tooltip-icon-button)
ui/ shadcn/ui primitives (avatar, button, collapsible, dialog, tooltip) — new-york style, lucide icons
+ ui/address-or-hash.tsx Truncated address/hash with copy-to-clipboard
tool-ui/ask-location/ Interrupt-driven or addResult-driven location picker card
- tool-ui/weather/ Forecast widget renderer
+ tool-ui/weather/ Forecast widget renderer (vendored runtime + container + overlay)
+ tool-ui/crypto/ Price, connect-wallet, place-order, order-status, nft-gallery cards
lib/utils.ts cn() = twMerge(clsx(...))
lib/threads/ Threads module (schema, queries, adapter, validators)
+lib/wagmi.ts wagmi/RainbowKit config (chains, connectors, WalletConnect projectId)
+lib/alchemy/ networks.ts (slug → Alchemy URL + disabled list) + portfolio.ts (RPC helpers)
+lib/prices/coingecko.ts CoinGecko free-tier price client (60s in-memory cache)
+lib/decimal.ts Decimal-based amount math for crypto (no native float)
```
### Backend graph (`backend/agent.ts`)
-The parent graph dispatches a router decision into one of two sub-flows, both ending in `afterAgent`:
+The parent graph dispatches a router decision into one of three sub-flows, all ending in `afterAgent`:
-- `routerAgent` — calls `chatModel.withStructuredOutput(RouteDecisionSchema, { method: "jsonSchema" })` (tagged `nostream` so partial tokens don't leak into the chat) and returns `{ routerDecision: { next: "weatherAgent" | "chatAgent" } }` for the conditional edge to read.
-- `weatherAgent` / `chatAgent` — a model → tools loop driven by `toolsCondition`. Exits to `afterAgent` when the model emits no `tool_calls`.
+- `routerAgent` — calls `chatModel.withStructuredOutput(RouteDecisionSchema, { method: "jsonSchema" })` (tagged `nostream` so partial tokens don't leak into the chat) and returns `{ routerDecision: { next: "weatherAgent" | "chatAgent" | "cryptoAgent" } }` for the conditional edge to read.
+- `weatherAgent` / `chatAgent` / `cryptoAgent` — a model → tools loop driven by `toolsCondition`. Exits to `afterAgent` when the model emits no `tool_calls`.
- `afterAgent` — touches `threads.last_message_at` for the current thread; no message-channel writes.
- `renameThreadAgent` — fans out from `START` (parallel to `routerAgent`), generates the thread title on the first turn only, persists it to the `threads` row.
Two topologies share the same router + rename + after nodes and are gated by `USE_SUBGRAPH` (env var, see Environment):
-- **Inlined (default).** `weatherModel` / `weatherTools` / `chatModel` / `chatTools` are inlined as plain nodes in the parent graph. The model/tool logic is duplicated from `backend/agent/weather-agent.ts` and `backend/agent/chat-agent.ts` — keep them in sync. The router's pathMap remaps `"weatherAgent"`/`"chatAgent"` (the router's string enum) to `"weatherModel"`/`"chatModel"` (the inlined node names).
-- **Subgraph (`USE_SUBGRAPH=true`).** The compiled `weatherAgent` and `chatAgent` from `backend/agent/*-agent.ts` are wired as opaque nodes via `addNode("weatherAgent", weatherAgent)`. PathMap is an array of allowed destinations, since the returned string already matches the node name.
+- **Inlined (default).** `weatherModel` / `weatherTools` / `chatModel` / `chatTools` / `cryptoModel` / `cryptoTools` are inlined as plain nodes in the parent graph. The model/tool logic is duplicated from `backend/agent/weather-agent.ts`, `chat-agent.ts`, and `crypto-agent.ts` — keep them in sync. The router's pathMap remaps `"weatherAgent"`/`"chatAgent"`/`"cryptoAgent"` (the router's string enum) to `"weatherModel"`/`"chatModel"`/`"cryptoModel"` (the inlined node names).
+- **Subgraph (`USE_SUBGRAPH=true`).** The compiled `weatherAgent`, `chatAgent`, and `cryptoAgent` from `backend/agent/*-agent.ts` are wired as opaque nodes via `addNode("weatherAgent", weatherAgent)`. PathMap is an array of allowed destinations, since the returned string already matches the node name.
Both builders live in `backend/agent.ts` (`buildSubgraph()` and `buildInlined()`). When `USE_SUBGRAPH` flips, no other file needs to change — but if you add a node, prompt, or tool, update both builders.
@@ -112,6 +127,10 @@ The chat models in `backend/model.ts` carry `modelKwargs: { reasoning_split: tru
The weather prompt (in `backend/prompt/system.ts`) lists four steps in order — `ask_location` → `geocode_location` → `get_weather` → one-sentence reply — and explicitly forbids batching tools in a single turn. The frontend card (`components/tool-ui/ask-location`) keys off the `ask_location` `ToolMessage`, so any tool run alongside it would race the human input. See `docs/INTERRUPT.md` for the two runtime paths the card can take.
+### `CRYPTO_AGENT_PROMPT` enforces one-tool-per-turn + no-investment-advice
+
+Same one-tool-per-turn discipline as weather, applied to the trade flow: `connect_wallet` → `place_crypto_order` → `get_order_status` are HARD checkpoints, each pauses for one user click. `place_crypto_order` is gated by a "no fiat amounts" rule — when the user names a dollar/yuan/euro amount, the agent declines rather than quoting. The prompt also hard-blocks investment advice: no "buy now", no price-direction predictions, no "good entry", no editorializing on token quality; the agent describes only what the user asked and what the card does. The cards render numbers — the prose never repeats them.
+
### State persistence (dev vs prod)
The checkpointer active for a run is chosen by the runner, not by us:
@@ -131,10 +150,14 @@ Consequences worth knowing:
`app/assistant.tsx` is a client component. It instantiates the runtime with `useLangGraphRuntime({ stream, create, load })` from `@assistant-ui/react-langgraph` (which wraps `@langchain/langgraph-sdk`'s `Client`). `stream` is built from `unstable_createLangGraphStream`; `apiUrl` is `NEXT_PUBLIC_LANGGRAPH_API_URL` if set, otherwise the same-origin `/api` URL.
-`app/api/[..._path]/route.ts` is an edge-runtime catch-all that proxies every method (`GET/POST/PUT/PATCH/DELETE/OPTIONS`) to `${LANGGRAPH_API_URL}/${path}` with `x-api-key: LANGCHAIN_API_KEY`, strips hop-by-hop / content-encoding headers, and adds permissive CORS. The body of mutating requests is forwarded as text.
+`app/api/[..._path]/route.ts` is a node-runtime catch-all (see rule #9 — edge throws on `auth.api.getSession`) that proxies every method (`GET/POST/PUT/PATCH/DELETE/OPTIONS`) to `${LANGGRAPH_API_URL}/${path}` with `x-api-key: LANGCHAIN_API_KEY`, strips hop-by-hop / content-encoding headers, and adds permissive CORS. The body of mutating requests is forwarded as text. The handler is wrapped in `withAuth` (cookie + Authorization are forwarded upstream so LangGraph can identify the calling thread).
`components/assistant-ui/thread.tsx` mounts `InterruptUI` (uses `useLangGraphInterruptState` + `useLangGraphSendCommand`) inside the last assistant message. The interrupt-driven render only fires when `NEXT_PUBLIC_USE_SUBGRAPH=true`; in default (inlined) mode, the ask_location card renders in the tool-call slot instead. See `docs/INTERRUPT.md` for the full two-mode flow.
+### Web3 providers
+
+`app/layout.tsx` wraps the assistant tree in `` (`app/web3-providers.tsx`), which stacks `@tanstack/react-query` `QueryClientProvider`, `WagmiProvider` (configured by `lib/wagmi.ts`), and RainbowKit's `RainbowKitProvider`. Wallet state is global to the browser; the crypto cards read `address` / `chainId` from wagmi hooks directly — they never receive the wallet through tool args. The trade flow is fully SIMULATED regardless of wallet connectivity: `place_crypto_order` auto-funds Mock Coin on the first trade and synthesizes the order on click. Setting `NEXT_PUBLIC_CRYPTO_REAL_SWAP=true` is required to route through any real DEX path (currently dormant — wagmi hooks live in the React tree, so a server-side router alone can't reach them).
+
### Patches
`patches/` is currently empty. `pnpm-workspace.yaml` retains the `patchedDependencies:` header as a placeholder — when you need to patch a package, add the entry there and drop the `.patch` file under `patches/`. Re-check on every package bump; drop the entry + file when upstream ships the fix.
@@ -239,10 +262,59 @@ Before running `pnpm dev` (or starting any dev server), check whether the releva
Killing a running dev server loses unsaved browser state, breaks open browser tabs, and erases hot-reload history. If the dev server appears stale or stuck, surface the observation and ask the developer how they want to proceed; do not act unilaterally.
+### 8. Tool-UI buttons are text-only — no icons
+
+Buttons inside `components/tool-ui/**` (and any new card added under that directory) render the label as the action. Do not put a Lucide icon (or any other icon) as a prefix to the label, even with `gap-2` to space them out. No ``, ``, etc. inside `