---
name: vellum-ai/workflows
source: https://app.decimal.ai/s/vellum-ai-workflows@1/SKILL.md
source_sha256: 53b28b9ca2b9
---

A workflow is a short JS/TS script you author that runs in a sandbox and fans work
out across many short-lived **leaf agents**, orchestrated deterministically. Launch
one with `run_workflow` (inline `script` OR saved `name`, exactly one). It returns a
`runId` immediately; the run is asynchronous and you are notified in this
conversation when it completes — **do NOT poll**.

Reach for one when a job is too big, too parallel, or too important for one inline
pass. That is more than batch/map-reduce over many items — it also covers exhaustively
sweeping or auditing a large surface, researching across many sources and synthesizing,
and generating several independent attempts to judge or adversarially verify before
trusting the result. For a single task or a quick lookup, do it inline.

## The script model

These are the load-bearing invariants. Get them wrong and the run misbehaves silently.

### Scripts are SYNCHRONOUS — never `await`

Host functions block and return their result directly. Write straight-line code.

```js
const r = agent("Summarize this thread."); // r is the result, right here
```

Do **not** write `await agent(...)`, and do **not** make the script `async`. An
`async` script deadlocks on its second host call — the sandbox can suspend the main
evaluation stack but not a promise continuation.

### Every script begins with a literal `meta`

The first statement must be a pure-literal export — no computed values, template
strings, or concatenation:

```js
export const meta = {
  name: "triage-inbox",
  description: "Triage and label inbox messages",
};
```

`meta` is extracted **statically**, without executing the script, so it must be a
plain object literal with string `name` and `description`. The `name` is how a saved
workflow is referenced by `workflow(name)` and the scheduler.

### You must `return` the result

The script body runs as a function. Its result is whatever it `return`s at the top
level — a bare trailing expression (e.g. `result;`) is **discarded** and the run
finishes with no result. Always `return` the value you want surfaced.

```js
const result = agent(`Write the final summary: ${JSON.stringify(parts)}`);
return result;
```

### Determinism (this is what makes runs resumable)

Every leaf call is journaled by sequence number and input hash, so a resumed run can
replay the unchanged prefix instead of re-spawning agents. That only holds if the
script is deterministic, so `Date.now()`, `Math.random()`, and argless `new Date()`
**throw**. Pass any timestamps or random seeds in through `args`.

## Host API

All functions are synchronous from the script's perspective.

| Function                     | Returns                                        | Notes                                                                                                       |
| ---------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `agent(prompt, opts?)`       | the leaf's result                              | Runs ONE leaf. **Throws** on leaf failure (fails the whole run).                                            |
| `leaf(prompt, opts?)`        | a leaf descriptor                              | Runs nothing on its own; used inside `parallel`/`map`/`pipeline`.                                           |
| `parallel(specs)`            | `results[]`                                    | Runs an array of `leaf(...)` descriptors concurrently, results in input order. A failed leaf becomes `null` (never throws). |
| `map(items, build)`          | `results[]`                                    | `build(item, i)` returns a `leaf(...)` descriptor per item; runs them like `parallel`.                     |
| `pipeline(items, ...stages)` | `results[]`                                    | Each `stage(prev, i)` returns a `leaf(...)` descriptor (run an agent) OR a plain value (pass through unchanged — filter/transform locally, no agent spent). Per-stage barrier: stage N+1 starts only after all of stage N finishes. |
| `phase(title)`               | —                                              | Marks a named phase for progress reporting.                                                                |
| `log(msg)`                   | —                                              | Emits a progress log line.                                                                                  |
| `usage()`                    | `{ agentsSpawned, inputTokens, outputTokens }` | Live snapshot so a script can self-moderate.                                                               |
| `workflow(name, args?)`      | the child's result                             | Runs a SAVED workflow inline, depth 1 only (a child may not call `workflow()`).                            |
| `args`                       | the run input                                  | The `args` object passed to `run_workflow`.                                                                |

Use `agent` for a single sequential leaf (throws on failure). Use `parallel`/`map`/
`pipeline` for fan-out (a failed leaf is `null`, so a batch survives a few bad items).

## Leaf options (`opts` for `agent` / `leaf`)

| Option    | Type                       | Effect                                                                                  |
| --------- | -------------------------- | --------------------------------------------------------------------------------------- |
| `schema`  | JSON Schema object literal | Forces structured output via a tool. A schema leaf runs with **no tools** — no `file_read`/`file_list`/`recall`/`web_search`, so it **cannot read files or recall memory** (pure judge/extractor). Pass anything it must judge **inline** in the prompt; a schema leaf told to "read these files" answers from the model's prior, not real data. Use a plain JSON Schema literal, not Zod. |
| `label`   | string                     | Short display/diagnostic label for the leaf.                                            |
| `profile` | string                     | Overrides the model profile. Must exist in `llm.profiles` or the leaf throws. See [Listing profiles](#listing-available-profiles). |
| `persona` | boolean                    | `true` makes the leaf speak AS the assistant (identity + memory) — use for output meant to be in the assistant's voice. Default is anonymous — use for impartial judging/extraction. |

`persona: true` is the costly path (it runs the full memory-injection pipeline). Use
it only for the few leaves whose output must be in the assistant's voice; keep bulk
judging/extraction anonymous.

## Capabilities — the single consent point

The `capabilities` argument to `run_workflow` declares **once**, up front, what the
run's leaves may do. There are **no per-call permission prompts inside a running
workflow**.

```jsonc
{
  "tools": ["file_write", "gmail_send"], // side-effecting tools granted to leaves
  "hostFunctions": [],                    // host-function names the run may invoke
  "persona": true                         // grant leaves persona (identity + memory)
}
```

- **Read-only baseline** (available to **tool** leaves, no declaration, no launch prompt):
  `file_read`, `file_list`, `recall`, `web_search`. **A schema leaf gets none of these**
  (it runs as a single forced-tool-choice call) — pass it inline content, never tell it to read.
- **`web_fetch` is NOT in the baseline** — an outbound fetch is side-effecting (its
  URL can exfiltrate read data), so a leaf that must fetch a URL has to declare
  `"web_fetch"` in `capabilities.tools`.
- **Declaring ANY side-effecting tool** (writes, sends, shell, `web_fetch`, …) or
  host function makes the LAUNCH prompt the user for approval **once** — that single
  approval covers the whole run. A read-only run (no declared tools) launches with no
  prompt. Declare the minimum you need.

Runs are autonomous but BOUNDED by a per-run agent cap — spend is structurally
capped and you cannot exceed it.

## Listing available profiles

Before choosing a `profile` for a leaf, look up the valid values rather than guessing
— an unknown profile throws.

- **Preferred (model-accessible):** call `manage_workflows` with action
  `list_profiles`. It returns the profile names defined in `llm.profiles` plus the
  workspace-wide active profile.
- The same data is served by the daemon route `GET config/llm/profiles` (operationId
  `llm_profiles_list`), which clients use to populate profile dropdowns.

Omit `profile` to use the default: a persona leaf mirrors the main agent (the active
profile floats above the call-site default); an anonymous leaf uses the cost-optimized
`workflowLeaf` default.

## Run management

Use `manage_workflows` to inspect and control runs:

| Action          | Requires   | Purpose                                                          |
| --------------- | ---------- | ---------------------------------------------------------------- |
| `status`        | `run_id`   | Status + agent/token counts for one run (NOT the result).       |
| `get_result`    | `run_id`   | The full result of a finished run.                              |
| `list_runs`     | —          | Recent runs, newest first.                                      |
| `abort`         | `run_id`   | Signal an in-flight run to abort.                               |
| `resume`        | `run_id`   | Resume an `interrupted` run (see below).                        |
| `list_profiles` | —          | List defined profiles + the active profile (for leaf `profile`).|

The completion notification injected when a run finishes carries a **truncated**
preview of the result (large results are cut off). To read the complete result,
call `manage_workflows` with action `get_result` and the `run_id` — `status`
deliberately omits the result to stay lightweight.

### Crash recovery / resume

Resume is **not automatic**. If the assistant restarts mid-run, the run is reconciled
to status `interrupted` (the agent/token accounting is preserved so the agent cap
still carries across the restart). It sits there until you explicitly resume it by
`run_id` via `manage_workflows` action `resume`. Resuming re-invokes the engine with
the same `runId`: the journal **replays the completed prefix** without re-spawning (or
re-paying for) finished leaves, then continues from the first unfinished leaf under the
run's originally-declared capabilities. Only `interrupted` runs are resumable; a
`completed` / `failed` / `aborted` run is terminal.

## Worked example

Score each inbox item in parallel (anonymous schema leaves), then write one summary in
the assistant's voice (a single persona leaf). The item list comes in via `args` —
never fetched inside the script.

```js
export const meta = {
  name: "triage-inbox",
  description: "Score and summarize inbox items",
};

phase("score");
const scored = map(args.items, (item) =>
  leaf(`Rate this message's urgency 0-10 with a one-line reason:\n${item.subject}\n${item.body}`, {
    label: `score:${item.id}`,
    schema: {
      type: "object",
      properties: { urgency: { type: "number" }, reason: { type: "string" } },
      required: ["urgency", "reason"],
    },
  }),
);

phase("summarize");
const summary = agent(
  `Here are scored inbox items. Write a short triage summary, highlighting anything urgent:\n${JSON.stringify(scored)}`,
  { persona: true },
);
return summary;
```

A failed scoring leaf shows up as `null` in `scored`; the run continues.