A look under the hood of my portfolio's AI assistant
Casper can navigate the user's browser, but I didn't use OpenAI's function calling for this. Instead, navigation is an in-band tool: the system prompt defines a text token pattern ([[NAV:target]]) that the AI learns to output inline with its response. The frontend picks it up with regex.
This is provider-agnostic. No function schemas, no tool_choice parameters, no vendor lock-in. Any LLM that can follow instructions can use this pattern.
Here's the relevant part of the system prompt:
NAVIGATION TOOLS (CRITICAL):
You have the power to navigate the user's browser directly.
- Rule 1: NEVER provide Markdown links for internal pages. The user cannot click them.
- Rule 2: To navigate, you MUST append the exact tag to the very end of your response.
- Rule 3: If the user says "Take me to projects" — NAVIGATE IMMEDIATELY.
If the user asks "What skills does he have?" — ANSWER first, then offer.
- Rule 5: If the user says "nothing happened", assume you forgot the tag.
Apologize briefly and output it immediately.
- Rule 6: If you say "Heading to X", you MUST include the tag. Words alone don't move.
Map of the Website:
- Home page -> [[NAV:index.html]]
- About Me (Biography) -> [[NAV:about.html]]
- CV (Formal History) -> [[NAV:cv.html]]
- Projects (Showcase) -> [[NAV:projects.html]]
- Photography (Hobby) -> [[NAV:photography.html]]
- Casper Lab (Interactive AI Playground) -> [[NAV:casper-lab.html]]
- Go Back -> [[NAV:BACK]]
On the backend, I parse the chat history to track which pages Casper has already navigated to, so he can suggest unvisited ones:
visited_pages = set()
for msg in chat_history:
if msg.get('role') == 'assistant':
matches = re.findall(r'\[\[NAV:(.*?)\]\]', msg.get('content', ''))
for m in matches:
visited_pages.add(m)
Casper's knowledge about me (skills, work history, projects) lives in JSON data files that also power the CV and About pages. At cold start, the backend loads these files and builds a structured knowledge base that gets injected into the system prompt via a placeholder.
The interesting part is how the message array is assembled at runtime. The current page and visited pages are injected as a system message after the conversation history, right before the user's message. This exploits recency bias, since LLMs pay more attention to tokens closer to the end of the context window.
# Context injected AFTER history for recency bias
context_msg = {
"role": "system",
"content": f"[SYSTEM UPDATE]\nCURRENT LOCATION: '{current_page}'\n"
f"VISITED PAGES: {visited_list_str}\n"
f"INSTRUCTION: If the user asks 'what else', suggest pages NOT in the visited list."
}
messages = [
{"role": "system", "content": system_prompt},
*chat_history,
context_msg, # <-- after history, before user message
{"role": "user", "content": user_message}
]
This means even in a long conversation, Casper always knows where the user is right now and which pages they've already seen.
Why in-band tools over function calling? Provider-agnostic, cheaper (no extra API overhead), and the navigation use case is simple enough that text tokens work perfectly. I can swap from OpenAI to any other LLM without changing the tool logic.
Why inject context after history? LLMs pay more attention to recent tokens. Placing the page context right before the user message ensures it's "top of mind" for the model, even after 20+ messages of conversation.
Local-first development In development, Casper tries a local LM Studio endpoint first. Same OpenAI-compatible API, zero code changes. Falls back to cloud in production automatically.