# 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