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
3 changes: 2 additions & 1 deletion _first/basilic/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ The interface follows a coherent design system: tokens, primitives, composition

## Artifacts

- **Fact:** No agent-readable `DESIGN.md` yet. Do not invent a second palette to complete the template. When added, use [DESIGN.md Format](https://raw.githubusercontent.com/google-labs-code/design.md/refs/heads/main/docs/spec.md) at `_first/DESIGN.md` (or one path listed in [../FIRST.md](../FIRST.md)).
- **Fact:** Demo shell brand is **Basilic** (sidebar). Markets home uses `@repo/ui` + `tokens.css` (`text-chart-2` / `text-destructive` for 24h change). No second palette.
- **Fact:** No Google-format `_first/DESIGN.md` yet. Do not invent a second palette to complete the template. When added, use [DESIGN.md Format](https://raw.githubusercontent.com/google-labs-code/design.md/refs/heads/main/docs/spec.md) at `_first/DESIGN.md` (or one path listed in [../FIRST.md](../FIRST.md)).
- **Fact:** Tokens: [`../../packages/ui/src/styles/tokens.css`](../../packages/ui/src/styles/tokens.css) — semantic colors, sidebar, radius, `@theme inline`, Inter / Poppins / mono
- **Fact:** Components: `@repo/ui` (shadcn/ui, Radix, Tailwind 4). ADR [004](../../apps/docu/content/docs/adrs/004-design-system.mdx). Frontend: [frontend.mdx](../../apps/docu/content/docs/architecture/frontend.mdx)
- **Fact:** Apps consume `@repo/ui`; app-only UI collocated in `apps/web` / `apps/mobile` / `apps/docu`
Expand Down
12 changes: 6 additions & 6 deletions _first/basilic/JOURNEYS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Actors, entry points, happy paths, alternates, error paths, permission gates, an
- **Fact:** Account: last-method guardrail (`LAST_SIGN_IN_METHOD`); change/link email; OAuth/wallet/passkey link; API keys `bask_` shown once.
- **Fact:** CLI: API key only; JWT auth endpoints excluded ([cli.mdx](../../apps/docu/content/docs/development/cli.mdx))
- **Drift:** Web3 verify exists on Fastify; **web has no wallet UI**. Do not map a wallet-connect journey as shipped.
- **Fact:** Assistant demo job (web, in-shell chat): entry = composer or suggestion; happy = `getAccountInfo` output with `__render: 'user-info'` rendered; errors = stream/tool failure or stop; completion = `assistant_turn` with `outcome: 'completed'` **and** `accountRender: true`. A text-only successful reply is **not** this job. No GenUI CTA (`actions: {}`).
- **Fact:** Assistant demo jobs (web, in-shell chat): (1) account — `getAccountInfo` + `__render: 'user-info'`; (2) markets — `getMarketSnapshot` + `__render: 'market-card'`. Entry = composer or suggestion. Completion for (1) = `accountRender: true`. Completion for (2) = market card rendered. A text-only reply is **not** either job. No GenUI CTA (`actions: {}`).
- **Unresolved:** named journey files beyond auth MDX; mobile completion

```mermaid
Expand Down Expand Up @@ -55,13 +55,13 @@ stateDiagram-v2
Assistant job (demo, same shape):

- actor: web end user (signed in)
- job: retrieve account context in-conversation
- entry: `/` assistant chrome; suggestions “Who am I?” / “What can you help with?”
- happy path: user sends a turn → `getAccountInfo` → `__render: 'user-info'` card
- alternates: text-only assistant reply (turn completed, account job not)
- job: retrieve account context or a market snapshot in-conversation
- entry: `/` assistant chrome; suggestions “What moved?” / “Explain BTC” / “Who am I?”
- happy path: user sends a turn → `getAccountInfo` → `__render: 'user-info'` **or** `getMarketSnapshot` → `__render: 'market-card'`
- alternates: text-only assistant reply (turn completed, demo job not)
- errors: stream error; user stop
- gates: proxy + Bearer JWT
- completion: `user-info` rendered. Not “the model replied.”
- completion: `user-info` **or** `market-card` rendered. Not “the model replied.”

## Recipe

Expand Down
6 changes: 3 additions & 3 deletions _first/basilic/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ The project has an inspectable answer to what, why, and how we will know. Non-go
- **Fact:** Canonical product brief: [product/index.mdx](../../apps/docu/content/docs/product/index.mdx), [product/features.mdx](../../apps/docu/content/docs/product/features.mdx), [product/roadmap.mdx](../../apps/docu/content/docs/product/roadmap.mdx)
- **Fact:** [`../../README.md`](../../README.md) — fork-and-run TypeScript fullstack starter (Fastify, OpenAPI, Next, Expo scaffold). No Wagmi, no first-class OpenAI SDK, no web wallet UI
- **Fact:** [`../../apps/docu/content/docs/index.mdx`](../../apps/docu/content/docs/index.mdx) — toolkit intro; Product is in the docs nav
- **Fact:** Two audiences: **adopters** (developers using the starter) and **demo users** (web news `/`, markets, settings, in-shell assistant; auth is the shipped job)
- **Fact:** Two audiences: **adopters** (developers using the starter) and **demo users** (web markets `/`, headlines strip, settings, in-shell assistant; auth is the shipped job)
- **Fact:** GTM: clone + [Getting Started](../../apps/docu/content/docs/development/index.mdx) (`db:start`, `pnpm reset`) + first local login. Bar: [Product Ready](../../apps/docu/content/docs/testing/product-ready.mdx). Finance **N/A** (toolkit)
- **Fact:** Owner: Gabo Esquivel (named on product index)
- **Fact:** New-device sign-in alerts are transactional email via Fastify `emailProvider` + `@repo/email`, not a notification product
- **Fact:** Not a billed SaaS in files. Do not invent TAM/LTV
- **Fact:** Non-goals: mobile not an API client; no web wallet UI; Cache Components off; `@repo/react` hooks handwritten; PostHog not installed; Sentry inactive
- **Fact:** PostHog **chosen, not installed**. Product events are **specified** in types + [analytics.mdx](../../apps/docu/content/docs/architecture/analytics.mdx) and **instrumented** via `apps/web/lib/analytics.ts` `capture()`. They are **not collected** and therefore **not measured**
- **Fact:** Two demo questions are specified: (1) auth — `auth_succeeded` / `auth_failed` by `method`; (2) assistant — `__render: 'user-info'` (`assistant_turn` with `accountRender`). Unmeasured
- **Fact:** PD (Markets + GenAI artifacts) is named on the feature map as **intended**, not shipped
- **Fact:** PD (Markets + GenAI artifacts) is shipped on the feature map: CoinGecko or mock, `getMarketSnapshot` / `market-card`
- **Unresolved:** PostHog install / consent / retention; keep / iterate / kill board; whether adopters copy `lib/analytics`

`pnpm qa` going green is Pipelines, not product success. Quality for R0 is [Product Ready](../../apps/docu/content/docs/testing/product-ready.mdx).
Expand Down Expand Up @@ -67,7 +67,7 @@ Product intent is documented or explicitly deferred with named owners. Implement

Apply Product First to Basilic.

Read root README, `apps/docu/content/docs/product/`, analytics MDX and ADR 011, and what the web, mobile, and API actually do. PostHog is not installed. `capture()` is a no-op — do not claim events are collected or measured. PD is named on the feature map, not shipped.
Read root README, `apps/docu/content/docs/product/`, analytics MDX and ADR 011, and what the web, mobile, and API actually do. PostHog is not installed. `capture()` is a no-op — do not claim events are collected or measured. PD is shipped on the feature map.

Preserve intentional existing product choices. Do not silently decide scope, priorities, pricing, TAM, LTV, or event names. This is a toolkit — say so rather than filling finance blanks.

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/lib/ai/tools/account-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getDb } from '../../../db/index.js'
import { users } from '../../../db/schema/index.js'
import { env } from '../../env.js'
import { createBraveSearchTool } from './brave-search.js'
import { createMarketSnapshotTool } from './market-snapshot.js'

const userInfoSpecRoot = 'user-info-1'

Expand Down Expand Up @@ -64,6 +65,7 @@ function createAccountInfoTool(userId: string) {
export function getMergedTools(userId: string, log: import('fastify').FastifyBaseLogger): ToolSet {
return {
getAccountInfo: createAccountInfoTool(userId),
getMarketSnapshot: createMarketSnapshotTool(),
...(env.BRAVE_SEARCH_API_KEY && {
braveSearch: createBraveSearchTool(env.BRAVE_SEARCH_API_KEY, log),
}),
Expand Down
39 changes: 39 additions & 0 deletions apps/api/src/lib/ai/tools/market-snapshot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
buildMarketCardSpec,
marketSnapshotMock,
pickMovers,
toMarketRows,
} from './market-snapshot.js'

describe('market snapshot helpers', () => {
it('parses CoinGecko-shaped rows and ignores junk', () => {
const rows = toMarketRows([
{
id: 'bitcoin',
symbol: 'btc',
name: 'Bitcoin',
current_price: 1,
price_change_percentage_24h: 2,
market_cap_rank: 1,
},
{ id: 'bad' },
])
expect(rows).toHaveLength(1)
expect(rows[0]?.id).toBe('bitcoin')
expect(rows[0]?.currentPrice).toBe(1)
})

it('picks largest absolute 24h moves', () => {
const movers = pickMovers(marketSnapshotMock)
expect(movers.length).toBeGreaterThan(0)
expect(movers.length).toBeLessThanOrEqual(5)
})

it('builds market-card spec', () => {
const spec = buildMarketCardSpec({ movers: marketSnapshotMock.slice(0, 2), source: 'mock' })
expect(spec.root).toBe('market-card-1')
expect(spec.elements['market-card-1']?.type).toBe('MarketCard')
expect(spec.elements['market-card-1']?.props.source).toBe('mock')
})
})
166 changes: 166 additions & 0 deletions apps/api/src/lib/ai/tools/market-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { tool } from 'ai'
import { z } from 'zod'

export const coingeckoMarketsUrl =
'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false&price_change_percentage=24h'

export type MarketRow = {
id: string
symbol: string
name: string
currentPrice: number
change24h: number
marketCapRank: number
}

export const marketSnapshotMock: MarketRow[] = [
{
id: 'bitcoin',
symbol: 'btc',
name: 'Bitcoin',
currentPrice: 67_420.12,
change24h: 2.14,
marketCapRank: 1,
},
{
id: 'ethereum',
symbol: 'eth',
name: 'Ethereum',
currentPrice: 3_412.5,
change24h: -1.08,
marketCapRank: 2,
},
{
id: 'solana',
symbol: 'sol',
name: 'Solana',
currentPrice: 178.4,
change24h: 4.62,
marketCapRank: 5,
},
{
id: 'ripple',
symbol: 'xrp',
name: 'XRP',
currentPrice: 0.62,
change24h: 0.41,
marketCapRank: 4,
},
{
id: 'cardano',
symbol: 'ada',
name: 'Cardano',
currentPrice: 0.45,
change24h: -2.3,
marketCapRank: 9,
},
{
id: 'dogecoin',
symbol: 'doge',
name: 'Dogecoin',
currentPrice: 0.12,
change24h: 6.11,
marketCapRank: 8,
},
]

const marketCardRoot = 'market-card-1'

export function toMarketRows(raw: unknown): MarketRow[] {
if (!Array.isArray(raw)) return []
return raw.flatMap(item => {
if (!item || typeof item !== 'object') return []
const row = item as Record<string, unknown>
const id = typeof row.id === 'string' ? row.id : ''
const symbol = typeof row.symbol === 'string' ? row.symbol : ''
const name = typeof row.name === 'string' ? row.name : ''
const currentPrice = typeof row.current_price === 'number' ? row.current_price : Number.NaN
const change24h =
typeof row.price_change_percentage_24h === 'number' ? row.price_change_percentage_24h : 0
const marketCapRank = typeof row.market_cap_rank === 'number' ? row.market_cap_rank : 0
if (!id || !symbol || !name || Number.isNaN(currentPrice)) return []
return [{ id, symbol, name, currentPrice, change24h, marketCapRank }]
})
}

export function pickMovers(rows: MarketRow[], query?: string) {
const q = query?.trim().toLowerCase()
const filtered = q
? rows.filter(
r =>
r.symbol.toLowerCase() === q ||
r.id.toLowerCase() === q ||
r.name.toLowerCase().includes(q),
)
: rows
const source = filtered.length > 0 ? filtered : rows
const sorted = [...source].toSorted((a, b) => Math.abs(b.change24h) - Math.abs(a.change24h))
return sorted.slice(0, 5)
}

export function buildMarketCardSpec({
movers,
source,
}: {
movers: MarketRow[]
source: 'live' | 'mock'
}) {
const top = movers[0]
const headline = top
? `${top.name} ${top.change24h >= 0 ? 'led' : 'lagged'} the board`
: 'Market snapshot'
return {
root: marketCardRoot,
elements: {
[marketCardRoot]: {
type: 'MarketCard',
props: {
headline,
source,
movers: movers.map(m => ({
symbol: m.symbol.toUpperCase(),
name: m.name,
price: m.currentPrice,
change24h: m.change24h,
})),
},
children: [],
},
},
} as const
}

export async function loadMarketRows(): Promise<{ rows: MarketRow[]; source: 'live' | 'mock' }> {
try {
const res = await fetch(coingeckoMarketsUrl, { signal: AbortSignal.timeout(8_000) })
if (!res.ok) return { rows: marketSnapshotMock, source: 'mock' }
const rows = toMarketRows(await res.json())
if (rows.length === 0) return { rows: marketSnapshotMock, source: 'mock' }
return { rows, source: 'live' }
} catch {
return { rows: marketSnapshotMock, source: 'mock' }
}
}

export function createMarketSnapshotTool() {
return tool({
description:
'Returns a crypto market snapshot (prices and 24h change). Use when the user asks what moved, BTC, ETH, top coins, or a market overview. Optional query filters by symbol or name.',
inputSchema: z.object({
query: z.string().max(64).optional(),
}),
execute: async ({ query }: { query?: string }) => {
const { rows, source } = await loadMarketRows()
const movers = pickMovers(rows, query)
const spec = buildMarketCardSpec({ movers, source })
const summary = movers
.map(m => `${m.symbol.toUpperCase()} ${m.change24h.toFixed(2)}%`)
.join('; ')
return {
__render: 'market-card',
spec,
summary: summary.length > 0 ? summary : 'No market rows.',
}
},
})
}
18 changes: 18 additions & 0 deletions apps/api/src/routes/ai/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,24 @@ describe('POST /ai/chat', () => {
expect(data.text.toLowerCase()).toMatch(/test@test\.ai|joined|email/)
}, 60000)

it('should return 200 when user asks what moved (getMarketSnapshot tool)', async ctx => {
const response = await fastify.inject({
method: 'POST',
url: '/ai/chat',
headers: { Authorization: `Bearer ${testToken}` },
payload: {
messages: [{ role: 'user', content: 'What moved? Use getMarketSnapshot.' }],
},
})
skipIfInsufficientCredits(ctx, response, 'what moved tool')
skipIfProviderUnavailable(ctx, response, 'what moved tool')
expect(response.statusCode).toBe(200)
const data = JSON.parse(response.body)
expect(() => ChatResponseSchema.parse(data)).not.toThrow()
expect(data.text).toBeTypeOf('string')
expect(data.text.length).toBeGreaterThan(0)
}, 60000)

it('should return 400 for overlong message content', async () => {
const response = await fastify.inject({
method: 'POST',
Expand Down
23 changes: 17 additions & 6 deletions apps/docu/content/docs/architecture/ai.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ apps/api/src/lib/ai/
download.ts denyRemoteChatFileDownload (blocks non-data: fetches)
runtime.ts abort, SSE piping, upstream error mapping
upstream-error.ts isInsufficientCreditsError
tools/
index.ts getMergedTools
account-info.ts getAccountInfo tool
brave-search.ts braveSearch tool (when BRAVE_SEARCH_API_KEY set)
tools/
index.ts getMergedTools
account-info.ts getAccountInfo tool
market-snapshot.ts getMarketSnapshot tool
brave-search.ts braveSearch tool (when BRAVE_SEARCH_API_KEY set)
```

Chat uses `streamText` / `generateText` with `stopWhen: isStepCount(AI_TOOL_MAX_STEPS)`. Generate is prompt-only (no tools). Shared runtime handles client abort + `AI_UPSTREAM_TIMEOUT_MS`, v7 UI streams (`toUIMessageStream` + `createUIMessageStreamResponse`), and catalog errors via `captureError` then `sendCatalogError`.
Expand Down Expand Up @@ -61,13 +62,23 @@ Templates: `apps/api/.env.defaults.example`, `apps/api/.env.test.example`.

The web app uses `useChatFromConfig` (`@repo/react`) → `POST {baseUrl}/ai/chat` with Bearer auth and `DefaultChatTransport` (UI-message SSE).

**Tool:** `getAccountInfo` — output shape:
**Tools:** `getAccountInfo` and `getMarketSnapshot`.

`getAccountInfo` output:

```json
{ "__render": "user-info", "spec": { "root": "...", "elements": { ... } }, "summary": "..." }
```

`assistant-chat.tsx` matches `tool-getAccountInfo` parts and renders via `user-info-catalog.tsx`. Do not rename the tool or change the output shape without updating the web UI. `__render: 'user-info'` is the product name for the demo account-context job. Product analytics records `assistant_turn` with `accountRender` when that surface is present — not merely when the assistant replies. See [Product analytics](/docs/architecture/analytics).
`getMarketSnapshot` output:

```json
{ "__render": "market-card", "spec": { "root": "...", "elements": { ... } }, "summary": "..." }
```

`assistant-chat.tsx` matches `tool-getAccountInfo` → `user-info-catalog.tsx` and `tool-getMarketSnapshot` → `market-card-catalog.tsx`. Do not rename a tool or change the output shape without updating the web UI. `__render: 'user-info'` is the account-context demo job; `__render: 'market-card'` is the markets demo job. Product analytics records `assistant_turn` with `accountRender` when the account surface is present — not merely when the assistant replies. See [Product analytics](/docs/architecture/analytics).

`getMarketSnapshot` reads the same public CoinGecko markets URL as the web board. On 429 or network failure it returns a checked-in mock so fork-and-run does not need a CoinGecko key.

Composer send (textarea + suggestions) is disabled while `status !== 'ready'`; stop remains available during `streaming`.

Expand Down
4 changes: 2 additions & 2 deletions apps/docu/content/docs/architecture/frontend.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Stream with `loading.tsx` and Suspense. Mark search/filter updates as transition

- Do not export `dynamic = 'force-dynamic'` from the root layout. That sets `fetchCache: 'force-no-store'` for the route and voids fetch-level `next.revalidate`.
- [`proxy.ts`](/docs/architecture/authentication) gates requests. Cookie reads there do **not** make RSC segments dynamic. A page becomes dynamic when a Server Component calls `cookies()` (for example `getUserInfo` → `getServerAuthToken`).
- Semi-static RSC data (news, markets): `fetch` with `next: { revalidate: N }`.
- Semi-static RSC data (markets, headlines): `fetch` with `next: { revalidate: N }`.
- Auth / Fastify: `cache: 'no-store'`. Identical GET `fetch` calls are request-memoized; do not wrap them in `React.cache()`.
- No `revalidateTag` until something is tagged. Mutations stay on Fastify, not Server Actions.

Expand All @@ -85,7 +85,7 @@ Pending and optimistic UI for mutations stays on TanStack mutation flags (`isPen

### Server vs client fetch

- **RSC:** semi-static pages (news, markets) and profile (`getUserInfo` in the page, `initialData` on `useUser`).
- **RSC:** semi-static pages (markets, headlines) and profile (`getUserInfo` in the page, `initialData` on `useUser`).
- **Client TanStack:** settings/security (API keys, passkeys, TOTP). These surfaces are mutation-first and need WebAuthn or OTP browser APIs. Do not duplicate their GETs in Server Components; an empty first paint until hydration is accepted.

```tsx
Expand Down
Loading
Loading