Documentation menu

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 flatRoles belongs 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.added
  • FUNCTION:preuserinfo
  • FUNCTION: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.secretsctx.config
ContentsCredentialsNon-secret values
At restEncryptedClear text
ScopePer-script allowlist: only the secrets declared on this scriptTenant-global: every script in the tenant sees the same object
Value typesStringsWhatever 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:

RoutePurpose
GET /api/admin/action-configList every key and value for the tenant
POST /api/admin/action-configCreate 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:

  • status
  • ok
  • headers
  • text()
  • 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.*, and ctx.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 scriptBrokered connector
ctx.fetchyesno
ctx.secretsyesno — it holds no credential
ctx.request(method, path, body, contentType?)noyes, HTTP connectors
ctx.execute(name, params)noyes, SQL connectors
ctx.config, ctx.logyesyes
ctx.zitadel, ctx.atriumyesno

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.