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 replace nodes/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 branches

Node 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 hard agent-task node can also call browser.do directly as a single tool.)
  • In the canvas, an ai-task node shows an Instructions textarea instead of the Tool/action dropdown.

3. Authoring in the UI (human path)

/workflowsTemplates 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 declares missionId receives it).
  • Per-node data.params are 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 via workflows.update({ id, patch: { inputs } }) / the canvas Inputs dialog.
  • workflows.run({ id, context }) validates required inputs and applies defaults before walking the DAG; the context keys flow into nodes via the running context (§4).
  • Reuse pattern: author a template with a clear input contract → workflows.instantiate once per mission → workflows.run each time with that mission's context. Same template, many runs, different inputs.

6. Adding a new TOOL (so a node can run it)

Tools are static, code-defined registry actionsnot runtime-created, not skills. Two files:

  1. Define the actionlib/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,
  2. Implement + register the handlerlib/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 allowedTools is 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 (or instantiate a template) with nodes/edges/inputs. Node data.tool must 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.