AI agent (Claude with tools)
How the in-app /admin/ai chat and its underlying API read and mutate Atrium state via role-tiered tool calls, with a confirm-card audit gate on every mutation.
The Atrium AI exists in three places that share one tool catalog:
| Surface | Path | What it is for |
|---|---|---|
| Ask Atrium (read-only chat) | /ask | Thinking partner. Knows the directory, secret names, scripts. Generates code. Does not do anything. |
| Agent (in-app chat with tool use) | /admin/ai | Confirm-card flow on every mutation. The audit-log row is the approval. |
| Agent API (same logic, programmatic) | POST /api/admin/actions/ai/agent | Same protocol as the chat. Drive it from CI, scripts, your own tooling. |
There is also an MCP endpoint that exposes the same catalog, role-tier-filtered, over JSON-RPC for Claude Desktop and ChatGPT.
This page is about the agent surfaces.
The tier tables are not the permission model
Read this before the tables below, because taking them as an access-control reference will mislead you.
RBAC here is two layers, and both are load-bearing:
minRoleis a coarse catalog filter. It decides which tools the MCP server lists for a caller. Omitting it defaults the tool to super-admin.- A per-resource check inside the executor is the real gate: app-ownership and group-ownership asserts, top-level-group creation rules, and field-tier filtering on patches.
Layer 2 exists because Atrium's real permission model is (role tier x ownership
of this specific app or group x field tier), which a single minRole cannot
express. A tool an app owner may use on their own app therefore carries
minRole: 'user' plus an ownership assert. The floor is deliberately low and
the executor is the real gate, exactly as the matching REST route does it.
Concretely: delete_app sits in the user tier. That does not mean any signed-in
user can delete any app. It means the catalog filter lets the tool through, and
the executor then refuses unless you own that specific app. Likewise
update_app drops any field above your tier before writing, and refuses
outright if nothing survives the filter; get_group resolves the group first and
then asserts ownership, so a name lookup cannot be used to probe the group
namespace.
Some tools narrow instead of refusing. A non-DISCOVERABLE app raises
not-found rather than forbidden for get_app, so the existence of a slug is not
confirmed to someone who should not see it.
Tool catalog
47 tools today. mutates marks the ones that require confirmation.
User tier
Visible to any caller with a valid Atrium session or personal key. Most of these carry an ownership assert in the executor; see the section above.
| name | mutates | what it does |
|---|---|---|
list_apps | Directory listing, optional category filter | |
get_app | One app by slug; full record for admins and owners | |
list_groups | Groups you can manage, with counts and grants | |
get_group | Full detail of one group you own or administer | |
list_my_groups | The caller's own memberships and grants | |
request_app_access | yes | Submit a PENDING access request; dedupes |
register_app | yes | Self-serve registration with dev group, DNS and the app's SSO project (no OAuth client — provision_app_sso mints that on demand) |
update_app | yes | Patch editable fields; over-tier fields dropped |
preview_app_teardown | Enumerate what deleting the app would tear down | |
delete_app | yes | Delete an app and its linked dev group |
create_atrium_group | yes | Create an empty group (DEPARTMENT / PROJECT / AUXILIARY) |
update_group_metadata | yes | Update name, description, kind, tags |
add_group_member | yes | Add a user to a group; reconciler fires inline |
remove_group_member | yes | Remove a user; grants revoked inline |
get_dns_targets | Org DNS zones, ingress IP, open namespace | |
list_dns_records | An app's claimed subdomains and zone status | |
claim_dns_subdomain | yes | Claim a subdomain for an app you own |
release_dns_subdomain | yes | Release a subdomain: deletes the zone record and frees the name |
provision_app_sso | yes | Put an app behind OMMAX login: mint its SSO client, return the oauth2-proxy config |
connect_app_repository | yes | Bind an app you own to one repository with a repository-scoped token; wider tokens are refused |
get_app_repository | Which repository an app deploys from, and whether its token expired | |
disconnect_app_repository | yes | Unbind the repository and clear the stored token |
Extra guardrails worth knowing: create_atrium_group gates on a top-level-group
rule rather than a flat role, and records a non-admin creator as the group's
owner. update_group_metadata additionally makes tags admin-only, refuses to set
kind to DEPARTMENT, and refuses to edit a group that already is one.
Admin tier
| name | mutates | what it does |
|---|---|---|
list_secrets | Secret names and descriptions, never values | |
list_actions | Action scripts with trigger metadata, state, run counts | |
get_runtime_health | Runtime reachability, loaded scripts and keys | |
discover_zitadel_url | Canonical Zitadel console URL for a project and concern | |
list_user_groups | Effective group profile and grants for a Zitadel user | |
search_users | Zitadel user search by email, username or display name | |
create_secret | yes | Encrypt and persist a new action secret |
create_app | yes | Register an app; caller becomes first owner |
add_app_owner | yes | Assign a Zitadel user as owner of an app |
remove_app_owner | yes | Remove an owner; no-op if absent |
set_app_public | yes | Toggle public access for OMMAX users (mirrors projectRoleCheck; never opens the app to other orgs) |
list_external_links | Operator-pinned external resources on an app | |
discover_external_resources | Adoptable external resources for a provider and kind | |
set_external_link | yes | Pin the app's dev group to a pre-existing resource |
delete_external_link | yes | Remove one external-resource pin |
grant_group_app_role | yes | Link a group to an app, with role keys or none (empty = plain access) |
revoke_group_app_role | yes | Unlink a group from an app, removing its grants |
delete_atrium_group | yes | Delete a group after dropping its grants |
add_group_child | yes | Nest one group under another |
remove_group_child | yes | Unlink a child group from its parent |
list_scim_connections | All SCIM connections; never exposes the bearer token | |
get_scim_connection | One app's SCIM connection config | |
create_scim_connection | yes | Upsert a SCIM connection; token encrypted at rest |
delete_scim_connection | yes | Delete an app's SCIM connection |
trigger_scim_sync | yes | Run SCIM reconciliation; dry-run by default |
mint_tutorial_upload_url | yes | Presign a PUT URL to upload a tutorial video straight to in-stack MinIO; returns { url, objectKey } |
create_tutorial | yes | Add a tutorial to the /tutorials catalogue from an uploaded object |
delete_tutorial | yes | Remove a tutorial and delete its video object |
reorder_tutorials | yes | Set the /tutorials display order from an id list |
Super-admin tier
These five declare no minRole at all and fall through to the super-admin
default, which is applied by the MCP server rather than by the tool definition.
| name | mutates | what it does |
|---|---|---|
list_zitadel_targets | Zitadel Action Targets (webhook endpoints) | |
list_zitadel_executions | Which trigger fires which target | |
mint_api_key | yes | Mint an Atrium API key; token returned once |
create_zitadel_target | yes | Create a Target; signing key returned once |
rotate_zitadel_signing_key | yes | Rotate a Target's key, invalidating the old one |
script_author is a legal tier value but no tool currently uses it.
The criterion for adding a tool: it should be a single administrative operation
mapping cleanly onto a service-layer function. Tools delegate to the same code
path the route handlers use, so adding a field to an app patch schema reaches
both the REST route and update_app. The services themselves do not check
auth; that is the tool layer's job and the route handlers' job.
The protocol: confirm-card on mutations
Every mutation creates an AtriumAiAuditLog row in REJECTED state at proposal
time. Approval flips it to EXECUTED. The server never trusts the client to
echo the tool input back unchanged: the confirm path loads the stored input from
the audit row, so the user approves the exact bytes that ran.
Confirmation goes back to the same path; there is no /confirm sub-route
(every guess at one — /agent/confirm, /ai/confirm, /agent/execute — is a
404, and an API caller who guessed concluded the agent was read-only). So the
pending response carries a confirm block holding the literal follow-up
request: { method, path, body }, where body is POSTable verbatim. prompt
is optional on that call — the confirm branch never reaches the model.
The confirm branch also checks that the audit row belongs to the caller (403 otherwise), that it has not already executed (409), and that the tool name matches (400).
AtriumAiAuditLog rows surface every call:
EXECUTED: a read tool ran, or a mutation was confirmed and ran.REJECTED: a mutation was proposed but never confirmed.ERROR: an exception was thrown during execution;errorMessagepopulated.
The MCP endpoint reuses the same table but stamps
conversationId = "mcp:<apiKeyId>", so MCP-driven calls are filterable from the
in-app flow.
Read-tool results are fed back on the tool role rather than as free-text user
input, so a hostile result body containing something like "ignore previous
instructions" is not read by the model as an instruction from the user. The loop
is capped at 10 iterations and one tool call per turn.
Try it from the CLI
Mint an API key at /admin/api-keys, then:
TOKEN="atr_live_..."
# Ask a question. The agent calls read tools as needed.
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
https://atrium.ommax-intelligence.de/api/admin/actions/ai/agent \
-d '{
"conversationId": "demo-001",
"prompt": "How many shared-credential apps do we have, and who owns Oxylabs?"
}'
# → { type: "text", text: "There are 5 shared-credential apps..." }
# Propose a mutation. The server returns pending and NEVER executes.
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
https://atrium.ommax-intelligence.de/api/admin/actions/ai/agent \
-d '{
"conversationId": "demo-001",
"prompt": "Create an Atrium group called Sales Engineering."
}'
# → { type: "pending_tool_call",
# pendingToolCall: { auditLogId: "cmp...", toolName: "create_atrium_group",
# toolInput: { name: "Sales Engineering" }, ... },
# confirm: { method: "POST", path: "/api/admin/actions/ai/agent",
# body: { conversationId: "demo-001", confirmation: {...} } } }
# Confirm: POST `confirm.body` back, unchanged. The server loads toolInput
# from the audit row, so your echo of it is ignored either way.
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
https://atrium.ommax-intelligence.de/api/admin/actions/ai/agent \
-d '{
"conversationId": "demo-001",
"confirmation": {
"toolName": "create_atrium_group",
"toolInput": { "name": "Sales Engineering" },
"auditLogId": "cmp..."
}
}'
# → { type: "tool_result", toolName: "create_atrium_group", result: { id, name, ... } }
Why the agent is lean
The /ask chat preloads the directory, secret names, action templates and
example scripts into the system prompt. That is useful for thinking-partner
answers but costs roughly 30k to 60k tokens per turn. The agent path builds a
slim context instead and tells Claude to call tools to find directory state.
Every turn fits comfortably under the gateway's per-key rate ceiling, and the
agent learns actual current state rather than a snapshot baked into the prompt.
The trade-off: broad questions ("describe every app") cost more model iterations. Specific questions are fast.
Permission model
The agent route is gated by super-admin, via session or bearer key. That has not changed now that scoped API keys exist: scoped keys carry no roles, so they cannot reach this route at all, and the gate did not widen when they landed.
So on the agent surfaces you see the whole catalog, because only super-admins get in. MCP is the surface where the tier filter is actually observable: a regular user's key sees the user-tier slice, an admin key adds the curation surface. The MCP server re-checks the tier at call time as well as at list time, so a client cannot invoke a tool name it discovered elsewhere.
The route also returns 503 when the model gateway is unconfigured and 502 on
an upstream failure, rather than surfacing a partial result.
The audit log is queryable by user, status and kind. A UI panel is queued; for now go directly:
SELECT "toolName", "status", "durationMs", "createdAt", "errorMessage"
FROM "AtriumAiAuditLog"
WHERE "userId" = '<zitadel sub>'
ORDER BY "createdAt" DESC
LIMIT 50;