Decisions

What was chosen, and what it was chosen over. A decision without its rejected alternative is just a description.


D1 — One agent loop. Workflows are tools, not a layer.

Chosen: a single ToolLoopAgent. Deterministic sequences become ordinary tools that happen to be loops inside. tools/local/flows/ is empty.

Over: a workflow engine, a graph, a planner/executor split.

Why: the workflow-versus-agent distinction is about reliability, not architecture. A workflow only wins when the sequence is known and the model demonstrably gets it wrong. Building the engine first means guessing which of those sequences exist.

Reopen when: a real task fails reproducibly. Then it is one file plus one line, and agent/ does not move.


D2 — One MCP server, not one per domain.

Chosen: mcp-nextcloud covers files, calendar, contacts and Deck. mail will be separate.

Over: four servers, one per Nextcloud app.

Why: the process boundary is justified when the credential shape changes, not when the domain does. Files, calendar, contacts and Deck all ride the same Nextcloud token; mail needs IMAP, which is genuinely different. Four servers would mean four deployments and four OAuth configurations for one credential.

A related pushback, worth recording: the argument that "the agent needs a clear ACI, so split the servers" conflates two things. The ACI is about tool design — fewer, better-named tools with circulating identifiers — and is orthogonal to process count. One server with 11 coherent tools gives the model a better interface than four servers with 20 each.


D3 — Tool categories aredata andaction. There is noorchestration.

Chosen: two categories, and the boundary is where G3 fires.

Over: a third orchestration category.

Why: a tool that orchestrates other tools is a workflow in disguise, which belongs to D1. Keeping the category invites building a mini-engine.

The data/action split is not bookkeeping — it is the confused-deputy boundary, and it is expressed in MCP annotations so the policy travels with the tool rather than living in the orchestrator.


D4 — Nextcloud is the Authorization Server. No external IdP.

Chosen: Nextcloud's oidc app issues the tokens; user_oidc accepts them as Bearer on DAV/OCS.

Over: Keycloak, Zitadel or Authentik alongside Nextcloud; and over storing a per-user app password.

Why: it turned out to be more standards-compliant, not less. occ oidc:create --token_type=jwt --resource_url=<mcp url> issues an RFC 9068 JWT whose aud is the MCP server's canonical URI — the audience binding the MCP spec requires. Neither Keycloak nor Zitadel implements RFC 8707 resource indicators properly today. And sub equals the uid by construction, with no identity mapping to maintain.

What it cost: 15-minute tokens with no refresh token — see D5.

The app-password fallback remains viable if the renewal in D5 ever proves too fragile: app passwords do not expire.


D5 — Silent token renewal, because there is no refresh token.

Chosen: TokenRenewer re-runs the authorization-code flow server-side, replaying the user's session cookies on a loopback call.

Over: raising expire_time and letting the user reconnect visibly; or abandoning OIDC for app passwords.

Why: verified against the token endpoint — the response carries only access_token, expires_in (900), id_token, token_type, and discovery advertises grant_types_supported: [authorization_code, implicit]. RFC 6749 refresh does not exist here. Without renewal the agent loses every tool 15 minutes after consent.

It is silent because both things that would need a human are already settled: the Nextcloud session is live and consent is on record.

The uncomfortable part, stated plainly: replaying session cookies internally is not a standard mechanism. It stays inside that user's authority — same session, same account, triggered by their own request — and only nc_*/oc* cookies are forwarded. But it is a workaround, and it should be revisited if the oidc app ever ships refresh tokens.


D6 — Streaming and approvals in v1, not later.

Chosen: SSE end to end, useChat on the island, approvals from day one.

Over: keeping the non-streaming {text} response and adding streaming later.

Why: G3 requires tool-approval-request/-response parts, and addToolApprovalResponse only exists on AbstractChat. Deferring meant hand-writing both the stream wire protocol and the approval resume logic — more expensive to postpone than to do.

What it cost, and the lesson: the plan said to keep the non-streaming controller until the island migrated. That step was skipped, which broke a working chat mid-refactor and left a window where the app returned 500. The ordering existed for a reason.


D7 —node:sqlite, three files.

Chosen: stdlib SQLite, three separate databases.

Over: the MariaDB already in the compose file; Postgres; one combined file.

Why: stdlib on Node 26 means no dependency, no process, no port, no credential — more sovereign than a server, not less. MariaDB would have coupled the agent's lifecycle to Nextcloud's, so a Nextcloud migration could break the agent.

Three files because the lifecycles differ: a GDPR erasure of chat.db must not touch audit.db, and the audit trail has to stay independently backupable.


D8 — Two compaction rungs. No sub-agents.

Chosen: tool-result clearing always, LLM summary at ~50 % of the window.

Over: sub-agent architectures; summarising every turn.

Why: the ladder is climbed only when the rung below breaks. Rung 1 is ~5 lines and gets most of the win. Sub-agents pay off for parallel research; with this many tools in one domain the coordination cost exceeds the gain.

The summary is stored and loaded, not regenerated — a conversation pays for summarisation once per compaction event.


D9 — Memory in v1, withmemory_write annotated destructive.

Chosen: memory.db from the start, writes routed through G3.

Over: deferring memory to v2 (which was the original plan).

Why: memory is read by the model on every later turn, which makes it a persistent instruction channel. A silent write path would turn a one-shot prompt injection into a permanent backdoor. The annotation is a lie about destruction and the truth about risk.


D10 — Two tools will never exist.

Chosen: no public-link tool at all, and no way for the model to dispatch mail. Composing yes; sending and saving a draft are reachable only from a human click in the interface.

Why: these are the two actions where the confused deputy does the most damage. A public link converts read access into exfiltration in one call; a sent mail is irreversible and outbound. Both are fully within the user's rights, so RBAC does not stop them and G3 is the only thing that would — and for these two, "ask the user" is not worth the risk when "don't offer it" costs nothing.

Recorded here because they look like gaps. They are omissions.


D11 — Errors: allow-list, not deny-list.

Chosen: the island renders a server message only when the envelope carries a requestId. Everything else gets one of four generic messages.

Over: a filter that strips technical-looking strings.

Why: the filter was written first. It missed CSRF check failed. A deny-list will always miss one — and the failure mode is leaking architecture to a user, which is exactly what this was meant to prevent.

The correlation id is Nextcloud's own request id, reused rather than invented, so one string joins the UI, nextcloud.log and the orchestrator's stdout.


D12 — Rspress for these docs.

Chosen: Rspress in docs/, a separate project.

Over: Nextra (needs Next.js), Vocs (React + Vite, exactly the stated constraint — but 2.7.2 fails a plain build with Cannot find package '~icons' and depends on a Waku beta), Docusaurus (versioning, blog and i18n, none of which this needs), and plain markdown in docs/.

Why: it builds first try, renders mermaid to SVG at build time, ships search with no third-party service, and needs no Next.js. It is not inside tamebi-nc-app/frontend on purpose: that Vite config has one entry, fixed filenames and emptyOutDir: true pointing at the island bundle Nextcloud serves.

Markdown stays markdown, so if Vocs stabilises the pages migrate without a rewrite.


Mail lives in Nextcloud Mail, not in our stack

Three options for where a mail credential goes, and the middle one is what the architecture already implied.

Environment variables — one IMAP account for every user of the stack, a durable password in the orchestrator's environment, and access no longer decided by the caller's identity. Fine for a one-person demo, wrong for the product.

Read Mail's credential and speak IMAP ourselves — the PHP app decrypts oc_mail_accounts.inbound_password per request and hands it to mcp-mail, which connects to Gmail directly. Works, and it puts an IMAP client, a SASL choice (PLAIN vs XOAUTH2) and network egress into our stack for no gain.

Talk only to Nextcloud — chosen. mcp-mail calls the Tamebi PHP app with the user's OIDC bearer; the PHP side calls Mail's IMailSearch, IMailManager and IMailTransmission contracts. No IMAP client, no credential, no decryption, no egress. Switching provider is a change in Mail's settings and no code, and the app-password-versus-OAuth question never reaches us.

Mail's own REST API would have been the obvious route and it is unusable server-to-server: 412 CSRF check failed to a bearer. The coupling we accept instead is on OCA\Mail\Contracts\* — a surface Mail wrote to be consumed, but not a guaranteed-stable public API. If it moves, the mail tools stop loading, which is the same degradation as an unreachable MCP server rather than a broken chat.

The mail body is plain text

For now. HTML renders better and becomes model-written markup inside a message someone else reads, which needs a sanitiser — a lot more than a small lot. Plain text also makes the preview faithful: what the app shows is exactly what goes out.

mdocUI was considered instead of MCP Apps, and declined

mdocUI lets a model write Markdoc tags inline and renders them as components. Tempting, and rejected for this surface: it moves UI authorship into the model's prose, which is precisely where untrusted influence arrives — a poisoned document can shape a tool result, which shapes the prose. Its open_url action would take a URL from that prose, and its continue action turns model-written text into a user message. MCP Apps put authorship in a server we write, review, version and fingerprint. It is also alpha (v0.6.x, props not frozen) and would sit on the critical path of every message.

Content search is FTS5, and the vector store is a measurement away

files_grep indexes the text of documents in one SQLite file with FTS5. It could have been a vector store. The reasons it is not are worth keeping, because every one of them is a number rather than a preference.

The chunking win is structural, not embedding-based. The one peer-reviewed result isolating the mechanism (HiChunk, ACL 2026) puts +6.97 evidence recall over fixed-200 chunks on hierarchical splitting plus merging back to the parent section — and only +3.6, flat on Fact-Cov, for the hierarchy without the merge. So the merge is taken (a passage carries its heading; the parent is files_read(path, offset)). Cosine-breakpoint "semantic" chunking is not: two independent nulls against it (NAACL 2025 Findings; ECIR 2025 measured 0.303 → 0.307 nDCG@5 for 2–4× the embedding time), and it is non-deterministic. Overlap is not taken either — Chroma measured the popular 800/400 default at 1.5 precision against 7.0 for the same size with none, at equal recall.

PageIndex's headline is not a measurement of PageIndex. 98.7% on FinanceBench is vendor-run, on Mafin 2.5 — a different product built on it — against a benchmark whose own repository documents ground-truth errors, with no independent replication. It also costs LLM calls per query and $0.50–5.00 per 100-page PDF to index, and publishes nothing about permissions. Its genuinely good idea is navigating by structure, and that is a heading column here.

An ACL is a bad WHERE clause for an ANN index. pgvector applies filters after scanning, bounded by hnsw.ef_search (default 40); a user who can read 0.1% of a corpus gets ~0 rows for a LIMIT 10. This design sidesteps it entirely by keeping permissions out of the index query — but it is the reason a naive "add a vector column and filter by uid" would have failed silently.

An embedding is a copy of the document. Vec2Text recovers 92% of 32-token inputs exactly. Whatever holds vectors inherits the corpus's classification — which is why even the FTS index lives in mcp-nextcloud and not the orchestrator.

The strongest peer-reviewed number favours what was already here. Amazon (AAAI 2026) measured agentic grep-and-reread at 94.5% of RAG's faithfulness and beating it on FinanceBench (32.7% vs 24.2%). The missing piece was never a vector store; it was content-level candidate generation.

What would change the answer

FTS5 fails on exactly one thing: vocabulary mismatch. The question says "congés payés", the document says "RTT". Three remedies, in cost order — the tool description tells the model to rephrase (free, and it is what the AAAI result relies on); heading: browses structure; and only then embeddings.

So the trigger is instrumented rather than argued. The queries table records candidates / survivors / returned per call: zero candidates is a hole in the index, candidates with no survivors is a permission boundary working, and non-zero candidates the model keeps rephrasing past is vocabulary mismatch — the only failure embeddings actually fix. When that last category is common enough to count, sqlite-vec goes on the same rowids in the same file: a migration, not a rewrite. Its brute-force scan is a feature here — exact, and it pages in rank order as cheaply as FTS5, which the ef_search cliff above does not.

Note the one thing that must not be bolted on casually: hybrid BM25+dense fusion without a reranker was measured as actively harmful (0.303 → 0.289, ECIR 2025), and three of four popular rerankers made MTEB-R worse than dense alone.

Deliberately not built

Not builtAdd when
Sub-agentsa task needs genuinely parallel research
Vector search / RAGthe queries table shows FTS5 plateauing — see below
A workflow enginea specific sequence fails reproducibly
mail_* toolsthe separate IMAP server exists
Conversation-title generationthe first-message truncation proves insufficient
Hot backupsomeone needs it; node:sqlite already exposes backup
RRULE expansion, timezones in ICSrecurrence or local time actually matters — the test pins the current wrong behaviour on purpose