Authoring workflows — and the tools/skills they run
How to get the LLM (or a human) to create workflows, how to add the tools a workflow node can run, how skills fit in, and how to make a workflow reusable by declaring its inputs.
1. The three layers (mental model)
| Layer | What it is | Identified by |
|---|---|---|
| Template | A reusable definition. Edit once, all future runs use the new shape. | workflows.isTemplate = true |
| Instance | A template forked to a specific mission/project/subject. | isTemplate = false, missionId/projectId/subjectId set |
| Run | One execution. Has a status + per-node output log. | a workflow_runs row (wfr-…) |
A run is not stored on the workflow — it's a separate row. Deleting a
template is a soft delete (workflows.delete sets deleted_at); the row and
its runs are preserved and it can be workflows.restored.
2. Getting the LLM to create a workflow
The agent has four workflow-authoring tools (see lib/schema/actions.ts):
workflows.create— build a new workflow from scratch.workflows.instantiate({ templateSlug, missionId? })— fork a template into an instance. Prefer this when a suitable template already exists.workflows.update({ id, patch })— rename, change status, or replacenodes/edges/inputs.workflows.run({ id, context })— execute it.
The workflows.create payload
{
"name": "Draft + review",
"isTemplate": true, // template (reusable) vs instance
"kind": "content", // free-form category
"triggerKind": "manual", // manual | event | schedule | webhook
"inputs": [ /* see §5 */ ],
"nodes": [ /* see below */ ],
"edges": [ /* see below */ ]
}Node (workflowNodeSchema):
{ "id": "n2", "type": "agent-task",
"position": { "x": 250, "y": 0 },
"data": { "label": "Generate draft", "tool": "content.drafts.generate", "params": {} } }Edge (workflowEdgeSchema):
{ "id": "e1", "source": "n1", "target": "n2", "label": "Yes" } // label optional; used by condition branchesNode types the executor understands (lib/workflows/executor.ts)
type |
Behavior |
|---|---|
trigger |
Entry point — no-op (already fired by the caller). |
agent-task / output |
Call one tool named in data.tool (or data.action) with the run context + data.params. |
ai-task |
Prompt-driven agent — runs data.prompt (plain text) as an LLM agent loop with the FULL tool set (incl. browser.do); the agent picks which tools to call. No hard tool. |
condition |
Branch — takes the Yes/No edge based on the previous node's approved/boolean output. |
human-review |
Pause the run and create a review ticket in the Inbox (resume via approve). |
foreach |
Fan out — run a sub-workflow once per item in data.iterableKey. |
emit-event |
Fire a workflow_event (data.eventKind) onto the event bus. |
workflow-call / sub-workflow |
Run another workflow as a child. |
delay / parallel / join |
Structural / scheduling (delay is currently skipped). |
⚠ The one rule that matters: tools must be REAL
A node's data.tool (alias data.action) must be the exact name of an action
that exists in the registry — e.g. content.drafts.generate,
knowledge.search, posts.publish, subjects.derive_voice,
engagements.respond, work_create.
There is no github.* namespace and no llm.* namespace. A node whose tool
is not a registered action is skipped at runtime (it silently does nothing),
so the workflow won't do what you intended. The node editor's Tool/action
dropdown only lists real actions; the agent gets the same constraint from its
handbook and from the workflows.create tool description. If the capability you
need has no action, file a capability-request instead of inventing a name
(see §6).
Worked example (valid — every tool is real)
{
"name": "Draft + review",
"isTemplate": true,
"inputs": [
{ "key": "missionId", "label": "Mission", "type": "mission", "required": true },
{ "key": "subjectId", "label": "Subject", "type": "subject", "required": true }
],
"nodes": [
{ "id": "n1", "type": "trigger", "position": { "x": 0, "y": 0 }, "data": { "label": "Manual" } },
{ "id": "n2", "type": "agent-task", "position": { "x": 260, "y": 0 }, "data": { "label": "Generate draft", "tool": "content.drafts.generate" } },
{ "id": "n3", "type": "human-review", "position": { "x": 520, "y": 0 }, "data": { "label": "Review draft" } }
],
"edges": [
{ "id": "e1", "source": "n1", "target": "n2" },
{ "id": "e2", "source": "n2", "target": "n3" }
]
}ai-task — the prompt-driven agent node
When a step needs judgment or several tools (browse → classify → draft), use an
ai-task node instead of a hard tool. Its data.prompt is plain-text
instructions; the executor runs an LLM agent loop with the full tool registry
(every native action incl. browser.do, plus Composio) + the run context, and
the agent decides which tools to call.
{ "id": "n2", "type": "ai-task", "position": { "x": 260, "y": 0 },
"data": { "label": "Scan + draft",
"prompt": "Use browser.do to open the subject's GitHub and read their recent activity. Classify it, pick what best matches the mission objectives, then call content.drafts.generate to draft social posts with that information." } }- Defaults to Claude (the strongest multi-tool model + the schema the registry is authored against); override per node with
data.model. - Output =
{ summary, toolsUsed, stepCount, ...lastToolResult }, merged into the run context for downstream nodes. - This is how
browser.do(the browser tool) reaches workflows — it's in the registry the ai-task agent wields. (A hardagent-tasknode can also callbrowser.dodirectly as a single tool.) - In the canvas, an
ai-tasknode shows an Instructions textarea instead of the Tool/action dropdown.
3. Authoring in the UI (human path)
/workflows → Templates tab opens the canvas:
- Pick/create a workflow in the top-left selector, or From template….
- Drag nodes from the Nodes palette; click a node to open Configure node.
- The Tool / action field is a searchable dropdown of real actions (invalid/legacy values show red).
- Inputs button → declare the input contract (§5). Save persists. Run executes (and links to the run). 🗑 soft-deletes.
4. How a step gets the previous step's output
There is no per-edge data mapping. The executor keeps one accumulating
runningContext, seeded from the run's input context. After each agent-task
succeeds, its output object is shallow-merged into the context; the next
node's tool is called with { ...runningContext, ...node.data.params } coerced
through that action's Zod input schema (which strips keys the action doesn't
declare).
Implications:
- Data passes between steps by key-name matching (e.g. one step outputs
missionId, a later step's action that declaresmissionIdreceives it). - Per-node
data.paramsare explicit inputs and win over inherited context. - Same-named keys are last-write-wins; non-object outputs aren't merged.
The persisted workflow_runs.nodeOutputs is a mirror for the UI + resume — a
running tool does not look the previous step up; the data is pushed into its
input.
5. Making a workflow reusable: declare its inputs
A workflow is reusable when its input contract is declared, so any future run
just supplies values. Each input (workflowInputSchema):
{ "key": "subjectId", // the context key handed to nodes
"label": "Subject",
"type": "subject", // text | number | boolean | select | subject | mission | url
"required": true,
"default": null,
"options": [], // for type:'select'
"description": "Whose GitHub activity to scan + post about." }- Declare them at create time (
inputs: […]) or later viaworkflows.update({ id, patch: { inputs } })/ the canvas Inputs dialog. workflows.run({ id, context })validates required inputs and applies defaults before walking the DAG; thecontextkeys flow into nodes via the running context (§4).- Reuse pattern: author a template with a clear input contract →
workflows.instantiateonce per mission →workflows.runeach time with that mission'scontext. Same template, many runs, different inputs.
6. Adding a new TOOL (so a node can run it)
Tools are static, code-defined registry actions — not runtime-created, not skills. Two files:
- Define the action —
lib/schema/actions.ts:export const githubListActivity = action({ name: 'github.list_activity', description: 'List recent GitHub activity for a repo.', method: 'GET', path: '/api/github/activity', input: z.object({ owner: z.string(), repo: z.string(), since: z.string().optional() }), output: z.object({ events: z.array(z.object({ type: z.string(), title: z.string(), url: z.string() })) }), mutating: false, }) // …add to the actionRegistry map: 'github.list_activity': githubListActivity, - Implement + register the handler —
lib/repositories/handlers.ts:export async function github_list_activity(input) { /* call GitHub */ } // …add to the handlers map: 'github.list_activity': github_list_activity,
Once registered, the tool is automatically: runnable by workflow nodes
(executeToolNode looks it up in handlers), exposed to the chat agent
(lib/tools/registry.ts iterates actionRegistry × handlers), selectable in
the node dropdown, and valid per the real-actions rule.
The registration is trivial; the substance is the handler body (e.g.
github.list_activity needs a GitHub token via a connection/Vault secret;
llm.classify_activity wraps the model client used by content.drafts.generate).
Dynamic tools: the chat agent also gets per-connection external tools via
Composio (lib/integrations/composio) and the Stagehand browser_act
escape hatch — but workflow nodes do not; the executor only resolves the
static handlers map. So for workflow nodes, "add a tool" always means the
2-file recipe above.
The LLM cannot create tools (they're code). When it lacks a capability it
should work_create({ kind: 'capability-request', … }) rather than invent a
tool name.
7. Skills — guidance, not tools
Skills are markdown playbooks stored in the skills table (e.g. the
Agent Handbook, slug=agent-handbook). They are runtime-creatable via
skills.create / skills.update or the Skills page.
- A skill's
allowedToolsis a permission gate listing existing action names the skill may use — it does not define a runnable tool. - Use a skill to teach the agent how/when to build or run a workflow (e.g. the handbook's "Authoring a workflow" playbook). Use an action (§6) to give it a new capability.
| Goal | Do this |
|---|---|
| New capability usable by workflow nodes | Add a native action (§6) |
| Give the chat agent an external SaaS capability | Composio connection / browser_act |
| Teach the agent a procedure (incl. how to author workflows) | Write/edit a skill |
| Make a workflow reusable | Declare its inputs (§5) + ship it as a template |
TL;DR
- Create:
workflows.create(orinstantiatea template) withnodes/edges/inputs. Nodedata.toolmust be a real registry action. - Reuse: declare inputs so every
workflows.run({ context })supplies what nodes need; ship as a template and instantiate per scope. - Add a tool: action def (
actions.ts) + handler (handlers.ts) — code, not skills. The LLM files a capability-request when a tool is missing. - Skills: guidance + an allowed-tools gate; created at runtime; they don't add runnable tools.