Services

Five deployables, two languages. Why they are separate is in How the pieces fit.

PathWhat it isPortHolds
orchestrator/Node/TypeScript agent loop — Vercel AI SDK, SSE, three SQLite files8090model key + two secrets. No Nextcloud credential
mcp-nextcloud/MCP server (spec 2026-07-28), stateless HTTP — files, calendar, contacts, Deck, content index8091, compose-internalthe document index. No credential at all
mcp-mail/MCP server for mail, stateless HTTP8092, compose-internalnothing. Speaks only to the Nextcloud app
tamebi-nc-app/Nextcloud PHP app tamebi + React island, bind-mounted into custom_apps/tamebithe user's session and OIDC token
tamebi-oo-plugin/Vanilla-JS OnlyOffice sdkjs plugin, bind-mounted into the documentservernothing

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.

Videoconferencing

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.

ServiceImageWhat it is
talk-signalingstrukturag/nextcloud-spreed-signaling:2.1.1the HPB. Reached at wss://TALK_HOSTNAME through Caddy
talk-janusmwalbeck/janus-gateway:1.4.1the SFU. Media on UDP 20000-20099
talk-coturncoturn/coturn:4.17-alpineSTUN/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.

Environment

Four values have no sensible default:

VariableWhat it is
AI_GATEWAY_API_KEYOne key, every provider. ORCHESTRATOR_MODEL is already in the gateway's creator/model form
TAMEBI_PROXY_SECRETProves a chat request came from the PHP app. openssl rand -hex 32
TAMEBI_APPROVAL_SECRETSigns approval responses so one binds to one tool call. openssl rand -hex 32
TAMEBI_HOOK_SECRETAuthenticates Nextcloud's file-change webhook. Without it the hook route answers 403 to everything, which is the right default
ONLYOFFICE_JWT_SECRETShared 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.

VariableWhat it is
TALK_SIGNALING_SECRETNextcloud ↔ signaling. Also pasted into Talk's admin settings
TALK_INTERNAL_SECRETsignaling's internal clients
TALK_HASH_KEY / TALK_BLOCK_KEYsign and encrypt browser session tokens. Empty means forgeable
TALK_TURN_SECRETshared with coturn. No TURN accounts exist; credentials are HMAC-derived on demand
TALK_HOSTNAMEthe signaling vhost. Must resolve and be served by the Caddyfile
PUBLIC_IPthe 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:

VariableServiceMeaning
NEXTCLOUD_URLboth MCP serverscompose-internal hostname
ALLOWED_HOSTSboth MCP serversHost header allow-list; loopback-only by default
MCP_SELF_URLmcp-nextcloudhow the document server reaches it to collect a lent file
ONLYOFFICE_INTERNAL_URLmcp-nextcloudthe document server, compose-internal
DATA_DIRmcp-nextcloudwhere index.db lives

Two names for the same server, twice

ThingBrowser-facingCompose-internal
OnlyOfficeDocumentServerUrllocalhost:8083DocumentServerInternalUrlhttp://onlyoffice
Nextcloud, seen by the agentoverwritehostlocalhost:8080NEXTCLOUD_URLhttp://nextcloud

Swapping either pair breaks things silently. → why

Setup scripts

All idempotent. Re-run after changing anything they touch.

ScriptDoes
scripts/setup.shconnects Nextcloud to the OnlyOffice document server
scripts/oidc.shmakes 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.shfile changes → the content index
scripts/theme.shapplies DESIGN.md tokens and logos
scripts/gen-background.pyregenerates the login background still

Nextcloud from the command line

Everything goes through occ inside the container:

docker exec -u www-data nextcloud-ai-nextcloud-1 php occ status
docker exec -u www-data nextcloud-ai-nextcloud-1 php occ app:enable tamebi
docker exec -u www-data nextcloud-ai-nextcloud-1 php occ user:add --display-name "…" --email … <uid>
docker exec -u www-data nextcloud-ai-nextcloud-1 php occ user:setting <uid> settings email <address>

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.

Tests

SuiteCommandCount
orchestratorcd orchestrator && npm test110, 3 skipped offline
mcp-nextcloud, unitcd mcp-nextcloud && npm test143, offline
mcp-nextcloud, integrationNC_TEST_USER=… NC_TEST_PASSWORD=… npm run test:integration24, against a live Nextcloud
mcp-mailcd mcp-mail && npm test12
React islandcd tamebi-nc-app/frontend && npm test37

mcp-nextcloud's test script lists its files explicitly — a new test file is silently skipped until it is added there.