What was chosen, and what it was chosen over. A decision without its rejected alternative is just a description.
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.
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.
data 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.
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.
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.
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.
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.
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.
memory_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.
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.
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.
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.
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.
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 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.
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.
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.
| Not built | Add when |
|---|---|
| Sub-agents | a task needs genuinely parallel research |
| Vector search / RAG | the queries table shows FTS5 plateauing — see below |
| A workflow engine | a specific sequence fails reproducibly |
mail_* tools | the separate IMAP server exists |
| Conversation-title generation | the first-message truncation proves insufficient |
| Hot backup | someone needs it; node:sqlite already exposes backup |
| RRULE expansion, timezones in ICS | recurrence or local time actually matters — the test pins the current wrong behaviour on purpose |