Operational runbook
The practical admin and debugging tasks around Atrium Actions and the runtime.
Endpoints that matter
| Surface | URL |
|---|---|
| Atrium UI | https://atrium.ommax-intelligence.de |
| Atrium API | https://atrium.ommax-intelligence.de/api/* |
| Atrium MCP (JSON-RPC) | https://atrium.ommax-intelligence.de/api/mcp |
| Runtime webhook endpoints | https://atrium.ommax-intelligence.de/actions/* |
| Runtime health | https://atrium.ommax-intelligence.de/actions/healthz |
| Zitadel Console | https://loginportal.ommax.de |
| Build / commit probe | https://atrium.ommax-intelligence.de/api/health (returns parentSha + commitTime of the running container — parentSha is the parent of the deployed commit, commitTime is the deployed commit's authoring time) |
Operational env vars
Set on the atrium Dokploy app (the web container). Sensitive values are NOT in this doc — see ~/.config/atrium/ and ~/.config/dokploy/secrets/ on the operator's box.
| Var | Values | Effect |
|---|---|---|
ATRIUM_RECONCILE_CRON | off (default) / dryRun / apply | Schedules the groups reconciler. dryRun logs diff counts only; apply writes Zitadel mutations. Provenance gate (see groups) protects direct-admin grants in either mode. |
ATRIUM_RECONCILE_CRON_INTERVAL_MS | number, default 6h | How often the cron sweep runs. |
ATRIUM_BROADCAST_NOTIFICATIONS | admins_only (default) / super_admins_only / everyone / off | Gates the broadcast email kinds (GROUP_ADDED, APP_SHARED_WITH_GROUP). Transactional kinds (ACCESS_REQUEST_*, ROLE_GRANTED) always send. |
ATRIUM_EMAIL_BCC | comma- or semicolon-separated email list | If set, every successful Plunk send fires a parallel send to each BCC address with [bcc] subject prefix. Doubles Plunk credit per send — turn off after the rollout window. |
ATRIUM_ACTIONS_RUNTIME_RECONCILE | on (default) / off | Periodic re-push of ACTIVE scripts to the runtime registry, plus eviction of stale registrations. Atrium IS the source of truth for the runtime registry; this is the resync mechanism. |
ATRIUM_ACTIONS_RUNTIME_RECONCILE_INTERVAL_MS | number, default 5min | How often the registry resync runs. |
ATRIUM_RUNTIME_API_KEY | string | Shared secret the actions runtime uses to call back into Atrium (/api/runtime/*). Required for ctx.atrium.* primitives in scripts. |
LITELLM_URL + LITELLM_API_KEY | URL + key | LiteLLM gateway powering the AI agent and /ask. Without these, both surfaces 503. |
PLUNK_SECRET_KEY (or legacy PLUNK_API_KEY) | string | Deprecated as a runtime var. Email is now configured in Org settings → Integrations → Email (encrypted in the DB); the send path never reads env. These vars are consumed once by the seed's migration into the integration row on first deploy, then are inert. With no row and no env, email is unconfigured and sends fail gracefully (notifications are best-effort; the primary operation still succeeds). |
Before debugging a script
Check these in order:
- Is the script
ACTIVE? - Does another
ACTIVEscript already own the same exact trigger slot? - Does the matching ZITADEL Execution actually point at the intended Target?
- Does the runtime list the script in
/internal/scripts? - Do recent payloads exist for the exact trigger name you chose?
Slot conflicts
Atrium only routes one ACTIVE script per exact trigger slot.
Examples:
FUNCTION:preuserinfoFUNCTION:preaccesstokenEVENT:user.human.added
If activation fails with a slot-ownership error, either:
- disable the current live owner
- or compose the missing behaviour into the current owner
Debug a script that "does nothing"
- Open
/admin/actions - Open the script
- Check Recent runs
- Check Recorded payloads
- Compare the script's exact trigger to the payload's exact trigger
Most common causes:
- wrong trigger name (
session.addedvsoidc_session.added) - claim logic placed on an Event instead of a Function
- expecting custom metadata in a session event payload
Test-run safety
Test-run is not a fully fake environment.
What stays local:
- the displayed return value
- the captured
ctx.logoutput
What can still be real:
ctx.fetch(...)ctx.atrium.groups.*ctx.zitadel.users.*
Use:
- placeholder secrets by default
- sample users
- guard clauses
- or deliberately safe payloads
when iterating on mutating scripts.
Recorded payloads
Recent payloads are stored as:
- structure-preserving
- sanitized examples
- last 10 per exact trigger
They are useful for:
- matching the real shape
- seeing which events actually occur in OMMAX
- seeding test payloads without copying raw production bodies
Add a new ZITADEL Target
TOKEN="atr_live_..."
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
https://atrium.ommax-intelligence.de/api/admin/zitadel/targets \
-d '{
"name": "atrium-actions-functions-preaccesstoken",
"endpoint": "https://atrium.ommax-intelligence.de/actions/functions/preaccesstoken",
"interruptOnError": false
}'
Persist the returned signing key immediately.
Wire or replace an Execution
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
https://atrium.ommax-intelligence.de/api/admin/zitadel/executions \
-d '{
"condition": { "function": { "name": "preuserinfo" } },
"targetIds": ["<target-id>"]
}'
To remove the routing, pass:
{ "targetIds": [] }
Rotate a Target signing key
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
https://atrium.ommax-intelligence.de/api/admin/zitadel/targets/<target-id>/rotate-key
Then:
- add the new key to
ZITADEL_WEBHOOK_SIGNING_KEYS - keep old + new during the cutover window
- redeploy the runtime
- remove the old key after traffic is clean
Push a schema change
- Edit schema.prisma
- Run
cd src && npx prisma generate - Commit and deploy
- Let container startup apply the additive
db push
For destructive changes, do a real migration instead of relying on startup push.
Verify the running deploy
Don't trust Dokploy's "status=done" alone — orchestrator success ≠ container actually serving the new build. Instead probe the app:
curl -sS https://atrium.ommax-intelligence.de/api/health | jq
# {
# "status": "ok",
# "parentSubject": "feat(...): ...",
# "parentSha": "abc1234", ← parent of the deployed commit
# "commitTime": "2026-...", ← deployed commit's authoring time
# ...
# }
To poll for a specific commit landing, watch for commitTime matching that commit's authoring time, OR the next commit landing on top (then parentSha becomes the one you pushed).
Email notifications
Email kinds, all routed through sendEmail (src/lib/email/send.ts), audited in AtriumEmailLog:
| Kind | When it fires | Gating |
|---|---|---|
ACCESS_REQUEST_NEW | User clicks "Request access" on an app card | Always sent (transactional) |
ACCESS_REQUEST_APPROVED / _DENIED | Admin approves/denies on /admin/access-requests | Always sent |
ROLE_GRANTED | Super-admin grants a role via /admin/members (route: POST /api/admin/admins) | Always sent. Falls back to Zitadel for the recipient's email when no AtriumUser row exists yet (first-time invitee). |
GROUP_ADDED | User added to a group | Broadcast — gated by ATRIUM_BROADCAST_NOTIFICATIONS |
APP_SHARED_WITH_GROUP | New app grant added to a group with members | Broadcast — gated |
Debugging an email that didn't arrive:
SELECT kind, "toEmail", status, "lastError", "createdAt"
FROM "AtriumEmailLog"
WHERE "toEmail" = '<recipient>'
ORDER BY "createdAt" DESC
LIMIT 10;
status will be one of QUEUED (still in flight), SENT (Plunk accepted, providerId stored), FAILED (lastError populated), or SKIPPED (broadcast gate dropped it; reason in lastError).
Drift surface (per-app)
Live at /admin/apps/[slug] as an amber banner whenever there are direct-admin or external grants. Backed by GET /api/admin/apps/:slug/drift. See groups for the provenance contract.
To probe via API:
curl -sS -H "Authorization: Bearer $TOKEN" \
https://atrium.ommax-intelligence.de/api/admin/apps/<slug>/drift | jq
Returns { totalGrants, groupManagedCount, driftCount, drift: [...] } with each drift entry tagged direct-admin (Atrium audit-row exists, just not via a group path) or manual-external (no audit row at all — created in Zitadel Console / SCIM / pre-audit-log).
Runtime restarts — silent provisioning outage if you don't repush
The runtime registry is in-memory and boots empty. The canonical script source remains in Atrium's DB. Atrium pushes scripts to the runtime via two paths:
- Per-script push — every
PATCH /api/admin/actions/{name}callspushScript()inlib/runtime-client.ts. Used when you edit a script. - Periodic reconcile —
maybeKickRuntimeRegistryReconcile()inside/api/healthreconciles everyATRIUM_ACTIONS_RUNTIME_RECONCILE_INTERVAL_MS(default 5 minutes). Throttled — successive/api/healthhits inside the window are no-ops.
The trap: between the moment the runtime restarts (deploy, OOM,
host reboot) and the next reconcile-or-PATCH, every Zitadel webhook to
/actions/events returns 404 silently. Events are NOT retried. The
recent-payloads recorder still captures the body — but no script
runs, no AtriumActionRun row is written, and the user-visible
symptom is "the auto-provisioning just stopped working".
Workaround after every redeploy of atrium-actions:
source ~/.config/atrium/credentials.env # ATRIUM_API_KEY
ATRIUM=https://atrium.ommax-intelligence.de
# List ACTIVE scripts to confirm what should be in the registry:
curl -sS -H "Authorization: Bearer $ATRIUM_API_KEY" \
"$ATRIUM/api/admin/actions" | jq -r '.scripts[] | select(.state=="ACTIVE") | .name'
# Force re-push of each by name (forceVersion bumps version even if
# source is unchanged, which triggers pushScript regardless of
# bundleHash equality):
for name in sentry-team-sync auto-group-by-department dokploy-grant-sync \
dokploy-grant-removed flatten-roles flatten-roles-access-token; do
curl -sS -X PATCH -H "Authorization: Bearer $ATRIUM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"state":"ACTIVE","forceVersion":true}' \
"$ATRIUM/api/admin/actions/$name" >/dev/null && echo " pushed: $name"
done
This is BACKLOG #9 — runtime cold-start reconciliation. The proper fix is a runtime → atrium callback on boot that fetches every ACTIVE script + decrypted secrets and installs them in one shot. Until that lands, treat every atrium-actions deploy as a provisioning outage of unknown duration and run the loop above to close the window.
How to confirm the registry has a script loaded (after running the workaround, or any time you suspect drift):
- Test-run via
POST /api/admin/actions/{name}/test-runis NOT a sufficient check — it sends the script source inline and bypasses the registry entirely. A successful test-run says nothing about whether live Zitadel webhooks would route to the script. - The reliable check is: trigger a real event (sign in, add a user,
etc.) and watch
/admin/actions/{name}→ recent payloads + the resulting Sentry/Dokploy/etc. side-effect. If the side-effect doesn't appear, the registry was empty.
Provisioning history we hit:
| Date | Symptom | Cause |
|---|---|---|
| 2026-06-04 | sentry-team-sync silently no-op'd for a new user after a deploy | Cleanup PR merge auto-deployed atrium-actions; runtime registry empty; Artem's saml_session.added hit /actions/events and 404'd before atrium re-pushed. Fixed by PATCH forceVersion=true loop above. |