Documentation menu

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:

SurfacePathWhat it is for
Ask Atrium (read-only chat)/askThinking partner. Knows the directory, secret names, scripts. Generates code. Does not do anything.
Agent (in-app chat with tool use)/admin/aiConfirm-card flow on every mutation. The audit-log row is the approval.
Agent API (same logic, programmatic)POST /api/admin/actions/ai/agentSame 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:

  1. minRole is a coarse catalog filter. It decides which tools the MCP server lists for a caller. Omitting it defaults the tool to super-admin.
  2. 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.

namemutateswhat it does
list_appsDirectory listing, optional category filter
get_appOne app by slug; full record for admins and owners
list_groupsGroups you can manage, with counts and grants
get_groupFull detail of one group you own or administer
list_my_groupsThe caller's own memberships and grants
request_app_accessyesSubmit a PENDING access request; dedupes
register_appyesSelf-serve registration with dev group, DNS and the app's SSO project (no OAuth client — provision_app_sso mints that on demand)
update_appyesPatch editable fields; over-tier fields dropped
preview_app_teardownEnumerate what deleting the app would tear down
delete_appyesDelete an app and its linked dev group
create_atrium_groupyesCreate an empty group (DEPARTMENT / PROJECT / AUXILIARY)
update_group_metadatayesUpdate name, description, kind, tags
add_group_memberyesAdd a user to a group; reconciler fires inline
remove_group_memberyesRemove a user; grants revoked inline
get_dns_targetsOrg DNS zones, ingress IP, open namespace
list_dns_recordsAn app's claimed subdomains and zone status
claim_dns_subdomainyesClaim a subdomain for an app you own
release_dns_subdomainyesRelease a subdomain: deletes the zone record and frees the name
provision_app_ssoyesPut an app behind OMMAX login: mint its SSO client, return the oauth2-proxy config
connect_app_repositoryyesBind an app you own to one repository with a repository-scoped token; wider tokens are refused
get_app_repositoryWhich repository an app deploys from, and whether its token expired
disconnect_app_repositoryyesUnbind 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

namemutateswhat it does
list_secretsSecret names and descriptions, never values
list_actionsAction scripts with trigger metadata, state, run counts
get_runtime_healthRuntime reachability, loaded scripts and keys
discover_zitadel_urlCanonical Zitadel console URL for a project and concern
list_user_groupsEffective group profile and grants for a Zitadel user
search_usersZitadel user search by email, username or display name
create_secretyesEncrypt and persist a new action secret
create_appyesRegister an app; caller becomes first owner
add_app_owneryesAssign a Zitadel user as owner of an app
remove_app_owneryesRemove an owner; no-op if absent
set_app_publicyesToggle public access for OMMAX users (mirrors projectRoleCheck; never opens the app to other orgs)
list_external_linksOperator-pinned external resources on an app
discover_external_resourcesAdoptable external resources for a provider and kind
set_external_linkyesPin the app's dev group to a pre-existing resource
delete_external_linkyesRemove one external-resource pin
grant_group_app_roleyesLink a group to an app, with role keys or none (empty = plain access)
revoke_group_app_roleyesUnlink a group from an app, removing its grants
delete_atrium_groupyesDelete a group after dropping its grants
add_group_childyesNest one group under another
remove_group_childyesUnlink a child group from its parent
list_scim_connectionsAll SCIM connections; never exposes the bearer token
get_scim_connectionOne app's SCIM connection config
create_scim_connectionyesUpsert a SCIM connection; token encrypted at rest
delete_scim_connectionyesDelete an app's SCIM connection
trigger_scim_syncyesRun SCIM reconciliation; dry-run by default
mint_tutorial_upload_urlyesPresign a PUT URL to upload a tutorial video straight to in-stack MinIO; returns { url, objectKey }
create_tutorialyesAdd a tutorial to the /tutorials catalogue from an uploaded object
delete_tutorialyesRemove a tutorial and delete its video object
reorder_tutorialsyesSet 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.

namemutateswhat it does
list_zitadel_targetsZitadel Action Targets (webhook endpoints)
list_zitadel_executionsWhich trigger fires which target
mint_api_keyyesMint an Atrium API key; token returned once
create_zitadel_targetyesCreate a Target; signing key returned once
rotate_zitadel_signing_keyyesRotate 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; errorMessage populated.

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;