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.
- 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.
What you'll build
Section titled “What you'll build”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.
Step 1 — Create the action
Section titled “Step 1 — Create the action”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.
/screenshots/admin.flows.custom_actions.your_first_action/create-dialog.png Fill in the fields:
- Name —
log-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 loginis useful when you have many actions.
Click Create. The editor opens immediately with a starter template pre-loaded.
Step 2 — Read the starter code
Section titled “Step 2 — Read the starter code”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
eventandapiare globals injected by the runtime. They're also declared as parameters here because Monaco uses the JSDoc types to power autocomplete. Typeevent.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.
/screenshots/admin.flows.custom_actions.your_first_action/editor-starter.png Step 3 — Write the logic
Section titled “Step 3 — Write the logic”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 againstevent.userbeing 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 theinfolevel. This shows up in the Test pane and in the audit stream.api.state.set('seen_email', email)— writes the email intostep.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.
Step 4 — Test before you enable
Section titled “Step 4 — Test before you enable”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.
/screenshots/admin.flows.custom_actions.your_first_action/test-pane-success.png Three sub-tabs update:
- Logs — shows the
infolog entry with the email from the preset. - State writes — shows
seen_emailandcountrywith 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.
When you use the Test pane, any secret values in the event are replaced with the literal <test-secret>. Your api.log calls cannot accidentally leak production secrets through the test output.
Step 5 — Enable the action
Section titled “Step 5 — Enable the action”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.
The moment you click Enable, the action is live for any flow that references it. If you're iterating on logic, keep the action disabled until you're satisfied with the Test pane results.
Step 6 — Attach it to the Login flow
Section titled “Step 6 — Attach it to the Login flow”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.
/screenshots/admin.flows.custom_actions.your_first_action/flow-editor-block.png Every time you save and publish the flow, IntelliAuth creates an immutable version. The audit trail shows exactly which version of the flow ran for any historical login.
Step 7 — Verify on a real login
Section titled “Step 7 — Verify on a real login”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 Flows → Login → Recent 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.
What you've learned
Section titled “What you've learned”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
eventobject, 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.
Troubleshooting
Section titled “Troubleshooting”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.