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
86 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 | Every group the caller can act on: their memberships, the groups they own (incl. the dev group of an app they own), and every group for an admin. Each row carries via and canManageMembers. | |
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). Limited to the Atrium app creators group; anyone else is refused |
request_app_creation | yes | Ask an admin to register one app for you: files it in their Requests queue and names who decides |
get_my_app_creation_request | no | Where your request to create apps stands, and which admins to chase |
update_app | yes | Patch editable fields incl. environments (whole-list replace); 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, discoverable |
add_group_member | yes | Add a user to a group; reconciler fires inline |
remove_group_member | yes | Remove a user; grants revoked inline |
create_app_dev_group | yes | Mint (or return) an app's developer group — the group whose membership drives dev-tooling access, and the hosting grants for an app on a target that declares roles. Idempotent; the group is born empty |
get_deploy_targets | The places an app can run: provider id, what each gets you, whether the org has it connected, and which is the default | |
set_deploy_target | yes | Change where an app you own is deployed. Does not move a running deployment — it changes which platform Atrium provisions and grants access on |
get_app_log_location | Where an app's build, deploy and runtime logs are served from, and what to authenticate with | |
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 (PKCE by default; pass mode). In oauth2-proxy mode it returns the proxy config plus a one-time link the owner opens in a browser signed in to Atrium for the client secret. The agent makes the cookie secret itself |
rotate_app_sso_secret | yes | Replace an app's SSO client secret after a leak. Breaks the app's login until the new client secret is redeemed in a browser signed in to Atrium and redeployed |
update_app_sso_redirects | yes | Add or remove exact redirect and post-logout URIs on your app's login client: https only (http for localhost), no wildcards or fragments, at most 10, never the last one |
switch_app_sso_mode | yes | Move your app between PKCE and oauth2-proxy login. Adds the new client next to the old one and changes nothing that runs |
retire_app_sso_client | yes | Delete one of your app's login clients after a switch (or to roll it back). Never the last one |
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 |
list_user_groups | Your own effective group profile and grants; admins may ask about anyone | |
search_users | Find a colleague by name, username or email (the same search every people picker uses) | |
search_discoverable_groups | Find a group anyone may know about by name, including ones you are not in; a head count, never its members | |
list_external_links | Operator-pinned external resources on an app you own | |
discover_external_resources | Adoptable external resources for a provider and kind; owners name their app | |
set_external_link | yes | Pin your app's dev group to a pre-existing resource |
delete_external_link | yes | Remove one external-resource pin from your app |
grant_group_app_role | yes | Give a group access to an app you own, with role keys or none (empty = plain access) |
revoke_group_app_role | yes | Take an app you own away from a group, removing its grants |
add_group_owner | yes | Make someone co-owner of a group you own, by user id or email |
remove_group_owner | yes | Remove an owner from a group you own; never the last one |
list_group_owners | Owners of a group you own, with names | |
list_group_members | Anyone's roster of any group, with names (as on the website) | |
list_group_children | Subgroups of a group you own, and the groups it sits in | |
create_child_group | yes | Create a new group nested under one you own; you own it |
get_app_reach | How many people can open your app (a count, never names) | |
get_app_access_flags | Your app's login-portal switches in plain words, and any drift from its access setting | |
repair_app_access_flags | yes | Make the login portal agree with your app's stored access setting |
get_app_provisioning | What your app should have in each connected system, what it has, which gaps can be closed | |
retry_app_provisioning_step | yes | Re-run one provisioning step now; report-only steps are refused |
retarget_dns_record | yes | Point a subdomain your app holds at a different address |
verify_app_hostname | yes | Check whether a hostname answers; link it from the tile if so, withdraw it if not |
get_app_sso | Whether your app is behind OMMAX login, and the clients the portal holds (no secrets) | |
list_sentry_platforms | Platform tokens for an error-tracking project pin (stack on set_external_link) | |
list_approvers | Who can approve a self-registered app, and whether you need one | |
list_marketplace_connectors | The connector catalogue behind /marketplace, and whether you may install | |
create_my_personal_space | yes | Create your own personal space with a hostname; one per person |
list_my_api_keys | Your API keys, metadata only (minting stays on the website: the key would land in chat) | |
revoke_my_api_key | yes | Revoke one of your API keys |
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 | |
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) |
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 four 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 | |
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).
Confirming is the same route, not a sub-route. There is no
/agent/confirm; every guess at one answers 404. So the pending payload ships
the instruction with it — pendingToolCall.confirm is a ready-made
{ method, path, body } an API caller POSTs back verbatim. Two consequences
worth stating, because an API caller who missed them concluded the agent was
read-only over the API (2026-08-21):
- A confirm turn is stateless. It needs no history and no memory of the
planning turn: the stored input on the audit row is what executes, so an
echoed
toolInputchanges nothing. (The agent route keeps no server-side conversation state at all — a plain follow-up turn must re-sendhistory.) - The tool that proposed the call is the tool that runs. Editing the proposal means asking for a new one, not confirming this one.
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;