Carola — the conversation-first assistant
Carola is PulsN's AI assistant — and in PulsN the conversation is the home, not a
separate destination. You land on /home ready to type to her; there is no dedicated chat
app. (The old ChattN space is retired — /chattn now just redirects to /home.)
Naming: the assistant is Carola. PulsN is the app, Fusion is the stack underneath it. Keep them distinct.
Coming from React/Django? The chat itself is an ordinary streaming-chat UI. What's worth understanding is the server half — how a message turns into tool calls against your data — which is the proactive agent and AI machinery.
Where it lives
| Layer | Files |
|---|---|
| Routes | src/routes/_authed.home.tsx (the landing) · _authed.c.$threadId.tsx (/c/$id) · _authed.project.$projectKey.c.$threadId.tsx (/project/$key/c/$id) |
| Surfaces | src/components/briefing/CarolaInline.tsx (the main pane) · src/components/CarolaRail.tsx (the companion rail) |
| Header | fusion-ui ConversationHeader · src/components/chat/ConversationHeaderBar.tsx (wired) |
| Endpoint | src/routes/api/agent/chat.ts (streams Carola + the tools) |
| Server functions | src/lib/carola-threads-server.ts (load / save / list / rename / star / re-scope / delete) |
| Tables | carola_thread · chat_document + chat_document_attachment + chat_document_chunk |
Scope is a lens, not a place
Carola is one continuous thread; scope decides which world she reads, and it's a lens you change in place — never its own page:
- on
/homeshe's portfolio/general — your own data and, on request, an overview across every building you can see; - under
/project/$keyshe's scoped to that building — its tasks, protocols, news and handbook, gated by your membership.
A carola_thread is owner-private and carries that scope (+ scopeKey/scopeLabel for a
project). Its id is a short base62 code — the URL you actually see (/c/$id, never a UUID).
Opening a thread restores its exact scope, so the header follows the conversation; history
persists ChatGPT-style in the sidebar.
The conversation header
Every open conversation gets its own slim header (fusion-ui ConversationHeader), distinct
from the app header:
- left — the chat title + a chevron menu: Rename (inline), Star, Add to project (re-home the thread under a building), Delete (with an inline confirm, no modal);
- right — Files (this chat's attachments) and pop-out (floating Carola).
Rename locks the title (title_edited) so the next auto-save doesn't re-derive it from the
first message, and the sidebar entry updates with it.
Files live in the conversation
Attach files to a chat (the composer paperclip, or the Files panel) and they become
per-conversation knowledge — owner-private, Claude-style; not a project-wide
repository (WikiN is that). One file ↔ one chat through the
chat_document_attachment join, so the file rides that conversation and survives the send.
The composer stays clean — added files land in the Files panel, not as chips.
A background worker (kreuzberg) extracts →
chunks → embeds (pgvector); Carola's search_attached_documents tool retrieves the exact
passages and cites the file + page.
Three postures
Carola is the same live thread in three places:
- Inline — the conversation pane on
/homeand/c/$id(CarolaInline). - Companion — the docked right rail on other pages (
CarolaRail), an evolvedDetailPanelthat also renders conversational maps and the Files panel. - Floating — popped into a real, always-on-top OS window via Document
Picture-in-Picture (
PopOutButton→src/lib/picture-in-picture.tsx). The live subtree is relocated withcreatePortal(same thread, same in-flight stream), never copied.
Floating Carola is the one deliberate exception to the inline-only rule (no in-page overlays / modals / popovers / floating widgets): it's a separate OS window, not an in-page widget. It's Chromium-only progressive enhancement, gated by
features.floatingCarolaso classified deployments can switch it off. While floating, Files opens as a file-list view inside that window rather than the main-window rail.
How it works
A message isn't just LLM text — Carola acts on your data through owner-scoped tools the
proactive agent exposes: list_recent_findings,
list_upcoming_deadlines, search_agent_memory, remember, run_agent_check_now,
search_project_wiki (WikiN), search_project_news (NyhetN),
show_project_map, and search_attached_documents. Everything is permission-scoped to the
signed-in user; the project tools only reach buildings you're a member of.
The LLM provider is pluggable — local Ollama or a hosted model — and degrades gracefully to a deterministic demo model when none is configured, so the chat always works in the example. See AI for providers and the proactive agent for the tool-calling harness.
/** Rename a thread (locks `title_edited` so saves stop re-deriving it from the first message). */
export const updateCarolaThreadTitle = createServerFn({ method: "POST" })
.inputValidator(z.object({ id: z.string().min(1), title: z.string().min(1).max(160) }).parse)
.handler(async ({ data }) => {
const session = await requireSession();
await db
.update(carolaThread)
.set({ title: data.title.trim().slice(0, 160), titleEdited: true, updatedAt: new Date() })
.where(and(eq(carolaThread.id, data.id), eq(carolaThread.ownerId, session.user.id)));
await reindexEntity("conversation", data.id).catch(() => {});
return { ok: true };
});SSR gotcha: the chat uses fusion-ui's
<Chat>(TanStack AI'suseChat), which is client-only — rendering it during SSR throws a duplicate-ReactuseIderror. It's gated behind a mounted flag, like other browser-only UI (SSR safety).
Find vs. ask
The header search bar (Universal search) sits next to Carola with three verbs that never blur: Ask hands the query to Carola, Find returns ranked results across everything you can see, and Scope re-lenses the conversation onto a building.
Extending it
- A new tool (let Carola query a new app) → register it in the agent harness; see the proactive agent and the AI sandbox.
- A new posture or header action → the surfaces (
CarolaInline/CarolaRail) share theConversationHeader; wire the action to acarola-threads-server.tsserver function and refresh the sidebar. - A different model → swap the fusion-ai provider via env; no app code changes.