Five deployables, two languages. Why they are separate is in How the pieces fit.
| Path | What it is | Port | Holds |
|---|---|---|---|
orchestrator/ | Node/TypeScript agent loop — Vercel AI SDK, SSE, three SQLite files | 8090 | model key + two secrets. No Nextcloud credential |
mcp-nextcloud/ | MCP server (spec 2026-07-28), stateless HTTP — files, calendar, contacts, Deck, content index | 8091, compose-internal | the document index. No credential at all |
mcp-mail/ | MCP server for mail, stateless HTTP | 8092, compose-internal | nothing. Speaks only to the Nextcloud app |
tamebi-nc-app/ | Nextcloud PHP app tamebi + React island, bind-mounted into custom_apps/tamebi | — | the user's session and OIDC token |
tamebi-oo-plugin/ | Vanilla-JS OnlyOffice sdkjs plugin, bind-mounted into the documentserver | — | nothing |
Supporting containers: Nextcloud, MariaDB, Redis, the OnlyOffice documentserver, and a Nextcloud cron container.
demo-video/ is the seventh directory and the only deliverable that is not a
service: a Remotion (React → MP4) demo film.
mcp-groupware/ is the retired stdio predecessor of mcp-nextcloud. Nothing
imports it. It is untracked in git, so deleting it is unrecoverable — commit it
first if it ever goes.
Nextcloud Talk, with the community high performance backend. Three containers
behind the talk compose profile — they do not start with a plain
docker compose up -d.
| Service | Image | What it is |
|---|---|---|
talk-signaling | strukturag/nextcloud-spreed-signaling:2.1.1 | the HPB. Reached at wss://TALK_HOSTNAME through Caddy |
talk-janus | mwalbeck/janus-gateway:1.4.1 | the SFU. Media on UDP 20000-20099 |
talk-coturn | coturn/coturn:4.17-alpine | STUN/TURN on 3478 (TCP+UDP), relay on UDP 20100-20149. network_mode: host |
Without the backend, Talk still works — peer-to-peer mesh, which degrades past about five participants, on the worst-connected client, and logs nothing server-side. That is the whole reason these three exist.
Talk replaced Jitsi. Jitsi had no room API at all, so a room's only protection was a secret in its URL, and its account directory (prosody) was unrelated to Nextcloud's. Talk has an OCS API with a participant list: Nextcloud decides who joins, which is the project's central invariant.
Single instance, so no NATS container: NATS_URL=nats://loopback makes the
signaling server process those messages internally. NATS only distributes
messages between several signaling instances.
Setup is not automatic. Paste https://TALK_HOSTNAME and TALK_SIGNALING_SECRET
into Nextcloud → Administration settings → Talk → Signaling server. Until that is
done Talk stays on peer-to-peer and never says so.
Media leaves outside Caddy — an HTTP proxy does not relay UDP — so GCP ingress
rules are required for udp:20000-20099, udp:20100-20149 and
tcp:3478,udp:3478. The ranges are deliberately narrow: every published port
costs a docker-proxy process, and width is firewall surface.
Four values have no sensible default:
| Variable | What it is |
|---|---|
AI_GATEWAY_API_KEY | One key, every provider. ORCHESTRATOR_MODEL is already in the gateway's creator/model form |
TAMEBI_PROXY_SECRET | Proves a chat request came from the PHP app. openssl rand -hex 32 |
TAMEBI_APPROVAL_SECRET | Signs approval responses so one binds to one tool call. openssl rand -hex 32 |
TAMEBI_HOOK_SECRET | Authenticates Nextcloud's file-change webhook. Without it the hook route answers 403 to everything, which is the right default |
ONLYOFFICE_JWT_SECRET | Shared with the document server; the same secret the Nextcloud connector uses |
The talk profile adds five more. They are interpolated as ${VAR:-} rather
than ${VAR:?} on purpose — compose interpolates the whole file before it
filters profiles, so a :? guard on a service the active profile excludes breaks
docker compose up -d for anyone not running video. The cost is that empty means
empty: the image's entrypoint sees nothing to substitute and leaves the upstream
template's public demonstration values in server.conf. The check therefore
belongs in the script that turns videoconferencing on, not in compose.
| Variable | What it is |
|---|---|
TALK_SIGNALING_SECRET | Nextcloud ↔ signaling. Also pasted into Talk's admin settings |
TALK_INTERNAL_SECRET | signaling's internal clients |
TALK_HASH_KEY / TALK_BLOCK_KEY | sign and encrypt browser session tokens. Empty means forgeable |
TALK_TURN_SECRET | shared with coturn. No TURN accounts exist; credentials are HMAC-derived on demand |
TALK_HOSTNAME | the signaling vhost. Must resolve and be served by the Caddyfile |
PUBLIC_IP | the machine's public address. On GCP the interface does not carry it, so Janus and coturn must be told, or ICE candidates are private and no video passes |
A direct provider key (GEMINI_API_KEY, OPENAI_API_KEY) takes precedence over
the gateway for that provider. Leave it empty otherwise — and note that empty
means empty: ${VAR:-} in compose passes an empty string rather than nothing.
Other variables of interest:
| Variable | Service | Meaning |
|---|---|---|
NEXTCLOUD_URL | both MCP servers | compose-internal hostname |
ALLOWED_HOSTS | both MCP servers | Host header allow-list; loopback-only by default |
MCP_SELF_URL | mcp-nextcloud | how the document server reaches it to collect a lent file |
ONLYOFFICE_INTERNAL_URL | mcp-nextcloud | the document server, compose-internal |
DATA_DIR | mcp-nextcloud | where index.db lives |
| Thing | Browser-facing | Compose-internal |
|---|---|---|
| OnlyOffice | DocumentServerUrl → localhost:8083 | DocumentServerInternalUrl → http://onlyoffice |
| Nextcloud, seen by the agent | overwritehost → localhost:8080 | NEXTCLOUD_URL → http://nextcloud |
Swapping either pair breaks things silently. → why
All idempotent. Re-run after changing anything they touch.
| Script | Does |
|---|---|
scripts/setup.sh | connects Nextcloud to the OnlyOffice document server |
scripts/oidc.sh | makes Nextcloud the agent's OIDC provider |
scripts/oidc-verify.sh <user> <pass> | proves a token authenticates the workspace and that another account stays unreachable |
scripts/webhooks.sh | file changes → the content index |
scripts/theme.sh | applies DESIGN.md tokens and logos |
scripts/gen-background.py | regenerates the login background still |
Everything goes through occ inside the container:
Setting an account's email is what publishes it into Nextcloud's system address book, which is where the agent finds a colleague's address instead of inventing one. Nothing fails without it; the agent simply cannot resolve people.
| Suite | Command | Count |
|---|---|---|
| orchestrator | cd orchestrator && npm test | 110, 3 skipped offline |
mcp-nextcloud, unit | cd mcp-nextcloud && npm test | 143, offline |
mcp-nextcloud, integration | NC_TEST_USER=… NC_TEST_PASSWORD=… npm run test:integration | 24, against a live Nextcloud |
mcp-mail | cd mcp-mail && npm test | 12 |
| React island | cd tamebi-nc-app/frontend && npm test | 37 |
mcp-nextcloud's test script lists its files explicitly — a new test file is
silently skipped until it is added there.