Skip to content

Actions

An action is something the engine does on a stage's behalf: call your webhook, send an email, check a one-time code, ask an AI model, verify an identity, generate a PDF. You declare it where it happens; the engine runs it. Its settings (endpoints, prompts, keys) never reach a browser.

Try it

A loan application with a KYC check, an AI risk read, PDFs and an email in the playground runs in your browser. Play every role from the toolbar.

tsx
<Stage name="apply" actor="role:applicant">
  <Step name="details">…</Step>
  <Action id="risk" type="ai_prompt" prompt="Rate the risk of {{business}}" schema={RISK} />
  <Action id="notify" type="email" on="enter" to="{{email}}" subject="We received your application" />
</Stage>

When an action runs

WhereRuns
<Action> in a <Stage> (or one of its steps)when the stage is submitted, in order, before routing
<Action on="enter"> in a <Stage>every time the stage is entered — the start of a session, routing in, each loop-back round
<Action> in a <Field>when something happens to the field — see Field actions

An <Action> outside every stage is an error at deploy.

On submit, each action's outcome is recorded and the next one runs. If an action is blocking and fails, the stage does not complete: nothing is recorded, the submit answers 422 with blockedBy, and the actor can fix their input and submit again. A non-blocking failure is recorded and the stage moves on.

On enter, the stage is already open, so a failure is recorded and never blocks (blocking on an enter action is refused at deploy).

Field actions

Check a VAT number when the field loses focus, look up the city for a zip code, verify an address:

Try it

A supplier check whose VAT number is checked when you leave the field in the playground runs in your browser. Play every role from the toolbar.

tsx
<Field name="vat_number" type="string" required>
  <Action id="vat_check" type="webhook" endpoint="https://…/vat" on="blur" blocking />
</Field>
onAsks when
valid (default)the value passes the field's own validation, once typing pauses
changethe value changes, once typing pauses
blurthe field loses focus
enter_keyEnter is pressed in it

The browser only notices the event and asks; the engine decides and runs the action — on its own copy of the answers, never on values sent with the request, and only for the actor who owns the stage. So repeated blurs, or a client asking in a loop, cost nothing:

  • Same value, no second run. An action that already ran, with no edit to its field since, is not run again — whether it succeeded or failed, so a wrong one-time code costs one attempt, not one per blur. Change the value and it runs again; useAction(id).stale is true in between.
  • blocking holds the stage. The stage does not submit until the action has succeeded on the field's current value. If nothing has checked that value yet (an answer carried over or prefilled, which nobody typed or left), submitting runs the check first. A failure shows on the field, the form goes back to that field's step, and submitting again does not rerun it. Only changing the value, or an explicit run(), checks again.
  • A "Check now" button is useAction(id).run. It is also the one way to rerun a failure on the same value — "Try again" after the endpoint was down.

Field actions work on top-level fields (not inside a <FieldGroup>); on="enter" belongs to a stage.

Results

An action's result lands in the session state under its id, where conditions and the UI can read it:

tsx
<Stage name="review" actor="role:underwriter" renderIf={{ 'risk.level': { in: ['high', 'medium'] } }}>
tsx
function RiskBadge() {
  const risk = useAction('risk') // { status, result, error, download }
  if (risk.status === 'running' || risk.status === 'waiting') return <Spinner />
  return risk.status === 'done' ? <Badge level={risk.result.level} /> : null
}

Whatever an action returns is readable by the session's actors (unless you narrow it with <Action visibleTo={['role:underwriter']}>). Never return a secret from a webhook.

Actions that finish later

Some actions cannot answer within the request — an identity check takes minutes. They park the session: the stage stays locked, the session status is waiting_action, and useAction reports waiting with whatever the action published meanwhile (a verification link). When the outcome arrives the engine records it and carries on: the remaining actions, then routing. A connected browser updates by itself.

A parked action that never hears back fails after timeoutSeconds (default 24 hours). An async blocking action that fails reopens the stage for its actor.

An async enter action parks the stage the other way round: its actor can fill it in, but cannot submit until the outcome is in — useful when the outcome is what they are meant to act on.

Types

webhook

POSTs to your endpoint:

json
{
  "action": "risk_check",
  "state": { "company": "Acme", "industry": "retail",  },
  "callback": { "url": "https://…/api/v1/sessions/Xk2…/actions/risk_check/callback", "token": "…", "ref": "…" }
}

with x-clipless-signature: sha256=<hex HMAC-SHA256 of the raw body>, signed with your environment's signing secret — create it with clipless secrets signing-secret and verify every call:

ts
const expected = createHmac('sha256', SIGNING_SECRET).update(rawBody).digest('hex')
if (req.headers['x-clipless-signature'] !== `sha256=${expected}`) return 401

Answer 200 with JSON and that JSON is the result. Answer 202 to finish later: POST the outcome to callback.url with Authorization: Bearer <callback.token>, and body { "ref": "<callback.ref>", "result": { … } } or { "ref": "…", "error": "why" }. Retrying a callback is safe.

Prop
endpointyour URL (https to a public host)
secretRefan environment secret sent as Authorization: Bearer …
blocking, timeoutSeconds, visibleToas above

email

tsx
<Action id="notify" type="email" to="{{email}}" subject="Hi {{name}}" body="Thanks, {{name}}." />

{{field}} is replaced with the answer (dot paths work: {{risk.level}}). An unresolved recipient fails the action instead of sending.

otp_send / otp_verify

A one-time code by email: otp_send on one stage (to, length default 6, ttlSeconds default 600), otp_verify on a later one with code="{{code}}" pointing at the field the actor typed it into. Make the verify blocking so a wrong code stops the stage. Only a hash of the code is stored, and attempts are capped.

ai_prompt

One model call. prompt (and optionally system) with {{field}} interpolation. With a JSON schema, the result is the parsed object; without, { text }.

Prop
provideranthropic (default) or openai_compatible (any chat-completions API at baseUrl; model required)
model, effortmodel choice; effort is lowmax
secretRefthe key's secret name; default ANTHROPIC_API_KEY / AI_API_KEY

It answers inline if the model is quick, and parks otherwise.

kyc

A Stripe Identity verification. It parks at once and publishes { status: 'pending', url } — send the actor to url. The outcome is { status: 'verified', … } or a failure. Needs the environment secrets STRIPE_SECRET_KEY and STRIPE_IDENTITY_WEBHOOK_SECRET, and Stripe's webhook pointed at /api/v1/providers/stripe-identity/<environment>/webhook. returnUrl is where Stripe sends the actor back to.

pdf

A document from the answers: a summary (title, and fields to choose which), or your AcroForm filled in (templateUrl). The result is { documentId, name, pages, size, sha256 }; the file is encrypted with the session's vault key. useAction(id).download() fetches it in the browser.

Secrets

Actions never read the engine's own configuration. Keys they need are environment secrets:

bash
printf %s "$ANTHROPIC_KEY" | bunx clipless secrets set ANTHROPIC_API_KEY
bunx clipless secrets                     # names only; values are write-only
bunx clipless secrets signing-secret      # create or rotate the webhook signing secret