Skip to content
Tier

Workflow editor

Pro

A workflow an agent proposes and a person edits before it runs: a trigger (an event in your app, a schedule, or a person starting it) and a list of steps and If/Else conditions. The proposal is labelled as the agent's, by the name your app gives, and every change the person makes is marked against it: added, changed or moved, with what they removed counted and named. Back to the proposal and Undo take changes back until the person saves; nothing runs or is saved until then.

Steps are actions from your app's list; conditions are built from your app's fields, as FilterBar's rules are, and Then and Else each hold their own steps. After a condition, the next node in its list runs whichever branch ran. The schedule is schedule-editor's ScheduleRecurrence and time zone, with its next runs worked out here and saying so.

Problems are tied to what they're about. The editor finds the unfinished ones (no event, no action, a term with no value). Your app adds its own through validate, which reads the draft as it changes, or by returning them from onSave; each shows on its node in the list and the graph, and in a list by Save that moves to the node. Errors lock Save, saying why; warnings don't. Save calls back with the definition and its change hash, so your server can store exactly what the person saw and refuse anything that drifted.

The list is the screen-reader truth: nested lists in reading order, one row in the tab order at a time. Arrow keys move between rows, Alt with Up or Down moves the node a place (into and out of branches), Enter opens its editor, Delete deletes it, and S and C add a step or a condition after it (the letters are strings, so a translation can match its words). The graph view lays the same nodes out with graph-layout, Then and Else on the arrows, and a node chosen there opens under it. When the agent sends a new proposal while the person is editing, the draft stays as it is and says so; with no edits made, the new proposal is taken.

In the graph view a step or condition moves to the same places Alt with an arrow walks in the list (between two nodes, into a branch), never to a free position: the layout stays automatic. With a mouse or a pen it is dragged, a line marking the place it will go. From the keyboard or on a touch screen, the Move button on its card under the graph picks it up; then the arrow keys walk its places in reading order and Enter drops it, or a tap on a place (each a button named by its place) drops it there, and Escape or the Cancel button beside Move puts it back, each step said. A move also ends, put back and said, when focus goes to the page or nowhere. Escape goes no further, so a Dialog or Sheet the editor is in stays open. The trigger always comes first, and its Move button says so. A move is marked "Moved by you" and Undo takes it back, as in the list.

Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0 and 2.9.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/workflow-editor

Brings in: bidi, button, change-hash, checkbox, combobox, date-range-picker, field, filter-bar, graph-layout, graph-view, heading-level, heading-scope, input, label, locale, locked, motion, number-input, radio-group, schedule-editor, select, textarea, tokens, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"use client"

import * as React from "react"
import type { FilterField } from "@/components/candor/filter-bar"
import { WorkflowEditor, type WorkflowAction, type WorkflowDefinition, type WorkflowEvent, type WorkflowIssue, type WorkflowNode } from "@/components/candor/workflow-editor"

const events: WorkflowEvent[] = [
  { id: "invoice.received", label: "An invoice arrives", description: "When the intake inbox receives an invoice and reads its fields." },
  { id: "invoice.disputed", label: "A supplier disputes a payment" },
]

const actions: WorkflowAction[] = [
  { id: "request-approval", label: "Ask finance to approve", description: "Opens an approval for the finance team; the workflow waits for it." },
  { id: "mark-ready", label: "Mark as ready to pay" },
  { id: "pay", label: "Schedule the payment", description: "Adds the invoice to the next payment run." },
  { id: "notify", label: "Post to the team channel" },
  { id: "hold", label: "Put the invoice on hold" },
]

const fields: FilterField[] = [
  { id: "amount", label: "Amount", kind: "number" },
  { id: "supplier", label: "Supplier", kind: "option", options: [{ value: "new", label: "New" }, { value: "known", label: "Known" }] },
  { id: "reference", label: "Reference", kind: "text" },
]

const proposal: WorkflowDefinition = {
  trigger: { kind: "event", event: "invoice.received" },
  nodes: [
    {
      id: "big-or-new",
      kind: "condition",
      match: "any",
      rules: [
        { id: "r1", field: "amount", operator: "greaterThan", value: 5000 },
        { id: "r2", field: "supplier", operator: "is", value: "new" },
      ],
      whenTrue: [{ id: "approve", kind: "step", action: "request-approval", note: "Large amounts and new suppliers need a second look." }],
      whenFalse: [{ id: "ready", kind: "step", action: "mark-ready" }],
    },
    { id: "tell-team", kind: "step", action: "notify", note: "Say which way the invoice went." },
  ],
}

/** Your app's own rule: a payment needs an approval before it on the same path. */
function validate(definition: WorkflowDefinition): WorkflowIssue[] {
  const issues: WorkflowIssue[] = []
  const walk = (nodes: WorkflowNode[], approved: boolean): boolean => {
    let ok = approved
    for (const node of nodes) {
      if (node.kind === "step") {
        if (node.action === "request-approval") ok = true
        if (node.action === "pay" && !ok) issues.push({ node: node.id, message: "A payment needs an approval step before it on the same path." })
        if (node.action === "notify" && !node.note) issues.push({ node: node.id, message: "Say what the post is about, or the channel gets the invoice number alone.", severity: "warning" })
      } else {
        const a = walk(node.whenTrue, ok)
        const b = walk(node.whenFalse, ok)
        ok = a && b
      }
    }
    return ok
  }
  walk(definition.nodes, false)
  return issues
}

export default function Example() {
  const [saved, setSaved] = React.useState<string>()
  return (
    <div className="flex max-w-3xl flex-col gap-3">
      <WorkflowEditor
        title="Invoice intake"
        agent="Finance agent"
        proposal={proposal}
        events={events}
        actions={actions}
        fields={fields}
        validate={validate}
        timeZones={["Europe/London", "America/New_York", "Asia/Tokyo"]}
        onSave={(definition, hash) =>
          // Your server recomputes the hash from the definition it receives, stores both, and only then turns it on.
          new Promise<void>((done) =>
            window.setTimeout(() => {
              setSaved(`${definition.nodes.length} top-level steps · sha256:${hash.slice(0, 12)}…`)
              done()
            }, 900)
          )
        }
      />
      {saved && <p className="text-xs text-muted-foreground">Sent to the server: {saved}</p>}
    </div>
  )
}

<WorkflowEditor>

PropTypeDefaultDescription
proposalrequiredWorkflowDefinition—The workflow the agent proposed. The person edits a copy; this one is what their changes are marked against.
agentrequiredstring—The agent that proposed it, named by your app (never from model output).
fieldsrequiredFilterField[]—The fields a condition's terms can test, as FilterBar's.
actionsrequiredWorkflowAction[]—What a step can do.
eventsWorkflowEvent[] | undefined[]The events that can start the workflow. Without any, an event trigger isn't offered unless the proposal has one.
triggersWorkflowTrigger["kind"][] | undefined—The kinds of trigger to offer, in order. Defaults to event (when there are events), schedule and manual.
onSaverequired(definition: WorkflowDefinition, hash: string) => void | WorkflowIssue[] | Promise<void | WorkflowIssue[]>—Called by Save with the definition and its hash from `hashOf`. Return a promise to show Save as saving until it settles. Resolve with issues to say why it wasn't saved: they show on their nodes, marked as from the last save, and don't lock Save, since your server checks again. A rejection shows "Saving the workflow failed." unless you pass `error`.
validate((definition: WorkflowDefinition) => WorkflowIssue[]) | undefined—Your app's checks on the draft, run as it changes. Each issue shows on the trigger or node it names.
busyboolean | undefinedfalseYour app is saving: every edit and Save are locked, saying so.
cannotSavestring | undefined—Why this person can't save the workflow, when they can't. Locks Save with it; editing still works.
errorstring | undefined—Why the last save failed, in your app's words, shown above Save.
hashOf((definition: WorkflowDefinition) => string | Promise<string>) | undefinedchangeHashWorks out the definition's hash. Defaults to changeHash, shown as "sha256:". Your own is shown without an algorithm's name.
timeZonesstring[] | undefined—The time zones a schedule offers, as IANA names. Defaults to every zone the browser knows.
weekdaysnumber[] | undefineddefaultWeekdaysThe days "Every weekday" runs on (0 is Sunday), as `schedule-editor` takes them. Defaults to Monday to Friday.
nowDate | string | number | undefined—The moment a schedule's next runs count from. Defaults to the present moment.
view"list" | "graph" | undefined—"list" or "graph", when your app controls the view.
defaultView"list" | "graph" | undefined"list"The view at first, when your app doesn't control it. Defaults to "list".
onViewChange((view: "list" | "graph") => void) | undefined—Called with the view the person picks.
titlestring | undefined—A heading over the editor, also its accessible name. Defaults to "Workflow" for the name, with no heading shown.
headingLevelHeadingLevel | undefined—The heading's level when `title` is set. Defaults to one below the nearest `HeadingScope`, else 2.
classNamestring | undefined—Extra classes for the outer element.

Strings

The fixed words, in English by default. Change any of them under workflowEditor in <LocaleProvider strings={{ … }}>; the table is exported as workflowEditorStrings.

KeyEnglish
labelWorkflow
proposedByProposed by {agent}. Nothing runs or is saved until you save.
fromAgentFrom {agent}
addedAdded by you
changedChanged by you
movedMoved by you
noChangesNo changes to {agent}’s proposal yet.
yourChangesYour changes: {changes}
addedCount{count} added
changedCount{count} changed
movedCount{count} moved
removedCount{count} removed
triggerChangedthe trigger changed
removedListRemoved by you: {names}
newProposal{agent} changed its proposal after you started editing. Your draft is as you left it.
useNewProposalStart from the new proposal
undoUndo
nothingToUndoNothing to undo.
resetBack to the proposal
resetLockedThis is {agent}’s proposal as it was.
graphLabelWorkflow as a graph
graphHintThe same trigger and steps as the list, in the same order. Choose one to edit it under the graph.
graphPickChoose the trigger or a step in the graph to edit it here.
triggerTrigger
startsStarts
eventWhen something happens
scheduleOn a schedule
manualWhen a person starts it
eventFieldEvent
chooseEventChoose an event
eventTitleWhen: {event}
eventNoneWhen something happens (no event chosen)
scheduleTitleOn a schedule: {rule}
manualTitleWhen a person starts it
manualNoteIt runs only when someone starts it.
nextRunsNext runs
stepStep
conditionCondition
numbered{kind} {number}
actionAction
chooseActionChoose an action
noActionNo action chosen
noteNote
noteHintWhat this step is for, in words. Shown under it in the list.
conditionTitleIf {terms}
conditionEmptyIf … (no terms yet)
termTerm {number}
termText{field}: {value}
termUnfinishedan unfinished term
matchThen runs when
matchAllEvery term is true
matchAnyAny term is true
thenThen
elseElse
thenOfThen, {condition}
elseOfElse, not {condition}
emptyBranchNothing runs on this branch.
emptyWorkflowNo steps yet.
addTermAdd term
removeTermRemove term {number}
editEdit
editNodeEdit {title}
doneDone
moveUpMove {title} up
moveDownMove {title} down
atTopIt’s already at the top.
atBottomIt’s already at the bottom.
moveMove
moveNodeMove {title}
moveHintThen the arrow keys choose a place in the graph and Enter drops it there, or tap a place; Escape or Cancel puts it back.
cancelMoveCancel
cancelMoveNodeCancel moving {title}
triggerFixedThe trigger always comes first.
placeAt{index} of {count} in {where}
placeHere{place}, where it is now
pickedUp{title} picked up, at {place}. Arrow keys choose a place, Enter drops it there, Escape puts it back.
putBack{title} put back where it was.
deleteDelete {title}
deleteNoteUntil you save, Undo brings it back.
addStepStep
addConditionCondition
addStepToAdd a step to {where}
addConditionToAdd a condition to {where}
whereTopthe workflow
whereThenThen of {condition}
whereElseElse of {condition}
addStepKeyS
addConditionKeyC
keyAltAlt
keyEnterEnter
keyDeleteDelete
hintMovemove
hintReorderreorder
hintEditedit
hintDeletedelete
hintAddStepadd step
hintAddConditionadd condition
hintEither{a}/{b}
hintCombo{modifier}+{key}
hintItem{keys} {action}
addedToAdded {title} to {where}.
movedToMoved {title}: {index} of {count} in {where}.
deletedDeleted {title}. Undo brings it back.
deletedWith{ one: "Deleted {title} and the {count} step in its branches. Undo brings them back.", other: "Deleted {title} and the {count} steps in its branches. Undo brings them back." }
undoneUndone.
wasResetBack to {agent}’s proposal. Undo brings your changes back.
afterAfter {nodes}.
afterBranch{node}, {branch}
problemProblem:
warningWarning:
problems{ one: "{count} problem", other: "{count} problems" }
issueWhere{where}:
ruleUnfinishednot finished
problemsHeadingTo look at before saving
fromSaveFrom the last save:
wholeWorkflowThe workflow
needEventChoose the event that starts it.
needActionChoose what this step does.
needTermsAdd at least one term.
unfinishedTermFinish term {number}: a field, a condition and a value.
needNodesAdd at least one step.
hashHash of this workflow
saveSave workflow
savingSaving…
savedSaved.
lockedSavingThe workflow is being saved.
lockedProblems{ one: "Fix the {count} problem marked first.", other: "Fix the {count} problems marked first." }
lockedHashingWorking out the hash of the workflow…
hashFailedThe workflow’s hash couldn’t be worked out, so it can’t be saved.
alreadySavedSaved as it is. Change something to save again.
saveFailedSaving the workflow failed.

Motion

MomentFamilyWhat it does
nodearrive, fadeA step or condition the person adds fades in where it lands. The agent's nodes, and everything on screen when the editor first drew, are still. A node moved in the list is simply in its new place; one dropped in the graph slides there with GraphView's settle.

Set with --cd-motion-workflow-editor-<moment>-* in your brand file; the families are in the design reference.

Types

WorkflowEvent
Something in your app that can start a workflow: "A ticket is opened".
{ id: string; /** The event in your app's words. */ label: string; /** A line saying when it fires, shown under the choice. */ description?: string | undefined; }
WorkflowAction
Something a step can do, from your app's list: "Send an email", "Assign to a queue".
{ id: string; /** The action in your app's words. */ label: string; /** A line saying what it does, shown under the choice. */ description?: string | undefined; }
WorkflowTrigger
What starts the workflow: an event in your app, a schedule (in `schedule-editor`'s shape), or a person.
/** When the event fires; `null` while none is chosen. */ { kind: "event"; event: string | null; } /** On a schedule, its times wall-clock times in `timeZone` (an IANA name). */ | { kind: "schedule"; recurrence: ScheduleRecurrence; timeZone: string; } /** Only when a person starts it. */ | { kind: "manual"; }
WorkflowStep
A step: one action from your app's list, with a note in words.
{ /** Stable for this step. Your issues name it, and it is part of what's hashed. */ id: string; kind: "step"; /** The `id` of a `WorkflowAction`; `null` while none is chosen. */ action: string | null; /** What the step is for, in words. Shown as text, never as HTML or a link. */ note?: string | undefined; }
WorkflowCondition
An If/Else: terms built from your app's fields (as `filter-bar`'s rules), and what runs when they hold or don't.
{ /** Stable for this condition. Your issues name it, and it is part of what's hashed. */ id: string; kind: "condition"; /** "all": every term must hold. "any": one is enough. */ match: "all" | "any"; /** The terms, as FilterBar's rules: a field, an operator and a value. */ rules: FilterRule[]; /** What runs when the terms hold (Then). */ whenTrue: WorkflowNode[]; /** What runs when they don't (Else). */ whenFalse: WorkflowNode[]; }
WorkflowNode
A step or a condition. After a condition, the next node in its list runs whichever branch ran.
WorkflowStep | WorkflowCondition
WorkflowDefinition
A whole workflow: what starts it, and the nodes that run in order. This is what is saved and hashed.
{ trigger: WorkflowTrigger; nodes: WorkflowNode[] }
WorkflowIssue
A problem your app found, tied to the trigger or a node. One that names neither is about the whole workflow. Errors lock Save until they're gone; warnings are shown and don't.
{ /** The node it is about, by id. */ node?: string | undefined; /** It is about the trigger. */ trigger?: boolean | undefined; /** What is wrong, in words that say how to fix it. */ message: string; /** "error" (the default) locks Save; "warning" doesn't. */ severity?: "error" | "warning" | undefined; }

Terms used here

Change hash
The SHA-256 of a change in one canonical form (object keys sorted at every depth), the same in the browser and on the server. An approval is made against a hash, so the server can check that what runs is the change the person saw. If the person edits the change before allowing it, the hash is worked out again for the edited version.

Every term is defined in the glossary (Markdown).

Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

HeadingLevel from @/lib/candor/heading-level
A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.
1 | 2 | 3 | 4 | 5 | 6