Getting started
This walks through one workflow from a file on disk to a running session: an expense claim that an employee submits and a manager reviews.
1. Install
bash
bun add @clipless/reactThe package holds the components, the hooks and the clipless CLI. It renders a workflow; the engine runs it — nothing in the package decides routing or runs actions.
The CLI runs on Bun
clipless reads your workflow file directly (TSX included), so it needs Bun installed: curl -fsSL https://bun.sh/install | bash. Your app itself can use npm, pnpm or anything else — only the command line needs Bun.
2. Write the workflow
A workflow is one file. Stages are the people involved (each has an actor role), steps are pages within a stage, fields are the answers.
tsx
// expense-claim.tsx
import { Field, Stage, Step, Workflow, type WorkflowProps, useWorkflowNav } from '@clipless/react'
function Nav() {
const { next, canGoNext, isSubmitting } = useWorkflowNav()
return (
<button type="button" disabled={!canGoNext || isSubmitting} onClick={next}>
Continue
</button>
)
}
export default function ExpenseClaim(props: Partial<WorkflowProps>) {
return (
<Workflow id="expense_claim_v1" version="1.0.0" {...props}>
<Stage name="claim" actor="role:employee" label="Your claim">
<Step name="details">
<Field name="description" type="string" label="What was it for?" required />
<Field name="amount" type="number" label="Amount (USD)" required min={1} />
<Field name="receipt" type="file" label="Receipt" accept=".pdf,image/*" required />
</Step>
</Stage>
<Stage name="review" actor="role:manager" label="Manager review">
<Step name="decision">
<Field
name="decision"
type="select"
label="Decision"
required
options={[{ value: 'Approve' }, { value: 'Reject' }]}
/>
</Step>
</Stage>
<Nav />
</Workflow>
)
}A <Field> without children renders a default input for its type. Give it children to use your own components — see Components.
next() moves to the next step, and on the last step submits the stage.
3. Try it against a test environment
<Workflow> always runs against the engine: it renders the stage the token's role owns and sends what they do; the engine validates, runs the actions and routes. To try a workflow, deploy it with a cls_test_… key (step 4), start a session (step 5) and render it with that token (step 6). Test environments are free, and email and one-time codes reach only your workspace's own address.
To click through as every role without deploying anything, the playground runs the examples in your browser with an actor switcher, the ledger and the state beside the form.
4. Deploy it
Deploying reads the workflow out of your file and sends it to the engine.
bash
export CLIPLESS_API_KEY=cls_test_… # a secret key for your test environment
bunx clipless deploy expense-claim.tsxEach deploy is kept: sessions already running stay on the version they started with. It goes to the key's environment only, so what you try in test does not change production. When it is ready, deploy the same file with a cls_live_… key.
5. Start a session from your backend
A session is one run of the workflow. Your backend starts it with the API key and gets a token for the first actor:
bash
curl -X POST https://api.clipless.dev/api/v1/workflows/expense_claim_v1/sessions \
-H "authorization: Bearer $CLIPLESS_API_KEY" \
-H 'content-type: application/json' \
-d '{ "role": "role:employee", "externalUserId": "user_42" }'json
{ "session": "Xk2…", "token": "eyJ…", "created": true, "state": { … } }externalUserId is your own id for the person; the engine only stores it.
6. Render with the token
Pass the token to the same component. <Workflow> now talks to the engine: the server validates, runs actions and routes; the browser renders.
tsx
<ExpenseClaim runtimeToken={token} />The token is scoped to one session and one role. Never send the API key to a browser.
7. Hand off to the next person
When the employee submits, the session moves to the manager's stage. The engine knows the next step belongs to role:manager. Your app knows which person that is. The handoff joins the two.
Find what is waiting on a role. Ask for sessions whose current stage the role owns:
bash
curl "https://api.clipless.dev/api/v1/workflows/expense_claim_v1/sessions?role=role:manager&status=in_progress" \
-H "authorization: Bearer $CLIPLESS_API_KEY"json
{ "sessions": [
{ "session": "Xk2…", "currentStageId": "review", "currentRole": "role:manager", "yourTurn": true,
"externalUserId": "user_42", "status": "in_progress", … }
] }That list covers every manager. Narrow it with your own data: whose manager is user_42? The result is the manager's inbox.
Mint the token when they open it. When the manager opens a claim in your app, your backend checks your own rules (is this person allowed to review this claim?), then mints a token for the role, naming the person:
bash
curl -X POST https://api.clipless.dev/api/v1/sessions/Xk2…/tokens \
-H "authorization: Bearer $CLIPLESS_API_KEY" \
-H 'content-type: application/json' \
-d '{ "role": "role:manager", "externalUserId": "mgr_7" }'json
{ "token": "eyJ…", "expiresAt": "…", "yourTurn": true, "currentStageId": "review", "status": "in_progress" }Render <Workflow runtimeToken={token}> with it, the same as step 6. yourTurn tells you what to show. If it is true, the review is open for them. If it is false, the claim is still with the employee or already decided. A token for a role that owns no stage is refused.
Mint tokens just in time, not in advance. A token lasts a day (expiresIn, in seconds, up to 30 days), so do not put one in an email. Email a link to your own page, and mint the token when the page loads. externalUserId is who you say the person is. It travels in the token, but the engine does not check it, and the ledger records the role, not the person. Deciding who may act for a role is your app's job.
Keep the token fresh. Someone who leaves the page open longer than a day would otherwise lose their connection partway through. Give <Workflow> a way to get a new token, from a route in your backend that mints one for the same session and role:
tsx
<ExpenseClaim
runtimeToken={token}
refreshToken={() => fetch(`/api/claims/${claimId}/token`).then((r) => r.text())}
/>It renews a few minutes before the token expires, and again if the engine ever refuses it. The person never notices, and nothing they typed is lost.
Without refreshToken, an expired token stops the workflow in that tab. useWorkflowState() then has tokenExpired: true. Answers the engine had not received yet are kept in the browser and sent after a reload, once your page has minted a new token. Show a notice and reload:
tsx
function SessionTimeout() {
const { tokenExpired } = useWorkflowState()
useEffect(() => {
if (tokenExpired) setTimeout(() => location.reload(), 3000)
}, [tokenExpired])
return tokenExpired ? <Toast>Your session timed out. Reloading…</Toast> : null
}Know when it is their turn. To notify the manager as soon as the claim arrives, put an action in their stage that runs on="enter". The engine calls it every time the stage opens, including after a send-back:
tsx
<Stage name="review" actor="role:manager">
<Action
id="notify_manager"
type="email"
on="enter"
to="{{manager_email}}"
subject="A claim to review"
body="Open it: https://app.example.com/claims/{{$session}}"
/>
…
</Stage>{{$session}} is the session id. Your page at that link mints the token. With type="webhook", your backend gets session, workflow, stage and externalUserId in the body and can notify people its own way. The engine can't call localhost, so to try a webhook on your machine, give it a public address with a tunnel: see Webhooks on your machine.
A browser that has the session open hears the session move (another actor, a provider callback) and updates itself.
8. See what happened
bash
bunx clipless sessions expense_claim_v1 # what is in flight
bunx clipless session expense_claim_v1 Xk2… # one session, event by event9. Keep the engine's settings out of the browser
The workflow file holds everything the engine needs, including AI prompts, webhook addresses and email text. Your bundler would ship all of it to every browser. Add the Clipless plugin and it doesn't:
ts
// next.config.ts
import { withClipless } from '@clipless/react/next'
export default withClipless({})Actions keep only their id and trigger. Import ./expense-claim.tsx?role=manager and a page gets only the manager's stages. Vite, webpack, Rollup, esbuild, Rspack and Bun work too. See What ships to the browser.
Next
- The sandbox — this expense claim as a complete Next.js app, with the manager and finance, tokens minted on the server, and the payout webhook.
- Concepts — what a stage, an actor and the ledger are, precisely.
- Actions — email the manager when a claim arrives, run a check, generate a PDF.
- Loop-backs and rounds — let the manager send the claim back.