What we built

An AI agent that works inside a company's own workspace — its files, its calendar, its mail, its project boards — and that can only ever see and do what the person talking to it could see and do themselves.

That second half is the hard part. It is easy to build an assistant that reads a drive: give it an admin token and let it index everything. What that produces is a system where every user's question is answered from every user's data, and where one prompt injection reads the payroll folder. The interesting problem is not access. It is acting on someone's behalf without ever acquiring more authority than they have.

Everything in this codebase is downstream of that sentence.

Three ideas, and why each one is not the obvious choice

1. The agent owns no credential

The service running the agent loop holds a model API key and two secrets of its own. That is the entire list. It has no Nextcloud account, no service user, no admin token, no database of who may read what.

When you ask it something, your own token travels with the request, is handed to the tool server, and is what reaches Nextcloud. Nextcloud answers 403 or 404 on its own terms, and that answer is the answer — nothing in this codebase catches it, works around it, or decides anything about permissions.

The obvious alternative — a service account plus an ACL check in our code — fails in a specific and quiet way: our copy of "who may read what" is a snapshot, and a snapshot goes stale the moment a share is revoked. Here there is no snapshot to go stale. Revoking access takes effect on the next request because there was never a second opinion about it.

The cost is real and worth naming: the agent cannot do anything the user cannot, including the convenient things. There is no "just this once, as admin".

The security model

2. Tools carry their own interface

Ask for your week and you get a week grid you can click, not a bulleted list of event titles. Search files and you get a table with real rows. Ask for a mail to be written and you get the mail, with a Send button under it.

The chat interface does not know what a calendar looks like. The tool ships its own HTML, as an MCP App, and the chat hosts it in a sandbox that has no cookies, no network, and no access to the page around it. A click inside that frame can ask for more data, through a relay that runs read-only tools only.

The alternative is a chat client that grows a special case per tool, and therefore a chat client that has to be modified every time a capability is added. Here adding a tool with a rich result touches no frontend code.

This is also where most of the security thinking went, because it means running markup and JavaScript written by a server rather than by us. A frame is distrusted at four separate points, and each one was arrived at by measuring browser behaviour rather than by reasoning about it.

MCP Apps

3. The document index holds no permissions — deliberately

To answer "where does it say the budget is forty thousand?" you need to search inside documents, and that means an index. An index is the classic place where a system like this leaks: it is built once, by something privileged, and thereafter it knows more than any single user should.

So this index has no user id anywhere in it. Not on a document, not on a passage. It cannot be asked who may read what, because it does not know and must never be taught. It ranks the whole corpus; then every candidate is put back to Nextcloud under the caller's own token, and what does not come back is never obtained.

Two consequences fall out of that shape rather than being implemented:

  • Revocation is instant. There is no permission snapshot to invalidate.
  • The path you get back is your own path. A shared file lives at a different path for each person; asking Nextcloud as you is what returns yours.

Filling it needs no credential either. Nextcloud's webhook only marks a row as stale — it carries no token — and the extraction happens on the next search by somebody who can actually open the file. A document nobody can open is never indexed at all.

Searching inside documents

What the agent will not do, on purpose

Two capabilities are absent, and their absence is a design decision that gets re-argued and re-rejected periodically:

It cannot create a public link. One call would turn read access into exfiltration, and nothing about the request would look wrong: the user is allowed to share that file.

It cannot send mail. It composes — the message appears as the mail it would be — and a person clicks Send. The two dispatch tools exist but are declared visible to the interface only, so they are structurally absent from the model's tool set. The capability exists and the agent cannot reach it.

These are not gaps waiting to be filled. They are the response to an attack the permission system cannot see: a document the user is allowed to read, containing an instruction the agent follows on their behalf. Every action would be permitted; the intent simply did not come from the user. No amount of scoping or token binding touches that, because the whole attack happens inside the user's legitimate perimeter.

The attack the guardrails exist for

What it is made of

Five services, and each one exists because it holds something the others must not:

HoldsDeliberately does not hold
the Nextcloud appthe user's session and OIDC tokenthe model key
the orchestratorthe model key, the conversation, the audit trailany Nextcloud credential
the Nextcloud tool serverthe document indexany credential — it reads one per request
the mail tool servernothingany IMAP client or mail secret
the OnlyOffice pluginnothingit runs in a different sandbox entirely

The separation is not tidiness. Each boundary is what makes a sentence above true: the orchestrator cannot leak workspace data because it never has any; the mail server cannot leak a mailbox because Nextcloud Mail owns the account and the secret; the tool server can serve every user from one process because it is stateless with respect to identity.

How the pieces fit

Where this is honest about its limits

Recurring events report their first occurrence only, and every timestamp is UTC. The content index searches words, not meaning, so a French question against an English document finds nothing. A cold index warms up over days rather than minutes, because it is filled by the searches themselves rather than by a crawler with a privileged account — which is a direct consequence of idea 1 and a cost of it.

None of these are hidden from the model: they arrive as warnings in the tool result, so the agent can say "I only see the first occurrence" instead of quietly being wrong.

Decisions for what was chosen and what it was chosen over.