The ctx API
The runtime surface available to an Atrium Action at execution time.
Atrium scripts run with a very small surface area:
ctx- a sandboxed
console
No process, no require, no global fetch.
ctx.payload
This is the JSON ZITADEL sent for the current execution.
Events
{
aggregateID: string;
aggregateType: string;
event_type: string;
event_payload: Record<string, unknown>;
// ...
}
Observed OMMAX reminder:
oidc_session.*payloads usually describe the session/token flow- they often include
userID,clientID,scope,audience,sessionID - they do not reliably include custom profile/metadata like
department
If you need persisted user data, fetch it explicitly.
Functions
For preuserinfo / preaccesstoken, ZITADEL sends function-specific payloads
that are user/token shaped rather than event shaped.
Typical fields include:
{
function: string;
userinfo: Record<string, unknown>;
user: {
id: string;
human?: Record<string, unknown>;
};
user_metadata?: Array<{ key: string; value: unknown }>;
user_grants?: Array<{ projectId?: string; projectID?: string; roles?: string[]; roleKeys?: string[] }>;
accessToken?: { sub?: string };
}
Important:
- claim-setting logic like
flatRolesbelongs here - Functions are load-bearing; bad latency or bad return shapes matter
Requests / Responses
{
fullMethod: string;
request?: unknown;
response?: unknown;
instanceID: string;
orgID: string;
userID: string;
}
ctx.trigger
Metadata about the current slot:
ctx.trigger.type; // 'EVENT' | 'FUNCTION' | 'REQUEST' | 'RESPONSE'
ctx.trigger.name; // e.g. 'user.human.added' or 'preuserinfo'
Atrium routes by exact slot:
EVENT:user.human.addedFUNCTION:preuserinfoFUNCTION:preaccesstoken
Only one ACTIVE script can own each exact slot.
ctx.secrets
Only the secrets declared on this script are present.
const apiKey = ctx.secrets.PLUNK_API_KEY;
Secrets are resolved when a script is pushed or test-run, not fetched on every invocation.
ctx.config
The clear-text sibling of ctx.secrets. Where secrets hold credentials, config
holds the tenant-specific values that would otherwise be hardcoded into a
script body: the department claim namespace, a department-to-team map, project
and IdP ids, the Atrium base URL.
Pulling those out of the source is what makes a script tenant-agnostic. The same
script body runs for any tenant, reading that tenant's values from
ctx.config.KEY.
const namespace = ctx.config.DEPARTMENT_CLAIM_NAMESPACE;
const teams = ctx.config.DEPT_LITELLM_TEAMS; // objects and arrays are fine
How it differs from secrets
ctx.secrets | ctx.config | |
|---|---|---|
| Contents | Credentials | Non-secret values |
| At rest | Encrypted | Clear text |
| Scope | Per-script allowlist: only the secrets declared on this script | Tenant-global: every script in the tenant sees the same object |
| Value types | Strings | Whatever JSON was stored: string, number, object, array |
There is no per-script allowlist for config, deliberately, because config is non-secret by definition. If a value needs to be hidden from other scripts, it is a secret, not config.
Read semantics
Config is resolved at push and test-run time, the same as secrets, not fetched on every invocation.
An unset key reads as undefined, and an unconfigured tenant yields an empty
object rather than an error, so a script can fall back exactly as it would for an
unset secret:
const baseUrl = ctx.config.ATRIUM_BASE_URL ?? 'https://atrium.example.com';
Do not assume a key exists because it exists in another tenant. That assumption is the thing config was introduced to remove.
Where an admin edits it
/admin/actions?tab=config. The tab is deep-linkable, so that URL opens straight
into the editor.
It is backed by an admin-gated REST surface if you prefer to script it:
| Route | Purpose |
|---|---|
GET /api/admin/action-config | List every key and value for the tenant |
POST /api/admin/action-config | Create a key |
PATCH /api/admin/action-config/<key> | Update a value |
DELETE /api/admin/action-config/<key> | Remove a key |
Changing a value takes effect for a script the next time it is pushed or test-run, not mid-flight.
ctx.fetch(url, init?)
Outbound HTTP with hostname allowlisting.
const res = await ctx.fetch('https://api.useplunk.com/v1/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${ctx.secrets.PLUNK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: 'user@example.com' }),
});
Returns a fetch-like object with:
statusokheaderstext()json()
Allowlist — wire-up matters
Every external hostname your script calls must be listed in
ATRIUM_ACTIONS_FETCH_ALLOWLIST (comma-separated) on the
atrium-actions Dokploy app. Hosts not in the list return
ctx.fetch: host '...' not in allowlist. The error fires per call
inside the script, so the failure mode is "script throws on the first
outbound" — not "deploy refuses to start".
Operationally this means every new external host requires a deploy:
update the env var, redeploy atrium-actions. New scripts whose host
isn't already in the list will silently fail in production even if
they passed dev test-runs against a different runtime.
This bit us when activating sentry-team-sync — the action ran
end-to-end in test-run but errored on every real saml_session.added
event because sentry.ommax-intelligence.de wasn't in the allowlist.
Add the host as part of the script-activation checklist, not after
the silent failures show up.
ctx.zitadel.users.*
Use this when the current payload only gives you userID, or when the payload
is session-shaped and you need real user metadata/grants.
const userId =
ctx.payload?.event_payload?.userID ??
ctx.payload?.user?.id ??
ctx.payload?.accessToken?.sub;
const user = await ctx.zitadel.users.get(userId);
const department = await ctx.zitadel.users.getMetadata(userId, 'department');
const grants = await ctx.zitadel.users.getGrants(userId);
await ctx.zitadel.users.appendGrant(userId, '371501187121396417', ['opes:access']);
Returned shape — flat, not the raw Zitadel REST shape
ctx.zitadel.users.get(userId) normalises Zitadel's REST response into a
small flat object:
{
id: string;
username: string;
email: string | undefined; // ← TOP LEVEL, not user.human.email.email
displayName: string | undefined; // ← TOP LEVEL, not user.human.profile.displayName
}
This bit us in sentry-team-sync v3, which read user?.human?.email?.email
and silently returned {skipped: "no email on user"} on every run — the
nested path always resolved to undefined. Use the flat field. Canonical
source: runtime/src/zitadel-client.ts getUserById.
Fields beyond these four (department, custom profile attrs, etc.) live
in metadata and you fetch them explicitly via
ctx.zitadel.users.getMetadata(userId, key). Do not read them off
user.human.profile.metadata.*.
The important lesson here is:
- token claims do not prove event payload shape
- event payloads do not prove claim shape
- use the persisted ZITADEL user state as the durable source of truth
ctx.atrium.groups.*
Curated callbacks into Atrium for group automation.
const group = await ctx.atrium.groups.upsertByName('ADA', {
description: 'Auto-managed by script',
});
await ctx.atrium.groups.addMember(group.id, userId);
await ctx.atrium.groups.removeMember(group.id, userId);
await ctx.atrium.groups.setMappings(group.id, [
{ appId: 'litellm', roleKeys: ['internal_user'] },
]);
// Universe of Atrium-managed group names — use as the "managed slugs"
// scope when reconciling Atrium groups against an external system.
const all = await ctx.atrium.groups.listAll();
// Groups a Zitadel user is currently a member of — your desired set.
const mine = await ctx.atrium.groups.listForUser(userId);
These are real Atrium API calls. They are not mock operations.
ctx.log.{info,warn,error}
Captured into script run logs and shown in the editor.
ctx.log.info('Processing', { userId });
ctx.log.warn('No department, skipping');
ctx.log.error('Provisioning failed', err.message);
Do not log secrets or raw sensitive payloads casually.
What test runs really do
Test-run is useful, but it is not a fake world.
Truthful model:
- the code runs through the same sandbox/runtime surface
- the return value is shown locally in the UI
- but
ctx.fetch,ctx.atrium.*, andctx.zitadel.*can still hit real systems
So:
- placeholder secrets reduce accidental leakage
- they do not make all side effects impossible
- mutating scripts should use sample users, guards, or deliberately safe payloads during iteration
What scripts return
Event
Return any diagnostic object you want to inspect:
return { ok: true, groupId };
The response body usually does not drive ZITADEL business logic for Event executions. The side effect is what matters.
Function
Return the mutation payload ZITADEL expects:
return {
setClaims: {
'urn:ommax:department': 'ADA',
},
};
If your Function's purpose is claims, returning nothing useful is a bug.
Request / Response
Return the modified body, or null for pass-through.
Error behaviour
If a script throws, Atrium records the error in run logs and returns an error payload from the runtime. That is useful for diagnostics, but it is not a substitute for deliberate synchronous control flow in Function/Request/Response hooks.
If you want to shape ZITADEL's behaviour, return the correct payload on purpose.
The connector ctx is a different, smaller object
Everything above describes the ctx handed to an action script. A
brokered connector gets a deliberately narrower one, because it is untrusted
code running with someone else's credential at stake:
| Action script | Brokered connector | |
|---|---|---|
ctx.fetch | yes | no |
ctx.secrets | yes | no — it holds no credential |
ctx.request(method, path, body, contentType?) | no | yes, HTTP connectors |
ctx.execute(name, params) | no | yes, SQL connectors |
ctx.config, ctx.log | yes | yes |
ctx.zitadel, ctx.atrium | yes | no |
A connector reaches its vendor only through the surface its manifest declares. It does not choose a URL either: it names a path, and Atrium resolves the base URL from the operator's config or the manifest's constant.
ctx.request returns { ok, status, body }, with body parsed as JSON when
the vendor sent JSON and handed back as text when it did not, so an error page
stays readable. The body you send is encoded by type: an object becomes JSON, a
string is sent verbatim under contentType (default
text/plain; charset=utf-8) — which is what lets a connector talk to a vendor
whose API is XML or form-encoded.
Connector entry points take (input, ctx), in that order.
See Tutorial: build a connector.