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
| Where | Runs |
|---|---|
<Action> in a <Stage> (or one of its steps) | when the stage is submitted, before routing: the blocking ones first, then the rest, each in the order written |
<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. Blocking actions run before the others, so a refused submit has sent no email and made no document — a resubmit never sends one twice. 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>on | Asks when |
|---|---|
valid (default) | the value passes the field's own validation, once typing pauses |
change | the value changes, once typing pauses |
blur | the field loses focus |
enter_key | Enter 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).staleis true in between. blockingholds 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 explicitrun(), 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; 20 minutes for an ai_prompt, which the engine finishes itself — a restart would otherwise hold the session a day). 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",
"session": "Xk2…",
"workflow": "onboarding_v1",
"stage": "intake",
"externalUserId": "user_42",
"state": { "company": "Acme", "industry": "retail", … },
"callback": { "url": "https://…/api/v1/sessions/Xk2…/actions/risk_check/callback", "token": "…", "ref": "…" }
}state is every answer and action result. An ai_webhook gets the same body without personal data: its vault/pii fields (and groups holding one) are left out, since it hands them to a model.
Every call comes 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 check it on each one:
ts
const expected = createHmac('sha256', SIGNING_SECRET).update(rawBody).digest('hex')
if (req.headers['x-clipless-signature'] !== `sha256=${expected}`) return 401Answer 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 | |
|---|---|
endpoint | your URL (https to a public host) |
secretRef | an environment secret sent as Authorization: Bearer … |
blocking, timeoutSeconds, visibleTo | as above |
Webhooks on your machine
api.clipless.dev only calls public https addresses, so it can't reach http://localhost. Give your local app a public address with a tunnel: cloudflared (Cloudflare's Quick Tunnels) or ngrok.
bash
# No account needed. A new address each time it starts.
cloudflared tunnel --url http://localhost:3000
# → https://quiet-river-1234.trycloudflare.combash
# Free account and auth token. One fixed address on the free plan, so no redeploying.
ngrok http 3000
# → https://your-name.ngrok-free.appKeep the address out of the workflow file, so each environment can point somewhere else. It is read when you deploy:
tsx
<Action id="paid" type="webhook" endpoint={process.env.PAYOUT_WEBHOOK_URL} />bash
PAYOUT_WEBHOOK_URL=https://quiet-river-1234.trycloudflare.com/api/webhooks/clipless \
bunx clipless deploy expense-claim.tsx --key cls_test_…When the tunnel's address changes, deploy again. Sessions already running keep the workflow they started with, so start a new one to use the new address.
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}}). It must name a field or an action — deploy refuses {{nmae}} — and one nobody answered reads as empty; a list reads as its items (a, b), anything else structured as JSON. A single-brace {name} that names nothing is left as written, so literal braces in a prompt are safe. An empty recipient fails the action instead of sending.
To link back to the session in your app, use the engine's own placeholders: {{$session}} (the session id), {{$workflow}}, {{$stage}} (the stage the action runs in) and {{$user}} (the externalUserId the session was started for). They work in every text setting, not just email:
tsx
<Action type="email" on="enter" to="{{manager_email}}" subject="A claim to review"
body="Open it: https://app.example.com/claims/{{$session}}" />Link to your own page, which mints a token when it opens. Never put a token in an email.
To a role. With to="role:reviewer", the email goes to whoever plays that role on the session (its participant, given when the session starts or with PUT /sessions/:sid/participants/:role), and it carries an Open button: a link to the hosted page that works for 7 days. Each email mints a new link, so the newest reminder always works. The action's result names the role, never the address or the link. An email to an address says only { sent }: the address usually comes from the answers, and a result is public. No participant for the role fails the action; deploy refuses a role no stage owns.
tsx
<Action id="invite" type="email" on="enter" to="role:applicant" subject="Your application" />
<Deadline after="2bd">
<Action id="nudge" type="email" to="role:applicant" subject="Still waiting on your documents" />
</Deadline>Test environments
With a cls_test_… key, email and one-time codes go only to the address your workspace registered with. A message to anyone else is recorded as not sent (sent: false, with the reason), and a code is refused, so you can test every flow on your own inbox while real people are never reached. Use a cls_live_… key for production.
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, attempts are capped (5 per code), and a session sends at most 5 codes an hour — a field action runs again whenever its field changes, so without the cap an actor could send code after code.
Each otp_send keeps its own code, and a new one replaces only that send's last. An otp_verify checks the workflow's only otp_send; with more than one, say which with send — deploy refuses it otherwise:
tsx
<Action id="email_code" type="otp_send" on="enter" to="{{email}}" />
<Action id="work_code" type="otp_send" on="enter" to="{{work_email}}" />
<Field id="code" type="text">
<Action id="check_email" type="otp_verify" code="{{code}}" send="email_code" blocking />
</Field>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 | |
|---|---|
provider | anthropic (default), openai_compatible (any chat-completions API at baseUrl; model required) or typesafe (Jev, for stages that only pick options and answer yes/no — see AI stages) |
model, effort | model choice; effort is low … max |
secretRef | the key's secret name; default ANTHROPIC_API_KEY / AI_API_KEY / TYPESAFE_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