# agentic-portfolio-search Drop-in **agentic search for portfolios and docs sites**: BM25 instant search plus grounded, cited **Ask AI** answers. It works with **zero LLM keys**, and if a key is present it adds an optional LLM rewrite that is checked against the cited sources. Live on [gavinbasuel.com](https://gavinbasuel.com/#ask). MIT licensed. - **Instant Search:** in-memory BM25F (title, tags, summary and facts weighted separately) with prefix expansion for as-you-type ranking. A few hundred documents index in about 1 ms and rank in under 1 ms. - **Ask AI:** a deterministic synthesizer combines host-written *verified intent leads* with facts copied from the cited documents. Every answer carries `[n]` citations. When nothing matches, it says so instead of guessing. - **Optional LLM rewrite:** set `AI_GATEWAY_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY` or `ANTHROPIC_API_KEY`. The model only sees the cited sources. Output that cites a source that doesn't exist, times out (6 s) or errors is dropped, and the deterministic answer is returned instead. - **Never 500s:** the route handlers return a JSON body on every path, serve a stale index if a corpus refresh fails, and cache the index for 60 s by default. - **Accessible React UI:** `Instant Search` / `Ask AI` tabs, starter-prompt chips, `/` to focus, ↑↓ / ⏎ / Esc, citation cards, a `renderLink` slot (for hover previews or `next/link`), `?ask=` deep links, and `askAgenticSearch(question)` for CTAs. - **Themeable:** `styles.css` reads host tokens (`--fg`, `--muted`, `--divider`, `--card`, `--bg`) and falls back to its own. The architecture (shared search UI with a mode toggle, prompt chips, keyboard model, citation cards, and `/api/search` + `/api/ask` route seams) was generalized from the private shared tattoo search package I built for Tattoo.co / tattoo.dev. Everything domain-specific was removed. ## Install Copy `src/` into your app (or add the package from a git URL), then: ```ts // app/api/ask/route.ts import { createAskHandler } from "agentic-portfolio-search/next"; import { corpus, intents } from "@/lib/corpus"; export const dynamic = "force-dynamic"; export const POST = createAskHandler({ documents: corpus, intents, subject: "Ada" }); export const GET = POST; ``` ```ts // app/api/search/route.ts import { createSearchHandler } from "agentic-portfolio-search/next"; export const POST = createSearchHandler({ documents: corpus }); ``` ```tsx // any client component "use client"; import { AgenticPortfolioSearch } from "agentic-portfolio-search/react"; import "agentic-portfolio-search/styles.css"; ; ``` ## The corpus ```ts import type { PortfolioDocument, AnswerIntent } from "agentic-portfolio-search/core"; export const corpus = (): PortfolioDocument[] => [ { id: "platform:acme-mcp", title: "Acme MCP", summary: "Remote MCP server with 4 tools.", url: "https://acme.dev", category: "Platform", tags: ["mcp", "cloudflare workers"], facts: ["Ships 4 MCP tools on Cloudflare Workers."], }, ]; // Leads are written by you from facts you can prove; the engine cites the pinned docs. export const intents: AnswerIntent[] = [ { id: "mcp", match: /\bmcp\b|protocol/, lead: "Ada ships MCP as a product surface.", pin: ["platform:acme-mcp"] }, ]; ``` ## API | Export | From | Purpose | | --- | --- | --- | | `createIndex(docs)` / `search(index, q, opts)` | `core` | BM25F index and ranked hits | | `synthesizeAnswer(index, q, opts)` | `core` | Deterministic grounded answer `{ answer, points, citations, mode, tookMs }` | | `createAskHandler(opts)` / `createSearchHandler(opts)` | `next` | Web `Request → Response` handlers (App Router, Workers, Bun) | | `refineWithLlm(answer, docs, cfg)` / `resolveProvider(cfg)` | `next` | Optional LLM rewrite, validated against sources | | ``, `askAgenticSearch(q)` | `react` | UI and CTA trigger | ## Test ```sh npx tsx test/smoke.ts ``` ## License MIT © 2026 Gavin Basuel