Skip to content

Your first custom action

Build a working custom action from scratch, test it in the editor, attach it to the Login flow, and verify it fired — all in about ten minutes.

Before you begin
  • You are signed in to the admin console as a tenant admin.
  • You have permission to edit flows (the Flows item is visible in the sidebar).
  • You have read Flows concepts — the Flow / Stage / Block / Action vocabulary should be familiar before you start.

You'll create a log-user action that runs on every login. It reads the user's email from event.user.email, writes a structured log entry, and stores two values in the step state so that downstream blocks — or a future action — can read them. The action is deliberately small: one function, four lines of logic, three API calls. That simplicity is the point. Once you've seen the pattern, extending it to call an external API or inject a custom claim is a mechanical addition.

Open the admin console and click Flows in the left sidebar. At the top of the page, select the Custom Actions tab. You'll see an empty list if this is a fresh tenant.

Click + New Action. A dialog opens.

Fill in the fields:

  • Namelog-user. The slug (log-user) is derived automatically from the name.
  • Runtime — leave it on the default JavaScript runtime.
  • Description — optional, but Logs the user's email and country on every login is useful when you have many actions.

Click Create. The editor opens immediately with a starter template pre-loaded.

The editor opens with a typed function stub. Read it before you touch anything:

/**
* log-user
*
* Logs the user's email and country on every login
*
* Available primitives:
* event.* — read-only context (user, request, geo, asn, ...)
* api.deny(reason) — terminate the flow with a reason
* api.state.set(key, value) — write step.<slug>.<key>
* api.log(level, message) — structured logs (debug/info/warn/error)
* api.user.setAppMetadata(patch) — merge into user.app_metadata
* api.session.setCustomClaim(n, v) — JWT custom claim
* api.fetch(url, init?) — outbound HTTPS (allowlist required)
*
* Type the parameter names below and press '.' — Monaco autocompletes
* the full event/api shape (user, request, geo, asn, methods, ...).
*
* @param {IntelliAuthActionEvent} event - Read-only authentication context.
* @param {IntelliAuthActionApi} api - Side-effect surface.
* @returns {Promise<void>}
*/
exports.handler = async function logUser(event, api) {
// Your code here.
//
// Example — deny Tor exit-node logins:
//
// if (event.request.asn && event.request.asn.is_tor) {
// api.deny('Tor exit nodes are not permitted');
// return;
// }
};

Three things are worth noting before you write your own logic:

  • The function must be exported as exports.handler. The runtime calls that symbol; a named function expression elsewhere won't be invoked.
  • Both event and api are globals injected by the runtime. They're also declared as parameters here because Monaco uses the JSDoc types to power autocomplete. Type event. in the editor and you'll see the full shape.
  • The comment block is just for you. It won't affect execution. Delete it once you're comfortable with the surface.

Replace everything inside the exports.handler = async function logUser(event, api) { ... } body with the following:

exports.handler = async function logUser(event, api) {
const email = event.user?.email
if (!email) {
api.log('warn', 'log-user: no user email — action ran before identity lookup')
return
}
api.log('info', `log-user: login for ${email}`)
api.state.set('seen_email', email)
api.state.set('country', event.request?.geo?.country ?? 'unknown')
}

What each line does:

  • event.user?.email — reads the user's primary email. The optional chain (?.) guards against event.user being undefined, which happens when the action runs in a Stage before the Identity Lookup block has fired.
  • api.log('warn', ...) — if there's no email, we log a warning and return early. The flow continues; we just skip the writes.
  • api.log('info', ...) — writes a structured log entry at the info level. This shows up in the Test pane and in the audit stream.
  • api.state.set('seen_email', email) — writes the email into step.log-user.seen_email. Any block that runs after this one in the same flow can read it via the {} picker.
  • api.state.set('country', event.request?.geo?.country ?? 'unknown') — writes the GeoIP country code. The optional chain is defensive: geo data is absent on the first request from a new IP until the telemetry block has run.

Click Save (or press Cmd-S). The editor writes a new version; the version counter in the header increments.

Look at the bottom panel. Select the Test tab. Pick the Logged-in US user preset from the dropdown — this loads a sample event object with a populated event.user.email and event.request.geo.country.

Click Run.

Three sub-tabs update:

  • Logs — shows the info log entry with the email from the preset.
  • State writes — shows seen_email and country with the values you wrote.
  • Metadata — shows elapsed milliseconds. Under 5 ms is typical for an action this simple.

If you see an error in the Logs tab instead, check that your code is exactly as written above. The most common mistake at this stage is a syntax error from a stray character.

Now switch the preset to Unauthenticated request — a flow event with no user identity. Click Run again. The Logs tab should show the warn entry (no user email) and State writes should be empty. That's the early-return path working correctly.

An action that hasn't been enabled will not fire in any live flow, even if a Run Custom Action block points to it. The Test pane always works regardless of enabled state — that's intentional so you can iterate safely.

Click Enable in the action header. The status badge changes from Disabled to Enabled.

Now you'll give the action something to run inside. Navigate to Flows in the sidebar (the main Flows list, not the Custom Actions tab). Click Login, then click Edit.

The flow editor opens. Find the Post-Auth stage — it appears after the Password Check and before the Issue Session blocks in the default layout.

Drag a Run Custom Action block from the block palette on the left into the Post-Auth stage. Drop it before the Issue Session block. Click the block to open its config panel, then pick log-user from the action dropdown. Click Save in the block config panel, then click Save & Publish in the flow editor header to publish the updated flow.

Open your tenant's login page. The URL follows the pattern <tenant-slug>-<org-slug>.<your-domain>/login. Sign in with a test user.

Once the login completes, go back to the admin console. Navigate to FlowsLoginRecent Runs. Click the most recent entry. The run trace opens.

You should see the Run Custom Action block in the Post-Auth stage, marked as completed. Expand it. The trace shows the two structured log lines you wrote and the two state values — seen_email and country — under the step outputs.

If you don't see a recent run entry, wait a moment and refresh — run traces appear within a few seconds of the login completing.

You've walked the complete lifecycle of a custom action: create, write, test, enable, attach, verify. The four-word vocabulary from Flows concepts — Flow, Stage, Block, Action — now has something concrete to anchor to. You've also seen all four of the most-used API methods in real code: api.log, api.state.set, and the read discipline on event.user and event.request.geo.

The next natural steps:

  • Event reference — the full shape of the event object, with notes on which fields are populated at which stages.
  • API reference — full type signatures and behaviour notes for every api.* method.
  • Attaching actions to flows — ordering rules, branching from action outputs, and version pinning.
  • Recipes — finished flows for common goals: inject a custom claim, call a fraud API, block disposable emails.

The Run Custom Action block doesn't appear to fire. Check that you clicked Enable in the action header. Disabled actions are silently skipped in live flows — they appear greyed in the block palette but won't generate an error. Also confirm you clicked Save & Publish in the flow editor after adding the block; an unpublished draft doesn't affect live logins.

email is undefined in my Test pane run. Check the preset you selected. If you chose a preset that represents a flow stage before identity lookup (such as Unauthenticated request), event.user will be undefined, which is the early-return branch. Switch to Logged-in US user to test the happy path with a populated identity.

My log entries don't appear in the run trace. The most likely cause is a runtime error — an uncaught exception terminates the action and the logs buffer is not flushed to the audit stream. Switch to the Test pane and run again; errors surface immediately there. Fix the error, save, and re-test before checking the live run trace.