writing — september 2026 · 9 min

A quiet cat, with a complicated backend

There is a cat sitting on the line above this site's footer. Open it and you can ask about my projects, writing, background, or what has been happening lately. The panel is small. The system behind it took rather more work.

01A narrow job

I did not want a general chatbot floating over a portfolio. It would know less about the site than the site itself, and it would spend most of its time inviting people to ask questions they never came here to ask. Kitty has a smaller job: help a visitor understand the work that is already here.

That means it can compare projects, find an essay, explain something on the current page, read my public background, check recent GitHub activity, or see what Spotify is playing. It cannot browse the wider web, send email, run code, or quietly become a different assistant. The useful boundary is this site and the few public sources attached to it.

The interface follows the same rule. It lives in the footer rather than arriving as a chat bubble, opens as an editorial rail or a page-edge sheet, and shows the work as short status lines beside the cat. The answer remains the point. The character is there to make the wait feel considered, not to make the answer less useful.

02Two projects, one conversation

The widget belongs to this Next.js portfolio. The agent is a separate Python service. They deploy independently on Vercel and share no application state in memory. Their whole relationship is one request and one event stream.

fig. 01 — two deployments, joined by one narrow stream

The browser sends the message, an optional conversation id, and the route it is currently showing. FastAPI admits the request and hands it to the graph. Neon stores checkpoints, Gemini makes the language and routing decisions, and the tools read either prepared site content or a live public source. Events travel back as they happen.

Keeping the repositories separate made the boundary honest. The portfolio does not import agent code, and the service does not scrape the deployed site during a question. A development-time sync copies project, profile, and route data into the service. Essays are embedded separately for passage search. A serverless instance can disappear after any turn because the conversation lives elsewhere.

03The loop is the agent

I used LangGraph's graph primitives directly instead of its prebuilt agent helper. There are only two working nodes. The agent node calls Gemini with a bounded set of tools. If the result contains tool calls, a ToolNode executes them and appends their results. Then the agent sees those results and decides again. With no tool call left, the run ends and the answer is complete.

fig. 02 — the result returns to the model; a tool call is not the end

This is why I call it an agent rather than a retrieval chatbot. Retrieval is one possible action. A project question reads structured project data; an opinion question searches the writing; a request to “take me there” asks the navigation tool for a real route; a question about recent work goes to GitHub. One turn can use several tools if it needs them.

Ambiguity is part of the graph too. The clarification tool interrupts the run with named choices and stores that pause in Postgres. The visitor's next message resumes the same tool call instead of starting a detached conversation. It is a small feature, but it makes the difference between pretending to understand and knowing when not to.

04The stream is part of the product

A correct answer that leaves the panel blank while it works still feels broken. The service therefore streams four small event shapes over server-sent events. The frontend turns them into visible state rather than exposing model plumbing.

steplooking through the projectschange the cat's working state
tokenThe project uses…grow one answer in place
questionWhich project?show choices while the graph waits
donethread_idkeep the conversation resumable
fig. 03 — the interface receives events, not one finished blob

A stepevent might say “reading the writing” while a tool runs. token events grow one answer in place, with updates grouped to one paint per animation frame. A question event draws the clarification choices.done returns the thread id that makes the next turn continuous.

Two details mattered more than they looked. Every SSE record needs its blank-line terminator or a proxy can hold the stream as one delayed blob. And status-label pacing cannot sit inside the read loop: when it did, quick tool results queued behind the animation and the answer arrived in a burst. The interface may slow a label down; it must never slow the network down.

05What actually broke

The first useful version was not the difficult part. The difficult part was keeping it useful after real conversations, cold starts, provider limits, and the model's own habits began to interact. Most of the lasting fixes were not more prompt text. They were clearer boundaries around the model.

01a tool turn broke on the next message
Gemini's hidden thought signature had been strippedpreserve the original message objects
02the voice became a collection of tics
the model kept learning from its own long transcriptkeep a coherent recent window; remove example lines
03a provider limit looked like a long hang
the adapter retried and waited out of sightbound retries and time; return a visible busy state
04a simple “i see” received invented filler
the model had nothing meaningful to generatehandle pure acknowledgements before the model
fig. 04 — recurring failures ended in code boundaries, not more prompt text

Gemini attaches an invisible thought signature to a tool call and expects the same message object back on the next turn. Rebuilding the history into cleaner objects removed it and broke the conversation. The graph now trims by selecting original messages, never by reconstructing them.

Long conversations failed differently. Kitty began borrowing phrases from its own earlier answers until a dry voice became a set of stock lines. The fix was a bounded, coherent history window and a prompt that describes the register without giving it example dialogue to copy. A separate guard checks the opening of every answer for leaked instructions before a token reaches the browser.

The strangest small failure came from messages such as “cool” and “i see”. They contain no new request, so asking a language model to improvise a response sometimes produced a grammatical non sequitur. Pure acknowledgements now take a strict path before the model and select from a few vetted closers. There is no intelligence to gain from generating what is already known.

Latency had the same lesson. During quota pressure, the model adapter retried in the background and a short failure looked like a very slow answer. Kitty now bounds retries and model time, then turns a provider limit into a visible busy state. The site also touches the service and its database while the visitor is reading, so two cold starts can wake in parallel with their attention rather than after the first question.

06Keeping it honest

The model chooses what to do, but it does not choose what counts as a fact. Project links and site routes come from generated portfolio data. Essay claims come from retrieved passages with their source route. The current page is accepted only after the service matches it against that route map. GitHub and Spotify provide the two things that are supposed to be current.

Tool routing and answer quality are tested separately. Routing can be checked mechanically: which tools ran, in what order, and whether an invented path appeared. Answers are judged against requirements for that case. Controlled failures replace GitHub, Spotify, the database, or the model with deterministic fakes, so the test suite never needs a cooperative network to prove that a bad upstream becomes a useful response.

I also decided not to add MCP for the first release. It would have made the repository look more current without making the widget more helpful. There is no external client waiting to consume these tools, and the browser already has the narrow interface it needs. If a real consumer appears, the protocol can earn its place then. Until that happens, it is another surface to maintain and explain.

The complete implementation, tests, and operating notes are in the Kitty repository. The shorter project view is on the work page.

07Closing

Kitty began as a way to make a portfolio less passive. What made it a worthwhile project was not putting a model behind a cat. It was deciding what the model should control, noticing where that control failed, and moving each hard guarantee into ordinary code.

The graph is small enough to understand in one sitting. Around it are the less glamorous parts that make it safe to leave on a public site: trusted content, durable state, a visible stream, narrow tools, admission limits, failure paths, and tests that disagree with a plausible answer when it took the wrong route. The cat is the part a visitor meets. The boundaries are the part that lets it stay there.