Patterns / Agent-proposed automation
Let an agent propose an automation
Asked to automate something, an agent can propose the whole workflow: what starts it, the steps, and the conditions that decide which steps run. A person should read it, change what's wrong, and turn it on themselves. Until they save, nothing runs.
Have the agent propose through a tool whose input is a WorkflowDefinition: a trigger, and nodes that are steps or conditions. Check the input's shape on your server, store it as the proposal with its agent, and send it to the page. The agent picks from your lists (the events that can start a workflow, the actions a step can take, the fields a condition can test), so check those ids too: the editor shows an id it doesn't know as the id, and your checks should say it doesn't exist.
WorkflowEditor shows the proposal as the agent's, by the name your app gives it, and marks everything the person changes against it: added, changed, moved, and what they removed. Conditions are built from your fields as FilterBar's rules are, a schedule is schedule-editor's shape, and Undo and Back to the proposal take changes back. The list is what a screen reader reads and works from the keyboard; the graph view lays the same nodes out, with Then and Else on the arrows.
Your rules go in one function that runs in two places: as validate while the person edits, so each problem shows on its node and errors lock Save with the reason, and on your server before it stores anything. Save calls onSave(definition, hash). Your server recomputes the hash from the definition it received, checks the rules again, and stores both. What runs is read from what was saved, by its hash, never from the agent's proposal.
The components
- Workflow editorProThe agent's trigger, steps and conditions, edited by a person with every change marked, saved with its change hash (Pro).
- Change hashProThe same sha256 of canonical JSON in the browser and on the server, so the saved definition is exactly the one the person saw.
- Filter barProThe fields, conditions and values a condition's terms are built from; `useFilterChips` words them anywhere else (Pro).
- Schedule editorProThe full editor for a scheduled job, whose `ScheduleRecurrence` a schedule trigger uses (Pro).
- Graph viewProThe graph the editor's graph view draws; on its own, a read-only view of a saved workflow (Pro).
- LockedFreeSave says why it can't be used: a problem to fix, a save in progress, or a person who can't save.
See it working
The code
Your rules, in one function the editor runs as validate and the server runs before it saves.
import type { WorkflowDefinition, WorkflowIssue, WorkflowNode } from "@/components/candor/workflow-editor"
/** Your app's lists: what can start a workflow, what a step can do, and what a condition can test. */
export const EVENTS = ["invoice.received", "invoice.disputed"]
export const ACTIONS = ["request-approval", "mark-ready", "pay", "notify", "hold"]
export const FIELDS = ["amount", "supplier", "reference"]
/**
* The rules your app holds a workflow to, run in the browser as the person edits (WorkflowEditor's `validate`) and
* again on the server before it saves. Each issue names the node it is about, so the editor shows it there.
*/
export function checkWorkflow(definition: WorkflowDefinition): WorkflowIssue[] {
const issues: WorkflowIssue[] = []
if (definition.trigger.kind === "event" && definition.trigger.event !== null && !EVENTS.includes(definition.trigger.event))
issues.push({ trigger: true, message: "This event doesn't exist in the app. Choose another." })
// A payment needs an approval before it on every path that reaches it.
const walk = (nodes: WorkflowNode[], approved: boolean): boolean => {
let ok = approved
for (const node of nodes) {
if (node.kind === "step") {
if (node.action !== null && !ACTIONS.includes(node.action)) issues.push({ node: node.id, message: "This action doesn't exist in the app. Choose another." })
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." })
} else {
for (const rule of node.rules) if (!FIELDS.includes(rule.field)) issues.push({ node: node.id, message: `There's no field called ${rule.field}.` })
const a = walk(node.whenTrue, ok)
const b = walk(node.whenFalse, ok)
ok = a && b
}
}
return ok
}
walk(definition.nodes, false)
return issues
}
The route that saves an edited workflow: the hash checked, the rules run again, and the definition stored with who saved it.
import { changeHash } from "@/lib/candor/change-hash"
import type { WorkflowDefinition } from "@/components/candor/workflow-editor"
import { checkWorkflow } from "@/lib/workflow-checks"
import { db } from "@/lib/db" // yours: proposals, saved workflows, the audit log
import { viewerOf } from "@/lib/auth" // yours: who is asking
/** Save a workflow a person edited from an agent's proposal. The body is `{ definition, hash }`. */
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const viewer = await viewerOf(request)
if (!viewer) return Response.json({ error: "Sign in to save workflows." }, { status: 401 })
// Check the shape with zod (or your own schema) before trusting it; this sketch assumes it's a WorkflowDefinition.
const { definition, hash } = (await request.json()) as { definition: WorkflowDefinition; hash: string }
// The same canonical JSON as the browser's, so the hash names exactly the definition that arrived.
if ((await changeHash(definition)) !== hash) return Response.json({ error: "The workflow doesn't match its hash. Reload and save again." }, { status: 409 })
// Your rules again, on the server: the browser's check is for the person, this one is the gate.
const issues = checkWorkflow(definition)
if (issues.some((i) => i.severity !== "warning")) return Response.json({ issues }, { status: 422 })
const proposal = await db.workflowProposal(id)
if (!proposal) return Response.json({ error: "There's no such proposal." }, { status: 404 })
const at = new Date().toISOString()
// Store the definition with its hash and who saved it. What runs is read from here by its hash, never from the proposal.
await db.saveWorkflow({ proposalId: id, definition, hash, savedBy: viewer.id, at, agent: proposal.agentId })
await db.audit({ at, who: viewer.id, what: `Saved workflow ${id} (sha256:${hash}), proposed by ${proposal.agentName}.` })
return Response.json({ saved: true })
}
The page's side: WorkflowEditor with your lists and rules, and Save sending the definition and its hash.
"use client"
import * as React from "react"
import { WorkflowEditor, type WorkflowDefinition, type WorkflowIssue } from "@/components/candor/workflow-editor"
import { checkWorkflow } from "@/lib/workflow-checks"
import { actions, events, fields } from "@/lib/workflow-lists" // yours: the labels for the ids in workflow-checks
export function EditWorkflow({ id, proposal, agentName }: { id: string; proposal: WorkflowDefinition; agentName: string }) {
const [failure, setFailure] = React.useState<string>()
return (
<WorkflowEditor
title="New automation"
agent={agentName}
proposal={proposal}
events={events}
actions={actions}
fields={fields}
validate={checkWorkflow}
error={failure}
onSave={async (definition, hash) => {
setFailure(undefined)
let res: Response
try {
res = await fetch(`/api/workflows/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ definition, hash }),
})
} catch {
setFailure("No answer from the server, so it isn't known whether the workflow was saved. Reload to see.")
throw new Error("no answer")
}
const body = (await res.json()) as { issues?: WorkflowIssue[]; error?: string }
// The server's own problems go back to the editor, which shows each on its node.
if (res.status === 422 && body.issues) return body.issues
if (!res.ok) {
setFailure(body.error ?? "The workflow wasn't saved.")
throw new Error(body.error)
}
}}
/>
)
}
Install
Each component brings in what it depends on. The free ones install as they are; Pro and Premium ones need a licence key.
Good to know
- The editor never saves or runs anything itself. Nothing reaches your server until the person presses Save, and nothing should run until your server has stored the definition.
- A step's note is shown as text, never as HTML or a link, since the agent may have written it.
- If the agent sends a new proposal while the person is editing, their draft stays as it is and the editor offers to start from the new one. Without edits, the new proposal is taken.
- Problems your server finds can come back from
onSaveas issues: each shows on its node, marked as from the last save, and nothing is marked saved.