Cassini ships as a Nextcloud AppAPI external app. One container exposes the admin operator surface, the meeting archive, the viewer and the Talk recording backend over a single HTTP port, and Nextcloud’s AppAPI proxy fronts it with per-route access enforcement.
The flow is deliberately two-phase and reversible. First you install and verify the app, with Talk untouched. Then you hand Talk’s recording over to Cassini, having backed up what was there before.
Requirements
- Nextcloud 33.0.9 to 35 with AppAPI and a HaRP deploy daemon.
- Talk with the High-performance backend (standalone signalling). Cassini joins
calls as an internal signalling client, so it needs the signalling server’s
internalsecret. This is the one value you have to supply by hand. - Nextcloud Files sharing enabled. Cassini shares each recording with the people, groups and Teams in its Talk room. No Team folders or Everyone Group app is needed.
- Optional: an NVIDIA GPU with the Container Toolkit (x86_64) for faster transcription. CPU is the default and runs on amd64 and arm64.
Cassini’s manifest declares Nextcloud 33.0.9 as its minimum and 35 as its maximum, and CI tests it against Nextcloud 33, 34 and 35.
The five steps
- Install Cassini from the
App Store. In the deploy
options, paste your signalling server’s
internalsecret. Every other option can stay empty. - Open Cassini as an administrator. It creates the
cassiniservice account that owns the meeting archive. - Check recording sharing, under Operator › Settings › Publish pipeline.
- Point Talk at Cassini. Back up Talk’s current
recording_serversvalue, then apply the one Cassini generates for you. - Record a test call in a private room and watch it arrive in Cassini.
Each step is below in full, with its commands and its checks.
All occ … commands are shorthand for however your deployment invokes occ (for
example sudo -u www-data php occ … or
docker exec -u www-data <nc-container> php occ …).
Step 1 — Install Cassini from the App Store
Install Cassini from Nextcloud’s app store, on a deploy daemon (below). In
the deploy options, paste your signalling server’s internalsecret as
CASSINI_TALK_SIGNALING_INTERNAL_SECRET. Every other option can stay empty:
the Talk recording secret is generated on first start and read back in Step 4,
and the rest have working defaults.
Persistent storage is automatic: AppAPI creates a named volume for every docker-deployed external app, and the operator stores all durable data under it.
The deploy daemon (HaRP)
AppAPI needs a registered deploy daemon before it can install any external app. Use a HaRP daemon: upstream AppAPI recommends it, and the older Docker Socket Proxy daemon is deprecated and scheduled for removal in Nextcloud 35.
Run HaRP next to a Docker engine (full options in the HaRP README):
docker run \
-e HP_SHARED_KEY="<generate-a-strong-ascii-key>" \
-e NC_INSTANCE_URL="https://cloud.example.com" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$(pwd)"/certs:/certs \
--name appapi-harp -h appapi-harp \
--restart unless-stopped \
-p 8780:8780 -p 8782:8782 \
-d ghcr.io/nextcloud/nextcloud-appapi-harp:release
Then register it in Nextcloud → Administration settings → AppAPI → Register
Daemon (template “HaRP Proxy”), and run Test deploy from the daemon’s
three-dot menu before going further. occ app_api:daemon:list should show it
afterwards.
Route /exapps/* to HaRP at your reverse proxy
Required for every HaRP daemon, local or remote. AppAPI does not talk to a HaRP-hosted app over an internal address — it builds a public URL and dials it:
GET https://cloud.example.com/exapps/<appid>/heartbeat
Your TLS terminator must send /exapps/* to HaRP’s 8780, not to Nextcloud.
Without this route the request reaches Nextcloud, which 502s, and install/enable
never completes.
Caddy:
handle /exapps/* {
reverse_proxy 127.0.0.1:8780 # HaRP
}
handle {
reverse_proxy 127.0.0.1:11000 { # Nextcloud
header_up Host {host}
}
}
handle, not handle_path: handle_path strips the matched prefix, and
HaRP routes on /exapps/<appid>/…. The nginx equivalent is
location /exapps/ { … } with a proxy_pass that has no trailing path
element, so the prefix survives.
The failure is indirect and easy to misdiagnose: the app reports [enabled] in
occ app_api:app:list but its navigation icon never appears, because Cassini
registers its navigation entries only after receiving PUT /enabled?enabled=1 —
a callback that never arrives.
Do not test this with an unauthenticated curl https://…/exapps/…. It returns
502 whether the route is right or wrong. Verify with occ plus nextcloud.log,
and by looking for PUT /enabled in the container log.
Finding the signalling internal secret
CASSINI_TALK_SIGNALING_INTERNAL_SECRET must equal your Talk signalling / HPB
server’s [clients] internalsecret. It is the one value Cassini cannot
self-provision: the invisible HPB-internal recorder authenticates to the
signalling server with this shared secret, and no API exposes it, so you supply
it once.
- Nextcloud All-in-One:
docker exec nextcloud-aio-talk printenv INTERNAL_SECRET - Standalone signalling server: the
internalsecretunder[clients]in the signalling server’s config, for exampleserver.conf. If you are setting signalling up at the same time, generate the value once and put it in both places.
If it is missing, the operator logs
WARNING: talk_signaling_internal_secret_set -> false: … at startup, and
GET /operator/status returns "signaling_internal_secret_configured": false
with a hint. Recording stays disabled until it is set.
This is not the same as the Talk recording secret, which Cassini generates
and persists for itself. CASSINI_TALK_RECORDING_SECRET authenticates Talk’s
recording-backend HTTP protocol; the signalling secret authenticates Cassini as
an internal signalling client for call capture. Supply a recording secret
explicitly only when you want to manage it out of band; an explicit value always
wins.
Which image AppAPI pulls
CI publishes to ghcr.io/codemyriad/gocassini:
| Tag | What it is |
|---|---|
X.Y.Z | Multi-arch portable image (linux/amd64, linux/arm64). Immutable by convention; matches <version>/<image-tag> in the manifest. It records without a GPU and runs local CPU transcription with a bundled int8 Parakeet model, on x86_64 and 64-bit ARM servers. |
X.Y.Z-cuda | CUDA release build for x86_64 (CUDA 12 / cuDNN 9 sherpa-onnx, fp32 Parakeet model, CASSINI_STT_DEVICE=cuda) |
X.Y.Z-rocm | Alias of the CPU build so ROCm-tagged daemons install; no ROCm acceleration yet |
sha-<shortsha> / sha-<shortsha>-cuda | Every pushed commit, for pinning a specific build |
latest / latest-cuda / latest-rocm | Convenience tags — fine for demos, not for production installs |
appinfo/info.xml pins <image-tag> to the release version, so a default AppAPI
install (and any later reinstall) pulls exactly the build the registered app
version was cut from, instead of whatever latest points at that day.
You do not select the -cuda tag by hand. When the deploy daemon’s compute
device is CUDA, AppAPI tries <image-tag>-cuda first and falls back to the plain
tag. Cassini detects that fallback: the plain image remains available for
capture, the status endpoint reports CUDA unavailable, and build jobs enter
build/blocked with instructions to install the matching -cuda image, instead
of decoding on the CPU.
The rest of the deploy options
These variables are declared under <environment-variables> in
appinfo/info.xml. That declaration is what makes them settable at all: AppAPI
only passes declared variables to the container, and values for undeclared keys
are silently dropped. Set them at install time, in the External Apps admin
UI (Deploy Options) or with --env on the command line.
| Variable | Required | What it does |
|---|---|---|
CASSINI_TALK_SIGNALING_INTERNAL_SECRET | Yes | Internal client secret for standalone Talk signalling / HPB; must match [clients] internalsecret. Required for private, group and one-to-one recording |
CASSINI_TALK_RECORDING_SECRET | No (auto-generated) | Shared secret for Talk’s recording backend protocol; must match the secret in spreed’s recording_servers (Step 4). If omitted the operator generates and persists one. An explicit value wins and is treated as externally managed |
CASSINI_TALK_BACKEND_URL | No | Override for operator→Talk callbacks. Leave empty to use the backend URL Talk sends with each request |
CASSINI_NC_ADMIN_USER | On instances where no discovered account is an administrator | The account Cassini acts as when it checks how the instance is set up. That check creates nothing. Recordings are still owned, written and managed by cassini |
CASSINI_PUBLISH_SINK | No | Where published recordings are stored. nextcloud-files (the default for an installed app) puts them in Nextcloud Files; local keeps them on the app’s own volume. Set local only deliberately |
CASSINI_STT_BACKEND | No | Which speech-to-text engine transcription uses; empty selects the default (sherpa-onnx). An unknown value fails the build loudly before any audio is decoded |
CASSINI_DISALLOW_MODEL_DOWNLOAD | No | Set 1 on a host with no outbound network access. Each image bundles the model of its default quality tier; another tier downloads once into the model cache on the persistent volume. With this set, a build whose tier needs that download is blocked with a message naming the missing model |
CASSINI_ROOM_ID_PEPPER | No (recommended) | Deployment-wide secret mixed into the one-way derivation of each meeting’s room id, so the Talk conversation token it comes from cannot be recovered by enumeration. Choose it once: changing it changes every room id, and meetings already published keep the ids they were written with |
LLM_BASE_URL, LLM_MODEL, OPENROUTER_API_KEY | No | Seed the app’s AI settings on first start only; afterwards endpoints are managed in the app. See AI providers |
CASSINI_OPERATOR_API_TOKEN | No | Bearer token for direct, non-AppAPI operator API calls. Requests proxied through Nextcloud are authenticated by AppAPI |
The full set, including the attribution and retention knobs and everything AppAPI
injects on its own, is in
docs/exapp-install.md
and
docs/exapp-talk-env-vars.md.
Installing from the command line instead
occ app_api:app:register takes the same options. --info-xml accepts a local
path or a raw URL; pin a tag or commit SHA, not a moving branch.
SIGNALING_INTERNAL_SECRET="<the value found above>"
curl -fsSL "https://raw.githubusercontent.com/codemyriad/gocassini/<tag-or-sha>/appinfo/info.xml" \
-o /tmp/gocassini-info.xml
occ app_api:app:register gocassini <daemon-name> \
--info-xml /tmp/gocassini-info.xml \
--env CASSINI_TALK_SIGNALING_INTERNAL_SECRET="${SIGNALING_INTERNAL_SECRET}" \
--wait-finish
If occ runs inside a container, copy the manifest in first
(docker cp /tmp/gocassini-info.xml <nc-container>:/tmp/) or pass the raw URL
straight to --info-xml.
<daemon-name> is the Name column of occ app_api:daemon:list. AppAPI pulls
the image, creates the persistent volume, starts the container
(nc_app_gocassini), and enables the app once the container answers its
heartbeat and reports init completion.
To install a build other than the pinned release, download appinfo/info.xml,
set <image-tag> to the sha-… or release tag you want, and register from that
local copy.
Changing deploy options after install
AppAPI deploy environment is container-creation-time configuration, not live
Nextcloud app config. Changing Talk’s spreed.recording_servers.secret does
not update CASSINI_TALK_RECORDING_SECRET in a deployed container, and
changing the signalling internalsecret does not update its variable either.
For secret rotation, recreate or redeploy the app with all required values while
preserving the AppAPI persistent volume. app_api:app:update reuses stored
deploy options and has no --env flag.
Because deploy environment is creation-time only, a release that adds a new required environment variable cannot be delivered by the admin UI’s Update button — it is a breaking change that needs a redeploy.
Step 2 — Open Cassini as an administrator
Open Cassini from the Nextcloud app menu, signed in as an administrator. It
creates the cassini service account that owns the meeting archive: every
recording is written as that account and shared from it.
Cassini tries to create the account and its cassini group itself when the app
is enabled, which works on releases that let an external app write to user
administration. Nextcloud 34.0.2 and later refuse that request, and then Cassini
creates it from your browser instead: press Create the account and start, and
it makes the account as you, after Nextcloud’s own password prompt. By hand
instead:
occ group:add cassini
occ user:add --group=cassini cassini
To check how the instance is set up — which apps are enabled, whether the
cassini account exists — Cassini also acts as
an administrator account it discovers. That check is read-only, and the archive
itself is never touched as an administrator. In almost every case this needs no
configuration; set CASSINI_NC_ADMIN_USER only when discovery cannot find an
administrator or picks the wrong account.
Step 3 — Check recording sharing
Open Operator › Settings › Publish pipeline › Who can see recordings. Once the cassini
account exists and Nextcloud file sharing works, the section has nothing to
report. Otherwise it says what is missing, and offers Create recordings
account or Check again.
There is nothing to choose. Every recording is kept in the cassini account’s
private CassiniRecordings/meetings folder and shared with the people, groups
and Teams in its Talk room, including invited people who did not join. Guests
without an account get no share, and Cassini never creates a public link. Full
detail is in Who can see a recording.
Step 4 — Point Talk at Cassini
Check the install first
All of these must pass before the handoff.
-
occ app_api:daemon:listshows the daemon and its Test deploy passes. -
occ app_api:app:listshowsgocassinienabled. -
The Nextcloud app menu shows a Cassini entry for every logged-in user. If it is missing, check the container log for
exapp ui:errors, then disable and re-enable the app to retry the registration. -
The container runs the intended image:
docker inspect nc_app_gocassini --format '{{.Config.Image}}'. -
The Talk welcome endpoint answers through the AppAPI proxy (a PUBLIC route, so plain curl works):
curl -fsS https://cloud.example.com/index.php/apps/app_api/proxy/gocassini/api/v1/welcome # → {"version":1} -
The status endpoint reports
"ok": true. It is an ADMIN route, so use an admin login with an app password:curl -fsS -u admin:<app-password> \ https://cloud.example.com/index.php/apps/app_api/proxy/gocassini/operator/statusIt reports the app version, the transcription device (
cpu/cuda) and whether it is actually usable, whether the Talk recording secret and the signalling internal secret are configured (never the values), and DB and storage health. The Talk fields should look like:{ "talk": { "secret_configured": true, "signaling_internal_secret_configured": true, "backend_url_override_configured": false, "secret_source": "generated", "recording_backend_url": "https://cloud.example.com/index.php/apps/app_api/proxy/gocassini" } }secret_sourceisgeneratedwhen the operator made the recording secret itself, orenvwhen you supplied one.recording_backend_urlis the value to register below — never the secret itself, which comes from the provisioning endpoint. -
CUDA installs only: the image tag ends in
-cudaand the container can see the GPU (docker exec nc_app_gocassini nvidia-smi). The status endpoint must show"device": "cuda"with"device_usable": true.
The handoff
Point Talk’s recording backend at the AppAPI proxy base. The api/v1/welcome and
api/v1/room/* routes are declared PUBLIC in the manifest, so Talk’s recording
protocol — authenticated by its own HMAC, not a Nextcloud session — passes
through the proxy.
Talk has no API for an app to register itself as the recording backend, so this
one admin step stays manual. It is secret-free: the operator’s ADMIN-only
provisioning endpoint returns the ready-to-apply recording_servers value,
including the self-generated secret.
Back up the current backend first, then switch:
# 0. Back up (empty output = no recording backend configured)
occ config:app:get spreed recording_servers | tee /root/recording_servers.backup
# 1. Pull the ready-to-apply value from Cassini and register it in one step.
RS="$(curl -fsS -u admin:<app-password> \
https://cloud.example.com/index.php/apps/app_api/proxy/gocassini/operator/talk/provisioning \
| jq -c '.recording_servers')"
occ config:app:set spreed recording_servers --value="$RS"
occ config:app:set spreed call_recording --value=yes
If you supplied CASSINI_TALK_RECORDING_SECRET yourself in Step 1, you can set
recording_servers directly with that secret and the recording_backend_url
from the status endpoint.
Rollback
Restore the saved value and Talk records through the previous backend again; the Cassini app can stay installed.
occ config:app:set spreed recording_servers --value="$(cat /root/recording_servers.backup)"
# or, if there was no recording backend before:
occ config:app:delete spreed recording_servers
Keep the previous backend running until your test recording passes.
Secret rotation
Rotate secrets as a coordinated operation; do not change only one side.
For the Talk recording secret: pause recordings, update
spreed.recording_servers.secret, redeploy Cassini with the same value as
CASSINI_TALK_RECORDING_SECRET, confirm /operator/status reports
secret_configured: true, and run a controlled recording.
For the signalling internal secret: update the signalling server’s
[clients] internalsecret and restart it, redeploy Cassini with the same value
as CASSINI_TALK_SIGNALING_INTERNAL_SECRET, confirm /operator/status reports
signaling_internal_secret_configured: true, and run a private, group or
one-to-one recording.
Step 5 — Record a test call
Use a non-critical private, group or one-to-one conversation, so the signalling-internal path is exercised.
- Pick a test conversation with at least one speaking participant.
- Start recording from Talk’s Record button.
- Confirm a Cassini job appears in the app’s Operator surface.
- Speak for a minute, then stop the recording or leave the call.
- Watch the job progress through record → build → seal → publish. Talk receives
started and stopped status per its recording-backend protocol and nothing
else; the meeting itself is published as a portable
.opusinto Nextcloud Files. - Run a second recording and confirm both transcripts stay visible.
GPU transcription
Set the deploy daemon’s Compute device to CUDA, and AppAPI pulls
<image-tag>-cuda and attaches the host’s NVIDIA GPUs to the container. The
Docker engine running the app needs the NVIDIA driver and the
NVIDIA Container Toolkit;
verify with
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi on
that engine before registering the app.
There is no CPU fallback from a CUDA install: a plain image under a CUDA daemon
is a permanent mismatch, so recording finishes and the build enters
build/blocked with an actionable message. Install the matching -cuda image,
then use Rerun to process the recording that was preserved.
Choosing between the two, and what changes in the transcript, is in CPU or GPU.
Persistent storage
AppAPI’s docker deploy creates a named volume (nc_app_gocassini_data), mounts
it at /nc_app_gocassini_data, and exposes that path as
APP_PERSISTENT_STORAGE. The operator stores all durable data under it:
$APP_PERSISTENT_STORAGE/operator/jobs.sqlite3 # SQLite job DB
$APP_PERSISTENT_STORAGE/operator/app-state.json # AppAPI lifecycle state
$APP_PERSISTENT_STORAGE/operator/jobs # per-attempt artifacts
No manual volume mounts are required — job history and recordings survive app updates and container recreates. The container logs a warning at startup when an effective data path sits on an ephemeral filesystem.
Uninstall
Restore Talk’s previous recording backend first (the rollback command in Step 4), then:
occ app_api:app:unregister gocassini # keeps the data volume
occ app_api:app:unregister gocassini --rm-data # also deletes recordings + job history
Recordings already published into Nextcloud Files are unaffected either way; they are ordinary Nextcloud files from the moment they are written.