Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,25 @@ GOOGLE_CLIENT_SECRET=
RESEND_API_KEY=
RESEND_FROM_EMAIL=onboarding@resend.dev

# Jina AI — https://jina.ai/reader (used by web-search + web-fetch tools).
# Jina AI — https://jina.ai/reader
# 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.
# - REQUIRED for the `search_web` tool (s.jina.ai returns 401 without auth).
# The tool is not registered in the agent's list when this is empty.
# - OPTIONAL for the `fetch_url` tool — r.jina.ai accepts unauthenticated
# requests on the free tier (lower rate limit). A key just lifts the
# limit; absence does NOT disable the tool.
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).
# requests (browser-side wallet portfolio view) AND by the
# `get_NFT_holdings` crypto tool (server-side Alchemy NFT API call).
# When unset, `get_NFT_holdings` is not registered in the agent's
# tool list (the model just doesn't see it); the JSON-RPC proxy
# stays up but returns 401 on every request.
# 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
Expand All @@ -89,4 +99,22 @@ NEXT_PUBLIC_CRYPTO_REAL_SWAP=
# 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=
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=

# Deno Deploy — https://console.deno.com/
# Server-only. Used by the code agent's `execute_code` tool via the
# @deno/sandbox SDK to run untrusted code in a Firecracker microVM.
# Supported languages: TypeScript / JavaScript (Deno `eval`) and
# Python (`python3 -c`, stdlib only — sandbox image ships CPython 3.13).
# Create an access token at
# https://console.deno.com/ → Sandbox tab → access tokens.
# - If you create an ORGANIZATION token (starts with ddo_*), you only
# need DENO_DEPLOY_TOKEN.
# - If you create a PERSONAL token (starts with ddp_*), you also need
# DENO_DEPLOY_ORG — your org slug from the console URL.
# When unset, `execute_code` is not registered in the agent's tool
# list — the model can still propose code via `write_code`, and on
# Run the model surfaces a graceful fallback. Free tier: 1M requests
# / 15h CPU / month — see https://deno.com/deploy/pricing.
DENO_DEPLOY_TOKEN=
DENO_DEPLOY_ORG=
66 changes: 65 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Copy `.env.example` to `.env.local` and fill in:
- `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.
- `DENO_DEPLOY_TOKEN` + optional `DENO_DEPLOY_ORG` — server-only. Used by the code agent's `execute_code` tool via the `@deno/sandbox` SDK to run TypeScript / JavaScript / Python in a Deno Deploy Sandbox (Firecracker microVM). TS/JS go through `deno eval`; Python goes through `python3 -c` (sandbox image ships CPython 3.13, stdlib only). An organization token (prefix `ddo_`) only needs `DENO_DEPLOY_TOKEN`; a personal token (prefix `ddp_`) also needs `DENO_DEPLOY_ORG` (the org slug from the console URL). When unset, `execute_code` is not registered — `write_code` still works, and on Run the model surfaces a graceful fallback. Create a token at https://console.deno.com/ → Sandbox tab.

LangGraph CLI also reads `.env.local` (`langgraph.json` → `env: ".env.local"`) and pins Node 22.

Expand Down Expand Up @@ -303,14 +304,77 @@ vi.mock("@/lib/auth/config", () => ({ auth: { api: { getSession } } }));

beforeEach(() => {
getSession.mockReset();
getSession.mockResolvedValue({ user: { id: "u1", email: "u1@example.com" }, session: { id: "s1", userId: "u1" } });
getSession.mockResolvedValue({
user: { id: "u1", email: "u1@example.com" },
session: { id: "s1", userId: "u1" },
});
});
```

If a new route handler reads `process.env` at call time, also set / restore the env in `beforeEach` / `afterEach` — see `tests/api/alchemy/status.test.ts` for the pattern.

Rationale: the tool-ui cards are short-lived surfaces with one or two clear actions. Icons + text compete for attention and bloat the layout without adding signal. Text-only buttons stay scannable and match the rest of the assistant-ui primitives.

### 11. Use `components/tool-ui/primitives/` for card chrome — don't inline it

**Rule.** Every tool-ui card shares the same chrome: a rounded border
shell, an icon-circle header, and (sometimes) a destructive or muted
banner. Re-typing these in a new card is a bug factory — the same
overflow / spacing / icon-size fix has to be applied in N places.

The four primitives under `components/tool-ui/primitives/`:

- `CardShell` — outer wrapper + inner flex container
- `CardHeader` — icon circle + title + subtitle (+ optional trailing)
- `ErrorBanner` — destructive surface for tool failures (supports a
`monospace` flag for stack-trace content)
- `SuccessBanner` — neutral muted surface for resolved states

When you add a tool-ui card, start from `<CardShell>` and `<CardHeader>`.
If you need a one-off error or success surface, reach for `ErrorBanner` /
`SuccessBanner` first — only inline a new variant when none of them fit.

Two non-obvious details baked into the primitives (so they don't get
re-broken by a "quick inline" later):

- The error `<span>` is `min-w-0 flex-1` plus `break-all` in monospace
mode. Without those, long stack-trace lines (no spaces, e.g.
`file:///home/app/$deno$eval.mts:1:7`) overflow the card — the
card's `overflow-hidden` clips, but the visual is still wrong.
- The icon inside the circle is a flat lucide icon at `size-4`, not a
`CheckCircle2Icon` / `AlertCircleIcon` (the circle is already
drawn by the wrapper; a circle inside a circle looks small).

If you add a new primitive, drop it next to the existing four and
add a row to the "Shared UI primitives" section in `docs/TOOLS.md`.

### 10. Tools that need a third-party key MUST be lazy-registered

**Rule.** A tool that calls a third-party API which requires a server-side key (e.g. `search_web` → `JINA_API_KEYS`, `get_NFT_holdings` → `ALCHEMY_API_KEY`, `execute_code` → `DENO_DEPLOY_TOKEN`) must:

1. Be defined as `StructuredTool | null`, gated on `process.env.<KEY>` at module load.
2. Be spread into `ALL_TOOLS` (and any group array it belongs to) with a `...(tool ? [tool] : [])` so a missing key drops it from the agent's tool list — the model never sees a tool that would 401 on every call.
3. Be documented in `docs/TOOLS.md` under "Tool ↔ API key".

```ts
// in the tool file
export const getNftHoldingsTool: StructuredTool | null = process.env.ALCHEMY_API_KEY
? tool(impl, { name: "get_NFT_holdings", ... })
: null;

// in backend/tool/index.ts
export const CRYPTO_TOOLS = [
...,
...(getNftHoldingsTool ? [getNftHoldingsTool] : []),
];
```

`fetch_url` is the one exception — r.jina.ai accepts unauthenticated requests on the free tier, so it's always registered. `lib/jina.ts` falls through to a no-Auth `fetch` when the pool is empty.

Why: if a tool is registered without a key, the model invokes it, the upstream returns 401, and the user sees a runtime error mid-conversation. Conditional registration is one line of code and converts that failure mode into a graceful degradation — the model just doesn't know the tool exists and falls back to prose.

When you add a key-needing tool, also update `.env.example` (with the sign-up URL) and the "Tool ↔ API key" table in `docs/TOOLS.md`.

## Things to know before editing

- The graph id `agent` is referenced in three places: `langgraph.json` (`graphs.agent`), `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` in `.env.example`, and the `unstable_createLangGraphStream({ assistantId })` call. Keep them aligned.
Expand Down
38 changes: 19 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,26 +205,26 @@ Test database stays isolated from dev — never put production-like data in `lan

## Environment variables

| Var | Used by | Required? |
| ------------------------------------ | ------------------------- | ------------------------------------------- |
| `OPENAI_API_KEY` | backend agent | yes |
| `OPENAI_MODEL` | backend agent | optional (default `gpt-4o-mini`) |
| `OPENAI_BASE_URL` | backend agent | optional (OpenAI-compatible gateway) |
| `JINA_API_KEYS` | web-search + web-fetch | yes (comma-separated; one per Jina account) |
| Var | Used by | Required? |
| ------------------------------------ | ------------------------- | -------------------------------------------- |
| `OPENAI_API_KEY` | backend agent | yes |
| `OPENAI_MODEL` | backend agent | optional (default `gpt-4o-mini`) |
| `OPENAI_BASE_URL` | backend agent | optional (OpenAI-compatible gateway) |
| `JINA_API_KEYS` | web-search + web-fetch | yes (comma-separated; one per Jina account) |
| `ALCHEMY_API_KEY` | NFT gallery + portfolio | yes (server-only; powers `get_NFT_holdings`) |
| `LANGGRAPH_API_URL` | Next.js proxy | optional (default `http://localhost:2024`) |
| `LANGCHAIN_API_KEY` | Next.js proxy → LangGraph | optional (leave blank locally) |
| `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` | browser runtime | optional (default `agent`) |
| `NEXT_PUBLIC_LANGGRAPH_API_URL` | browser runtime | optional (uses proxy if unset) |
| `DATABASE_URL` | drizzle-kit + backend | yes |
| `DATABASE_URL_TEST` | vitest | yes |
| `BETTER_AUTH_SECRET` | session cookie signing | yes (see [docs/AUTH.md](docs/AUTH.md)) |
| `BETTER_AUTH_URL` | OAuth callback base | yes (default `http://localhost:3000`) |
| `RESEND_API_KEY` | verification emails | yes |
| `RESEND_FROM_EMAIL` | verification email sender | optional (`onboarding@resend.dev` default) |
| `GITHUB_CLIENT_ID` / `_SECRET` | GitHub OAuth | optional |
| `GOOGLE_CLIENT_ID` / `_SECRET` | Google OAuth | optional |
| `LANGSMITH_*` | tracing | optional |
| `LANGGRAPH_API_URL` | Next.js proxy | optional (default `http://localhost:2024`) |
| `LANGCHAIN_API_KEY` | Next.js proxy → LangGraph | optional (leave blank locally) |
| `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` | browser runtime | optional (default `agent`) |
| `NEXT_PUBLIC_LANGGRAPH_API_URL` | browser runtime | optional (uses proxy if unset) |
| `DATABASE_URL` | drizzle-kit + backend | yes |
| `DATABASE_URL_TEST` | vitest | yes |
| `BETTER_AUTH_SECRET` | session cookie signing | yes (see [docs/AUTH.md](docs/AUTH.md)) |
| `BETTER_AUTH_URL` | OAuth callback base | yes (default `http://localhost:3000`) |
| `RESEND_API_KEY` | verification emails | yes |
| `RESEND_FROM_EMAIL` | verification email sender | optional (`onboarding@resend.dev` default) |
| `GITHUB_CLIENT_ID` / `_SECRET` | GitHub OAuth | optional |
| `GOOGLE_CLIENT_ID` / `_SECRET` | Google OAuth | optional |
| `LANGSMITH_*` | tracing | optional |

## Patches

Expand Down
5 changes: 1 addition & 4 deletions app/api/alchemy/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@ function getCorsHeaders() {
// (`https://api.g.alchemy.com/data/v1/<key>/assets/<endpoint>`).
// It's not gated by the network allowlist — Portfolio API takes the
// list of networks in its body, so the allowlist wouldn't even apply.
async function handle(
req: Request,
ctx: { params: { path: string[] } },
) {
async function handle(req: Request, ctx: { params: { path: string[] } }) {
try {
const { path } = ctx.params;
const first = path?.[0];
Expand Down
7 changes: 6 additions & 1 deletion app/assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,12 @@ export function Assistant() {
title: "Show me my NFTs",
label: "",
prompt: "Show me my NFTs",
}
},
{
title: "Write a Typescript function to solve `Two Sum` leetcode problem",
label: "",
prompt: "Write a Typescript function to solve `Two Sum` leetcode problem",
},
]),
});

Expand Down
7 changes: 3 additions & 4 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,7 @@
}

:where(.aui-md[data-status="running"]):empty::after,
:where(.aui-md[data-status="running"])
> :where(:not(ol):not(ul):not(pre)):last-child::after,
:where(.aui-md[data-status="running"]) > :where(:not(ol):not(ul):not(pre)):last-child::after,
:where(.aui-md[data-status="running"]) > pre:last-child code::after,
:where(.aui-md[data-status="running"])
> :where(:is(ol, ul):last-child)
Expand All @@ -258,8 +257,8 @@
> :where(li:last-child)::after {
animation: aui-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
font-family:
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--aui-content: "\258d";
color: var(--muted-foreground);
content: var(--aui-content);
Expand Down
Loading