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

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.

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_groupsEvery 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_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). Limited to the Atrium app creators group; anyone else is refused
request_app_creationyesAsk an admin to register one app for you: files it in their Requests queue and names who decides
get_my_app_creation_requestnoWhere your request to create apps stands, and which admins to chase
update_appyesPatch editable fields incl. environments (whole-list replace); 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, discoverable
add_group_memberyesAdd a user to a group; reconciler fires inline
remove_group_memberyesRemove a user; grants revoked inline
create_app_dev_groupyesMint (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_targetsThe places an app can run: provider id, what each gets you, whether the org has it connected, and which is the default
set_deploy_targetyesChange 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_locationWhere an app's build, deploy and runtime logs are served from, and what to authenticate with
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 (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_secretyesReplace 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_redirectsyesAdd 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_modeyesMove 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_clientyesDelete one of your app's login clients after a switch (or to roll it back). Never the last one
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
list_user_groupsYour own effective group profile and grants; admins may ask about anyone
search_usersFind a colleague by name, username or email (the same search every people picker uses)
search_discoverable_groupsFind a group anyone may know about by name, including ones you are not in; a head count, never its members
list_external_linksOperator-pinned external resources on an app you own
discover_external_resourcesAdoptable external resources for a provider and kind; owners name their app
set_external_linkyesPin your app's dev group to a pre-existing resource
delete_external_linkyesRemove one external-resource pin from your app
grant_group_app_roleyesGive a group access to an app you own, with role keys or none (empty = plain access)
revoke_group_app_roleyesTake an app you own away from a group, removing its grants
add_group_owneryesMake someone co-owner of a group you own, by user id or email
remove_group_owneryesRemove an owner from a group you own; never the last one
list_group_ownersOwners of a group you own, with names
list_group_membersAnyone's roster of any group, with names (as on the website)
list_group_childrenSubgroups of a group you own, and the groups it sits in
create_child_groupyesCreate a new group nested under one you own; you own it
get_app_reachHow many people can open your app (a count, never names)
get_app_access_flagsYour app's login-portal switches in plain words, and any drift from its access setting
repair_app_access_flagsyesMake the login portal agree with your app's stored access setting
get_app_provisioningWhat your app should have in each connected system, what it has, which gaps can be closed
retry_app_provisioning_stepyesRe-run one provisioning step now; report-only steps are refused
retarget_dns_recordyesPoint a subdomain your app holds at a different address
verify_app_hostnameyesCheck whether a hostname answers; link it from the tile if so, withdraw it if not
get_app_ssoWhether your app is behind OMMAX login, and the clients the portal holds (no secrets)
list_sentry_platformsPlatform tokens for an error-tracking project pin (stack on set_external_link)
list_approversWho can approve a self-registered app, and whether you need one
list_marketplace_connectorsThe connector catalogue behind /marketplace, and whether you may install
create_my_personal_spaceyesCreate your own personal space with a hostname; one per person
list_my_api_keysYour API keys, metadata only (minting stays on the website: the key would land in chat)
revoke_my_api_keyyesRevoke 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

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
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)
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 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.

namemutateswhat it does
list_zitadel_targetsZitadel Action Targets (webhook endpoints)
list_zitadel_executionsWhich trigger fires which target
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).

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 toolInput changes nothing. (The agent route keeps no server-side conversation state at all — a plain follow-up turn must re-send history.)
  • 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; 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;