The Portfolio Agent
How mdemora.dev got a LangGraph agent: lexical retrieval, tool rounds, job-fit scoring, and a stream that shows what it is doing while you wait.
My portfolio has had a chat widget for a while. It answered questions about my work from a small, deterministic slice of the site — no embeddings, no vector database, no second LLM call just to decide what to retrieve. That was deliberate. Most visitor questions map to a handful of facts, and I wanted the answers grounded in copy I had actually reviewed.
The first version was a straight RAG loop: retrieve chunks, build a system prompt, stream plain text back. It worked, but it hit walls quickly. Writing notes lived outside the index unless you duplicated them. Pasted job links needed a separate code path. Tool use meant bolting on ad-hoc logic. And while the model was thinking, the UI showed nothing — just a blinking cursor and faith.
So I rebuilt it as a LangGraph agent at /api/agent. The old /api/chat route still exists; add ?engine=chat to the URL if you want to compare them side by side.
What stayed the same
The boring parts are the parts worth keeping:
- Lexical retrieval only.
retrievePortfolioChunksscores portfolio chunks with phrase hits, rare-token weighting, and conversation context. No embeddings, no external retrieval service. - One canonical knowledge base. Visible copy lives in
src/content/portfolio.ts. Richer agent-only facts live insrc/content/agent-knowledge.ts. Chat retrieval and machine outputs (/llms.txt,/resume.json, MCP) all read from the same formatters — not from duplicated strings. - Honest unknowns. If a fact is not in the evidence, the bot says so. Salary, private contact details, and personal opinions outside the published record are out of scope by design.
- Disclosed persona. The UI and system prompt are explicit: this is Miguel's portfolio bot, not Miguel live.
What changed: a graph instead of a pipe
The agent graph lives in src/lib/agent/graph.ts. Shape:
classify → prepare → (score?) → agent ⇄ tools → end
Classify is cheap pattern matching, not an LLM call. Greetings, bot-identity questions, and prompt-extraction attempts get tagged direct and skip heavy retrieval — they only need the identity chunk.
Prepare runs retrieval and link reading in parallel. Retrieval uses the same lexical scorer as before. If the visitor pasted a URL, a separate extractor model copies the page into a fixed, length-clamped JSON shape. The answering model never sees raw page HTML; it only sees fields like title, org, requirements, and stack inside an explicitly untrusted block.
Score runs only for pasted job postings. A third cold model rules on each requirement (met, partial, missing) against retrieved portfolio evidence. The 0–100 number is arithmetic over those verdicts, not a vibe the model felt like emitting. The UI renders it as a fit card before the prose answer lands.
Agent is the answering model with tools bound. Tools is a wrapped LangGraph ToolNode that emits activity events the UI can show.
Up to four tool rounds are allowed per answer:
search_portfolio— re-query the index with different keywordslist_writing_posts/get_writing_post— read published notes on demandmonid_discover/monid_inspect/monid_run— optional live research whenMONID_API_KEYis set
After four rounds, tools are stripped and the model must answer with what it has.
Streaming that shows the work
The old route streamed text/plain. The agent route streams application/x-ndjson: one JSON object per line. Event types:
activity— what the graph is doing (consulting the miguel canon,searching the portfolio,scoring the fit, …)fit— the job-fit verdict object for the cardtext— answer tokenstimeout— upstream deadline hit
Plain text alone could not carry progress while the visitor waits. NDJSON keeps the stream parseable: the client renders each line as it arrives and collapses finished steps into a trail next to the answer.
There is one subtle streaming bug to design around. The agent node streams with tools bound, so an early chunk might turn out to be a tool call rather than an answer. The text filter holds output one chunk behind; if a tool-call chunk arrives, everything held for that message is dropped instead of leaking a half-sentence "let me check…" preface.
Three models, three jobs
One chat model doing everything is tempting and wrong here.
| Instance | Temperature | Job |
|---|---|---|
| Agent | 0.6, streaming | Answer visitors, optionally call tools |
| Extractor | 0, no tools | Copy untrusted pages into fixed JSON |
| Scorer | 0, no tools | Rule on job requirements one at a time |
The extractor and scorer never see the site system prompt or conversation history. That is risk reduction, not immunity — a hostile posting can still carry instruction-like text inside a field — but the answering model only ever sees clamped, machine-copied fields inside an untrusted block that forbids obeying them.
Security as product design
Pasted links are the main injection surface. The defences are layered:
- Fetch happens in the graph before the answering model runs — never because the model asked for it.
- Raw page text never enters the answering prompt.
- Extracted facts sit inside
<untrusted_external_page>with a policy block that forbids obeying directives, changing persona, or outputting contact details found on the page. - Monid output gets the same treatment when live research is enabled.
Prompt-extraction attempts are classified as direct and answered from the identity chunk without pulling in unrelated portfolio context.
What I would do differently next time
Start with the activity stream. Waiting on an agent without visible progress feels broken even when it is working. Designing the wire format first would have saved a UI retrofit.
Keep retrieval dumb longer. The thin-evidence hint — nudge the model to search_portfolio once when lexical scoring finds nothing strong — replaced a whole query-rewrite LLM call. Cheaper and easier to reason about.
Treat job-fit scoring as its own node. Mixing link extraction, retrieval, scoring, and answering in one prompt produced inconsistent numbers. Splitting scorer into a dedicated model call with computed arithmetic made the card trustworthy.
Try it
With the dev server running:
curl -N -X POST http://localhost:3003/api/agent \
-H "Content-Type: application/json" \
-H "Origin: http://localhost:3003" \
-d '{"messages":[{"role":"user","content":"Where is Miguel based?"}]}'
Paste a job URL in the chat widget to see the fit card. Ask about a writing note by title and watch the agent reach for get_writing_post.
The code is in src/lib/agent/. The graph tests mock models so CI never touches the network. If you are comparing architectures, flip ?engine=chat and ask the same question twice.
This site also exposes a read-only MCP server at /api/mcp and machine-readable portfolio formats at /llms.txt and /resume.json. The chat agent is the conversational layer on top of the same canonical data — not a separate source of truth.