@xano-sdk/workflows
v0.1.0
Published
Business processes on a record's status column, enforced by the server for the REST API, MCP agents and the in-app assistant alike: states and transitions with per-transition permissions, required fields, approval-gated moves and history; editable and ver
Readme
@xano-sdk/workflows
A business process on a record's status column, for a Xano SDK workspace, that the server enforces the same way for your REST API, MCP agents and the in-app assistant:
- states and the moves between them;
- who may make each move (your
@xano-sdk/rbacpermissions); - fields that must be filled in first;
- moves that wait for someone to approve;
- a history of every move: who, through what (the app, an agent, the assistant), from, to, and why.
The definition lives in your code as the default, and in the database as versions. Admins change it in the app (a node-canvas editor), publish a new version, and the server checks every version with the same rules your build checks the code with. A state that still has records can't be removed until you say where they go.
Plus an optional React frontend, @xano-sdk/workflows/react: <WorkflowEditor>, <WorkflowActions>,
<WorkflowHistory> and <WorkflowBadge>.
npm install @xano/sdk @xano-sdk/rbac @xano-sdk/workflows
# the editor also needs:
npm install @xyflow/react elkjsUse it
// xano/workflow.ts
import { c, col, expr, or, ref } from "@xano/sdk";
import { defineWorkflow } from "@xano-sdk/workflows";
import { note } from "./table/note.js"; // status: f.enum(["draft", "review", "published"])
import { user } from "./table/user.js";
import { rbac } from "./rbac.js"; // "notes.publish": ["admin"], "team.manage": ["admin"], …
export const notesFlow = defineWorkflow({
name: "notes", table: note, field: "status", user, noun: "note",
states: [
{ key: "draft", label: "Draft" },
{ key: "review", label: "In review", tone: "amber" },
{ key: "published", label: "Published", tone: "green", terminal: true },
],
transitions: [
{ from: "draft", to: "review", label: "Send for review", requires: ["body"] },
{ from: "review", to: "draft", label: "Back to draft", permission: "notes.write" },
{ from: "review", to: "published", label: "Publish", approval: true },
],
permissions: { rbac, manage: "team.manage", approve: "notes.publish" },
// Who sees a record (and so may move it): owners, and people who read everything.
visibility: {
record: or(expr(ref("record.owner_id"), "=", ref("actor.id")), rbac.has(ref("actor.role"), "notes.read_all")),
where: or(expr(col("owner_id"), "=", ref("actor.id")), rbac.has(ref("actor.role"), "notes.read_all")),
},
assistant: true, // build the in-app assistant's tools too
onChange: ({ recordId }) => [changed("note", recordId)], // your realtime publish, if you have one
});// xano/index.ts
notesFlow.register(app); // tables, functions, endpoints, assistant tools, generated testsdefineWorkflow throws when the app builds if the definition is wrong: a move to a state that doesn't
exist, a state nothing leads to, a move out of a final state, a permission or field that doesn't exist,
approval with nobody to approve, or an enum column whose values aren't exactly the state keys.
The rules every move follows
All moves go through one function, workflow_<name>/move(record_id, to, actor_id, note?). Under a lock
on the record it checks, against the live version:
- the person can see the record (
visibility); - a move from the record's current state to
toexists; - the person holds the move's permission ("Only admins can do this.");
- the move's required fields are filled ("Fill in Summary first.");
- if the move needs approval, it records a waiting request instead of moving. People who approve skip approval for their own moves in the app; an agent's or the assistant's move always waits, and the person it works for may approve it if they can approve.
Every outcome writes a history event. Approving re-checks the move: if the record moved on or the workflow changed since, the request goes stale and nothing moves.
Endpoints
All in the workflow_<name> API group, signed in with your user table. Logic lives in functions that
take the person's id; the endpoints pass auth("id").
| Route | What |
|---|---|
| GET <name>/info | The contract, the live version, can_edit, can_approve |
| GET <name>/definition | Live definition, shared draft, the editor's facts |
| POST <name>/draft · DELETE <name>/draft | Save (with problems listed) or discard the draft |
| POST <name>/publish | { spec, base, note?, migrations? } → the next version |
| GET <name>/versions · GET <name>/versions/{number} | Published versions; one's definition (0 = code default) |
| GET <name>/records/{record_id}/transitions | The moves this person could make, each allowed or with the reason |
| POST <name>/records/{record_id}/move | { to, note? } → moved or pending |
| GET <name>/records/{record_id}/history | Every move, newest first |
| GET <name>/summary | How many records are in each state (only those the person can see) |
| GET <name>/requests?view=to_decide\|mine | Waiting moves |
| POST <name>/requests/{request_id}/decide | { decision: approve\|decline\|withdraw, note? } |
MCP agents and the assistant
// with @xano-sdk/agents: the same functions, marked as the agent's
export const workflowTools = notesFlow.agentTools().map((spec) => agent.tool(spec));
agent.serve({ tools: [...workflowTools, /* … */] });
// with @xano-sdk/chatbot (assistant: true)
registerChatbot(app, { authTable: user, tools: notesFlow.assistantTools, /* … */ });Add WORKFLOW_PROMPT_RULE to the assistant's or server's instructions so the model says a move is
waiting for approval instead of claiming it happened.
Approvals: why this package keeps its own
@xano-sdk/agents approvals are for an agent's action that a person OKs (source mcp or assistant,
decided by the person or by role). A workflow's approval is different: anyone, a person in the app
included, can ask; it's decided by a permission; and approving must re-check the move against the
live definition and the record's state at that moment. So the waiting moves live in
<name>_workflow_request, decided through <name>/requests/{id}/decide (and <WorkflowActions>). An
app that wants one inbox lists both: GET approvals and GET <name>/requests.
The frontend (optional)
import "@xyflow/react/dist/style.css"; // once, for the editor's canvas
import { WorkflowProvider, WorkflowEditor, WorkflowActions, WorkflowHistory, WorkflowBadge, createWorkflowClient } from "@xano-sdk/workflows/react";
const notesFlow = createWorkflowClient({ apiBaseUrl: `${window.XANO_HOST}/api:notes-flow`, name: "notes", getToken: () => session.token });
<WorkflowProvider client={notesFlow} onRecordChanged={() => refetchNotes()}>
<WorkflowBadge state={note.status} /> {/* labelled and coloured by the live definition */}
<WorkflowActions record={note.id} /> {/* the moves, locked ones explained, approvals inline */}
<WorkflowHistory record={note.id} /> {/* the timeline */}
<WorkflowEditor noun="note" /> {/* admins edit and publish; others view, read-only */}
</WorkflowProvider>- Editor: drag from a state's right edge to another state to add a move; select anything to edit it
in the side panel (label, who can, required fields, approval; a state's name, colour, start and final).
Problems are listed as you type, by the same rules the server runs; click one to jump to it. The draft
autosaves and is shared; publishing asks where records in removed states go. Undo/redo, versions
("Use as draft"), "Tidy up" (elkjs layout), full keyboard (
?lists the shortcuts), dark mode from adarkclass on<html>, and a phone layout. - It's styled with shadcn/ui theme tokens, so it takes your theme. With Tailwind v4, let it see the
classes:
@source "../node_modules/@xano-sdk/workflows/dist";. GET <name>/definitionreturnscontract; a mismatch shows an update notice instead of guessing.
Tests
npm test (types, the factory's refusals, the shared rules, the editor model, the encoded bundle, the
React parts in happy-dom) and npm run test:live, which deploys the fixture to a local Xano engine and
runs the module's Xano tests: the server's validator against the TypeScript rules case by case, and
workflow tests for every rule above. Pass tests: { row, requires?, owner? } to defineWorkflow and
your app gets the same generated tests against its own table.
MIT
