Goal: change the chat surface without discovering the four Nextcloud constraints one crash at a time.
The chat is a React root mounted inside a page Nextcloud rendered — not an SPA, not an iframe. Layout, build output and file locations are in Reference: frontend.
The app directory is bind-mounted into Nextcloud, so a rebuilt bundle is live on reload. Two exceptions where the bind mount is not enough:
docker compose restart nextcloud. Annotations are opcache'd, and you will
otherwise debug code that is not the code running.docker compose restart onlyoffice, and
its .gz siblings are what actually get served — regenerate them.Register assets from PHP, never from the template. Nextcloud's CSP pairs a
nonce with strict-dynamic, so a hand-written <script src> in a template is
blocked outright. Assets are declared in the controller.
Build filenames are fixed, with no content hash, so the template can link them by name; Nextcloud's own cachebuster handles invalidation. This is also why the documentation site is a separate Vite project — a second entry point would fight that config.
Server-side values arrive as data attributes on the mount node. URL generation stays server-side, so a subdirectory install keeps working and escaping happens once.
Utilities need !important. The CSS entry uses @import "tailwindcss" important; — Nextcloud's own stylesheets are specific enough to win otherwise.
The hook does not re-expose the method that answers an approval — its public surface stops at sending, regenerating, stopping and reading messages. Answering an approval is the entire point of the confirmation mechanism, so the instance is kept and used directly.
Two related traps, both silent:
ai version must match the one @ai-sdk/react bundles. A nested copy in
node_modules means they have drifted, and approvals are discarded without an
error.Read the CSRF token at call time, not once at startup: Nextcloud rotates it.
A message is a list of parts, not a string. Text parts render as markdown; tool parts render as a trace — with exactly one exception:
An unanswered approval is the only thing the user must act on. Everything else is trace they can ignore.
MCP tools arrive as dynamic-tool parts, not tool-<name> — they are not
statically known to the client. Match both, or your new tool renders as nothing.
The trace maps tool names to short human sentences — "Reading a file", "Adding a calendar event" — and shows the single argument worth showing. An unknown tool falls back to its name with underscores replaced, so a new capability is readable before anyone writes a label for it.
Check the AI Elements registry before writing one. Streaming states, message shapes and tool displays are already there, and the catalogue is authoritative against the registry rather than from memory:
Then restyle it with the design tokens rather than accepting the default look. Do not fork a component to change its appearance, and do not hand-roll a chat input, a message list, a streaming indicator or a tool-call display — every one of those is in the catalogue.
This is the part most likely to be undone by accident.
The island renders a server-supplied message only when the error envelope carries a request id, which is the signature of an error this project produced. Everything else gets a message from a small map.
The first version was a deny-list — a regex hunting for technical-looking strings —
and it missed CSRF check failed, which Nextcloud's own middleware returns before
our controller ever runs. A deny-list will always miss one.
What it replaced said: "The orchestrator answered 412. Try again, or check that the orchestrator service is running." Two failures in one sentence — it named a service the user has never heard of, and it was wrong, because 412 is Nextcloud rejecting a stale token.
The reference id is shown, not hidden. It is the only way a user can help find the matching log line, and it identifies a request, not a person.
DESIGN.md is the single source for colours, type, spacing, radii and motion.
Every token is a CSS variable in the one entry file where Tailwind is configured,
consumed only through Tailwind classes — bg-surface-dark, text-on-dark-soft,
font-display.
No hardcoded values, no Tailwind default palette, no component defining its own
colours. A genuinely missing value is added to DESIGN.md first.
Logic that can be tested without a DOM lives in src/lib/. For anything visual,
run the app — a screenshot is worth more than an assertion about a class name.