With six apps running -- inbox0, Summarizer, DrawmaticAI, Pomodoro, Scribe, Code Reviewer -- I had a new problem. I knew where everything lived, but I had no single place to ask about it. Which emails need replies? What did I commit to in Tuesday's meeting? The apps were sovereign, which was the point, but the cost was fragmentation.
So I built Gopher, the seventh app and the first that does not own any data of its own. It queries the others, caches summaries in PostgreSQL, and answers in plain language. Dry, competent, and slightly knowing. It is the steward of the estate, not the owner of the rooms.
The architecture was straightforward on paper: FastAPI lifespan, asyncpg pool, Ollama tool-calling loop. Send a question, let the model decide whether it needs data from an app, execute the tool, feed the result back, get a synthesized answer. Three turns max. Simple.
It was not simple.
The first symptom: every response was a single stray }. Just that. One curly brace. I added logging and discovered the regex I used to parse tool calls -- TOOL_CALL:\s*(\{.*?\}) with non-greedy matching -- stopped at the first closing brace. For a nested JSON object like {"params": {}}, it captured broken JSON. json.loads failed silently. The model thought it had answered. I got punctuation.
Fix: replace the regex with brace-counting logic. Find TOOL_CALL:, walk character by character, increment on {, decrement on }, and only stop when the count hits zero. Now nested objects parse correctly. A small change. A two-hour debugging session.
Then the real bug appeared. I asked, "Any emails I should reply to?" and got work project emails when I was signed in as my personal account. That is not a UX issue. That is a trust issue. If Gopher leaks work context into personal space, the entire premise collapses.
I traced it two levels deep. First, inbox0 stores triage data per account but the server-to-server endpoint was returning the most recent triage across all accounts, not the user's default. Second, even after fixing that, Gopher was re-feeding stale tool results from previous conversation turns back into the LLM context. Old work emails persisted in the thread and the model kept surfacing them.
The fix was twofold. On the inbox0 side, the endpoint now resolves the user's default account explicitly and only queries triage for that account. On the Gopher side, tool role messages are excluded from the LLM history on subsequent turns. Each query starts fresh. No stale data leakage.
But I did not want Gopher to be dumb about multiple accounts. I have Work, iCloud, and a Gmail account in inbox0. So I added inbox0.get_accounts to the tool registry, and taught the tasks endpoint to accept account_id, account_name, or all=true. Now you can ask "what are my work emails?" or "anything urgent on iCloud?" and Gopher scopes the query correctly.
The final polish came from frustration, not architecture. The responses rendered as raw markdown -- **Must:** instead of bold headers, bullet lists as asterisks. I added a lightweight markdown-to-HTML converter and CSS for lists, bold, and italic inside chat bubbles. Then a TTS toggle because sometimes I want to hear the answer while I am walking away from the desk. Then PWA support so it loads like a native app on my phone, not a broken web page.
Building Gopher taught me that the hard part of an AI concierge is not the LLM. It is boundary management -- knowing what data belongs to which context, preventing leakage across conversations, and making the model accountable to the user's intent rather than its own pattern matching.
Gopher is the seventh app in the suite. It remembers your emails, meetings, tasks, and reviews. It knows which account you mean. It speaks when you ask it to. And it never pretends to know what it has not actually checked.