Last week /paper finished. This week I closed the gap between a stack that only I can run and one anyone can, and built the last two batches of chat features I had parked while the agent took all my attention.
Three of the four services had no Dockerfile: the chat backend, the browser app, and the Python paper service. They do now, along with a compose file at the repo root that brings the whole stack up together, and a script for running it without Docker when I want hot reload.
Two real bugs fell out, which is the whole reason containerising is worth it. sse-starlette was imported by the paper service but never declared as a dependency, so it had been resolving by accident through another package. And my check that the PDF logo ships inside the built wheel was giving a false pass, because uv sync installs editable by default, so the file was being found on disk rather than in the package. With all four containers on one network I ran a real streaming chat turn, and a full /paper run that produced a seven page PDF in 56 seconds.
That compose file is not how this ships, though. In production it lives inside CDLI’s own framework as a submodule, behind their nginx, so I wrote that out as a spec too, and doing it on paper caught two things that would have broken the moment it went live. The framework’s nginx sets proxy_buffering off nowhere, so an SSE stream would arrive in one lump at the very end and every answer would look frozen until it was already finished. And there is no read timeout override, so the default of 60 seconds would kill every /paper run. That second one is very likely the same timeout behind the CQP multi word 504s I have been shrugging at since Phase 1.
The backend has resolved CDLI identities since Phase E, and I had tested that half. But the browser app never sent an Authorization header at all, so no matter what the framework did, a signed in CDLI user would always land on the anonymous path with their own key. The gap was in neither half’s tests, because each half was correct on its own.
Fixed by fetching a token, forwarding it as a bearer, and refreshing once on a 401, since the token lives about fifteen minutes. Every failure path falls back to anonymous rather than erroring: a broken identity service should mean “bring your own key”, not “no chat”. The provider picker locks out on the funded path, because the backend ignores what you pick there, and a live picker would be quietly lying to you. The routing spec had a bug too, raised from the framework side: location ^~ /chat/ swallows the token route and hands it to the static file server, which answers 405, so CakePHP would never see the request.
This was the batch of chat features I had been deferring since /paper started. Stop, regenerate, edit and resend, copy, retry, starter prompts, transliteration rendered with raised determinatives and subscript indices, CDLI branding, the signed in account name, and per turn metadata showing which model and which tools ran.
Two things I am glad I did rather than the obvious version. First, send, regenerate, edit and retry all go through one shared function, so they cannot drift apart into four slightly different behaviours. Second, stop genuinely stops: I checked the backend log and saw it abandon the provider call three seconds in, so it is not just hiding output you have already paid for. Artifact cards render under an answer that cites tablets, built only from what the tools returned and never from what the model wrote, which is what stops an invented P number turning into a real looking CDLI link.
One CSS bug bit me three separate times here, capturing the stop button, then a modal’s danger button, then the slash menu rows. Patching it twice with ever more specific selectors is exactly why there was a third time.
The model ids in the picker were mostly guessed from marketing names, and mostly wrong: some namespaced, some preview only, one whole version line that does not exist, and a default with an announced shutdown date. I went through every provider’s published list and probed the ambiguous ones live, with a deliberately nonsense id as a control so a lenient API could not fake a pass.
Two silent PDF defects too. The logo had never once embedded, because the library drops a file:// image and still reports success, so I only found it by looking at output I had assumed was fine. And every subscript rendered as a black square, because only the base fonts were embedded and none of them have ₂ or ṣ.
/search, /artifact and /cqp in the chat box, with a keyboard navigable menu that completes as you type /. They add guidance to the system prompt rather than routing around the agent, so the model still decides what to call, it just knows what you were after. The bias is real on ambiguous input: the word “lugal” on its own goes to a normal search, and to the corpus query tool under /cqp.
The MCP Apps widgets had a flaw I had not thought through. MCP Apps binds a UI to a tool, not to a call, so a search tool carrying a widget renders one on every call, including all the intermediate searches a model makes while it is still working something out. You end up with five card grids and one answer.
So display is now split from retrieval. show_artifact_cards and show_inscription are display only tools the model calls once, at the end, with what it has decided to present. I also replaced the placeholder AI disclosure wording in the PDF footer, capped the card grid at eight, and gave the app CDLI’s own favicon.
Twelve weeks. A public MCP server over the CDLI catalogue with search, metadata, inscriptions, bibliography and corpus queries. A chat interface built on it, with your own key or a CDLI funded one, streaming, slash commands and artifact cards. And /paper, which researches a topic against the real catalogue and writes a cited note you can download as a PDF.
What is left is deploying the whole stack on CDLI’s own infrastructure, which is a conversation with the framework repo rather than more code here. Everything it needs is containerised and written down.
| # | Day | Date | A short description of the work done |
|---|---|---|---|
| 1 | Monday | 2026/08/10 | Containerized the chat backend, browser app and paper service, added a root compose file, and verified a real chat turn and a full /paper run across all four containers #29 |
| 2 | Tuesday | 2026/08/11 | Wrote the production deployment spec against CDLI’s framework, catching that its nginx would buffer every stream and kill every /paper run at 60 seconds #29 |
| 3 | Wednesday | 2026/08/12 | Made the funded tier actually reachable from the browser, fixed the token route being swallowed by the SPA prefix, and stopped provider errors showing as [object Object] #30 |
| 4 | Thursday | 2026/08/13 | Built the deferred chat features behind one shared turn function: stop, regenerate, edit and resend, copy, retry, transliteration rendering and artifact cards #31 |
| 5 | Friday | 2026/08/14 | Verified every model id against its provider’s published list, fixed the two silent PDF defects behind a missing logo and unreadable subscripts, and made a bad ranking response survivable instead of fatal #32 |
| 6 | Saturday | 2026/08/15 | Added the /search, /artifact and /cqp slash commands with a completion menu, biasing the model rather than bypassing it #33 |
| 7 | Sunday | 2026/08/16 | Split display tools from retrieval so a widget renders once at the end, finalised the PDF disclosure wording, and set up the password protected demo VM #34 |