---
title: Workflows as Tools
description: Define durable workflow tools that wait for people, webhooks, or timers without holding compute.
url: "https://eve.dev/docs/tools/workflows"
docs_index: /llms.txt
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

A workflow tool is a tool whose calls can wait for a person, a webhook, or a timer without holding
compute. Define one with `defineWorkflowTool` from `eve/tools` under `agent/tools/`. The model sees
it under its path-derived name, such as `deploy`.

Use a workflow tool when a call must wait for input, delegate work to subagents, or run retryable
steps over a long period. Each call starts a Workflow run that survives restarts. For a tool that
returns in one step, use [`defineTool`](/docs/tools) instead.

A workflow tool defines exactly one entry point, which decides how its calls run:

| Entry point           | How a call runs                                                                                             |
| --------------------- | ----------------------------------------------------------------------------------------------------------- |
| `execute(input, ctx)` | The turn waits until the workflow finishes, then gets its output as the tool result. Waits hold no compute. |
| `task(input, ctx)`    | A [task](#run-calls-as-tasks-task): the model gets a receipt at once, and the result arrives later.         |
| `serve(receive, ctx)` | A [resumable task](#resumable-tasks-serve) that the model can send more calls by its `taskId`.              |

See [Tasks](/docs/tools/tasks) for choosing between them and for what the model sees while tasks
work.

Workflow tools use the [Workflow SDK](https://workflow-sdk.dev): `"use workflow"`, `"use step"`,
`createHook`, `createWebhook`, `sleep`, retries, and replay. eve adds `ctx.ask` to ask a person a
question and `ctx.agent` to open a session with another agent. `ctx.agents` lists the agents the
call can open.

## Define a workflow tool

```ts title="agent/tools/deploy.ts"
import { defineWorkflowTool } from "eve/tools";
import { z } from "zod";
import { computePlan, runDeploy, type DeployPlan } from "../lib/deploy";

export default defineWorkflowTool({
  description: "Deploy a service to production. Pauses for a human to approve the plan.",
  inputSchema: z.object({ service: z.string() }),
  async execute({ service }, ctx) {
    "use workflow";
    const plan = await planDeploy(service);
    const answer = await ctx.ask({
      prompt: `Deploy ${service}?\n\n${plan.summary}`,
      display: "confirmation",
      options: [
        { id: "approve", label: "Deploy", style: "primary" },
        { id: "cancel", label: "Cancel" },
      ],
    });

    if (answer.status !== "answered" || answer.optionId !== "approve") {
      return { deployed: false, reason: "rejected" };
    }
    return { deployed: true, url: await applyDeploy(plan) };
  },
});

async function planDeploy(service: string) {
  "use step";
  return computePlan(service);
}

async function applyDeploy(plan: DeployPlan) {
  "use step";
  return runDeploy(plan);
}
```

The turn waits while the person reads the plan. When they answer, minutes or days later, the run
continues, deploys, and returns one tool result.

### Rules

- Default-export `defineWorkflowTool({ ... })` with exactly one entry point: `execute`, `task`, or
  `serve`. Defining none, or more than one, throws when the tool is defined.
- Write the entry point inline, or reference a top-level `async function` in the same module or an
  imported application module. `execute` and `task` can be async generators; `serve` can't.
- Start the entry point with `"use workflow"`. A missing directive is a build error, even if another
  function in the module has one.
- `"use step"` marks a top-level `async function` in the tool module, or any module it imports, as a
  step. The workflow body replays and must stay deterministic, so put side effects, clocks,
  randomness, `process.env`, and Node.js APIs in steps. Keep values you need after a wait in local
  variables in the body.
- Import durable primitives such as `createHook`, `sleep`, and `FatalError` from `eve/workflow` in
  the body. Call `start`, `getRun`, and `resumeHook` from `eve/workflow/api` in routes or steps, not
  in the body. Both use eve's bundled Workflow SDK, so your app needs no `workflow` dependency. See
  [Workflow imports](#workflow-imports).
- The tool's input must be a JSON object.
- Workflow bodies are for static tools under `agent/tools/`, not tools a `defineDynamic` function
  returns. `"use workflow"` in `defineTool`, a bare tool object, a channel handler, or a schedule
  handler fails the build. To start a session from a channel or schedule, use the
  [channel operations](/docs/channels/custom#channel-operations-and-session-handles) or
  [schedule handler](/docs/schedules#handler-form-run) APIs.
- Workflow imports resolve `paths` aliases from `tsconfig.json` or `jsconfig.json`, including in a
  workspace package. An [agent workspace](/docs/concepts/project-structure#several-root-agents)
  member can import workflow and step modules from the shared root package, such as `lib/`. An
  unresolved workflow import fails the build and names the missing import.

The context depends on where your code runs:

| Where                               | Type                      | Members                                                                                                                    |
| ----------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `execute` or `task` body            | `WorkflowToolContext`     | `session`, `callId`, `toolName`, `abortSignal`, `channel`, `agent`, `agents`, `ask`                                        |
| `serve` body                        | `WorkflowServeContext`    | `session`, `toolName`, `channel`, `agent`, `agents`, `ask`, `reply`; each received call carries `callId` and `abortSignal` |
| `"use step"` helper that gets `ctx` | `WorkflowStepToolContext` | `session`, `callId`, `toolName`, `abortSignal`, `channel`, `getSandbox`, `getToken`, `requireAuth`, `to`                   |

Call `ctx.agent()` and `ctx.ask()` and read `ctx.agents` in the workflow body. Pass a step only the
serializable values it needs. Call `getSandbox`, `getToken`, `requireAuth`, and `to` in a step, because
the workflow body throws when it calls them. Ordinary tools, channel handlers, and schedule handlers
don't receive `agent`, `agents`, or `ask`. Import `WorkflowStepToolContext` from `eve/tools` to type
step helpers.

### Workflow imports

Import durable primitives from `eve/workflow`, and run and hook operations from `eve/workflow/api`:

```ts
import { createHook, sleep } from "eve/workflow";
import { resumeHook } from "eve/workflow/api";
```

| Export                                 | Entry              | What it does                                                                                                                                                                             |
| -------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createHook<T>({ token?, metadata? })` | `eve/workflow`     | Returns a `WorkflowHook<T>`. Await it for the next payload, or iterate it with `for await`. `metadata` is JSON stored with the hook.                                                     |
| `createWebhook({ respondWith? })`      | `eve/workflow`     | Returns a `WorkflowWebhook` whose `url` resumes it with the incoming `Request`. With `respondWith: "manual"`, answer each `WorkflowWebhookRequest` with `request.respondWith(response)`. |
| `sleep(duration)`                      | `eve/workflow`     | Waits for a duration such as `"1h"` or a number of milliseconds, or until a `Date`.                                                                                                      |
| `getWorkflowMetadata()`                | `eve/workflow`     | Returns the run's `workflowName`, `workflowRunId`, `workflowStartedAt`, and `url`.                                                                                                       |
| `FatalError`, `RetryableError`         | `eve/workflow`     | Throw `FatalError` to fail without a retry. Throw `RetryableError`, with an optional `{ retryAfter }`, to retry a step.                                                                  |
| `start(workflow, args)`                | `eve/workflow/api` | Starts a `"use workflow"` function on the agent's own workflow queue and deployment. It takes no options; omit `args` when the function takes none.                                      |
| `getRun(runId)`                        | `eve/workflow/api` | Returns a `WorkflowRun<T>` with `runId`, `status`, `returnValue`, and `cancel()`. `start` returns the same type.                                                                         |
| `resumeHook(token, payload)`           | `eve/workflow/api` | Sends a payload to the hook with that token.                                                                                                                                             |

A hook's `token` resumes it. Pass a fixed `token` to `createHook` when a route must find the hook later. `await hook.getConflict()` registers the hook before you hand out its token, and resolves to the run that already holds the token, or `null`. Call `dispose()`, or declare the hook with `using`, to release the token.

Import `WorkflowHook`, `WorkflowHookOptions`, `WorkflowWebhook`, `WorkflowWebhookOptions`, `WorkflowWebhookRequest`, and `WorkflowMetadata` from `eve/workflow` for explicit types.

Only these exports are public. A bare `workflow` or `workflow/*` import in an application module fails the build with an error that names these entries.

## Wait for the result

An `execute` call works like any other tool call. The turn waits for the body to finish and gets its
output as the tool result. A wait inside the body pauses the workflow while the call stays pending.
Cancelling the turn cancels the run, and a new message aborts `ctx.abortSignal`; see
[Stop early for a new message](#stop-early-for-a-new-message).

To keep the conversation going while a call works, [define `task` instead](#run-calls-as-tasks-task).

### How suspension works

A workflow pauses, holding no compute, when it awaits an unresolved `ctx.ask`, hook, webhook, or
`sleep`. When the answer, event, or timer arrives, the workflow replays, reuses recorded step
results, and continues past the wait. Put side effects in `"use step"` functions so replay doesn't
repeat them.

In this body, the yield reports progress and the await pauses:

```ts
async *execute(input, ctx) {
  "use workflow";
  yield { status: "awaiting approval" };
  const answer = await ctx.ask({ prompt: "Continue?", display: "confirmation" });
  return { answer };
}
```

The tool call stays pending until the answer arrives and the body returns. An ordinary Promise or
Node.js timer inside a step doesn't pause the workflow or survive a restart.

## Run calls as tasks: `task`

Define `task(input, ctx)` in place of `execute` when the conversation should continue while a call
works. Examples are a twenty-minute deploy or research the model may not need right away. Waiting
alone doesn't need a task, because a question, sleep, or approval in an `execute` call already holds
no compute.

```ts title="agent/tools/deploy.ts"
import { defineWorkflowTool } from "eve/tools";
import { z } from "zod";
import { runDeploy } from "../lib/deploy";

export default defineWorkflowTool({
  description: "Deploy a service.",
  inputSchema: z.object({ service: z.string() }),
  async task({ service }, ctx) {
    "use workflow";
    return await runDeploy(service, ctx.abortSignal);
  },
});
```

A `task` body gets the same `WorkflowToolContext` as an `execute` body. Returning finishes the call,
a throw fails it, and an `async *task` generator can `yield` progress. Steering never aborts a task.

Each call is a task with its own id, `<tool>-<6 characters>`:

- **Receipt:** the call's tool result is a receipt, such as
  `Started task deploy-4hd8sa. Its result will arrive in a <task_result> message.`, and the model
  continues at once.
- **Result:** the body's return value or its error arrives later as a `<task_result>` block in a
  `task.result` message. eve adds the message before the model's next step. `toModelOutput` applies
  to the result, not the receipt.

The model waits for results with `eve__task_wait` and stops a task with `eve__task_cancel`. A turn
can't end while tasks are working. See [Cancel and clean up](#cancel-and-clean-up-ctxabortsignal)
for what aborts a task, and [Tasks](/docs/tools/tasks) for what the model sees, held turns, events,
and limits.

## Resumable tasks: `serve`

Define `serve(receive, ctx)` when the model should be able to send a running task more input, such
as a revision request. A `serve` body runs once per task and gets its calls from `receive()`:

```ts title="agent/tools/release-plan.ts"
import { defineWorkflowTool } from "eve/tools";
import { z } from "zod";
import { revise, type Plan } from "../lib/release-plan";

export default defineWorkflowTool({
  description: "Draft a release plan and revise it on request.",
  inputSchema: z.object({ request: z.string() }),
  async serve(receive, ctx) {
    "use workflow";
    let plan: Plan | undefined;
    for (;;) {
      const { input, abortSignal } = await receive(); // the first call, then each by taskId
      try {
        plan = await revise(plan, input.request, { signal: abortSignal });
        ctx.reply(plan); // a result; the task stays available
      } catch (error) {
        if (!abortSignal.aborted) throw error; // cancelled: wait for the next call
      }
    }
  },
});
```

A `serve` tool's calls are tasks, with the receipts and results
[described above](#run-calls-as-tasks-task). [Tasks](/docs/tools/tasks) covers waiting, the turn
rule, who can continue a task, and the task limit.

### Receive calls and reply

eve adds an optional `taskId` to the tool's model input and explains it in the description. The
build fails if `inputSchema` declares its own `taskId`. A call without `taskId` starts a new task. Its
receipt ends with
`To send it another message, call release-plan again with taskId release-plan-7k2m9q.` A call with
`taskId` goes to that task. Its receipt starts `Sent to task release-plan-7k2m9q.` The
`taskId` must name an unfinished task this tool started, or the call fails with `UNKNOWN_TASK`.
Any caller in the session can send it.

- `receive()` resolves with the next call, `{ input, callId, abortSignal }`, with `input` validated
  by `inputSchema`. The first call is the one that started the task. Later calls arrive in order.
- eve keeps calls that arrive while the body isn't waiting. A second `receive()` while one is
  pending returns the same promise.
- A call counts as received once its promise resolves, even if it lost a `Promise.race`. The next
  `ctx.reply()` answers it, so keep a raced `receive()` promise until you read the call.
- `ctx.reply(output)` answers every call received so far. Each call reports its own
  `call.settled`, the model receives the reply once, and the task goes idle until its next call.
- A reply with no call waiting is dropped, and `ctx.ask` throws while no call is waiting.
- A reply withdraws the open questions of the calls it answers. It doesn't abort their
  `abortSignal`, so work after the reply keeps running.
- Returning answers the calls still waiting and ends the task; a throw fails them. `serve` isn't a
  generator, so it can't `yield` progress.

### Working, idle, and cancelled tasks

- A resumable task is working while one of its calls has no result, and idle otherwise. Only a
  working task holds the turn and counts toward the
  [task limit](/docs/tools/tasks#ownership-and-limits).
- One stretch of work runs from a call reaching an idle task until a reply or cancel ends its
  calls. Calls in a stretch share one `abortSignal`. The next stretch gets a new one, so a task can
  be cancelled any number of times.
- `eve__task_cancel`, `session.cancel()`, and a failed turn end the waiting calls as `cancelled`,
  abort the stretch's signal, and make the task idle.
- To stay available after a cancel, the body must catch the abort and return to `receive()` within
  30 seconds, as the example does. Otherwise the task finishes. A body that ignores the signal keeps
  working, and its reply is dropped.
- The task finishes when its body returns or throws, or when the session ends. The end of the
  session aborts the current signal and rejects a pending `receive()`.

### Calls from several callers

A later call can come in a later turn, from a different caller than the one that started the task.
Once `receive()` resolves with a call, the context describes that call:

- `ctx.session.turn` is the call's turn, and `ctx.session.auth` is the auth the session accepted the
  call with. Steps that receive `ctx` see the same values, so `getToken`, `requireAuth`, and
  connections use that call's auth.
- `ctx.agents` lists the agents the call may open, including dynamic agents selected in its turn.
- The call's `call.started` and `call.settled` events carry its `callId` and turn. So do the
  questions and sign-ins the body opens while serving it.
- A session the body opens with `ctx.agent` belongs to that call. Its `session.parent` names the
  call's `callId` and turn, even after later calls. A remote receiver accepts that parent only from
  a trusted forwarder.
- Each message to that child uses the call's auth. Remote identity follows the
  [forwarding rules](../guides/remote-agents#forwarding-the-caller-identity).
- When trace context is available, a local child's first turn uses the call's tool span as its
  parent. A remote child's first turn starts its own trace, linked to that span with
  `eve.link.type=agent.dispatch`. Later child turns start new traces.

Variables the body keeps between calls, such as `plan`, can hold another caller's output. Key
per-caller data on these `ctx.session.auth.current` fields together: `authenticator`, `issuer`,
`principalType`, and `principalId`. A `principalId` is unique only within its authenticator and
issuer. Anonymous callers, whose `current` is `null` or whose `principalType` is `"anonymous"`,
share one identity, so don't keep per-person data for them. Any caller in the session can continue
or cancel the task; see [Tasks](/docs/tools/tasks#ownership-and-limits).

Every [agent tool](/docs/subagents), including the built-in `agent` tool and remote agents, is a
`serve` tool. Each agent task holds one session with the agent and replies with the final response
of each agent turn. Unlike other `serve` tasks, cancelling an agent task ends it, so start a new
task to delegate again.

## Ask a human: `ctx.ask`

```ts
const answer = await ctx.ask(
  {
    prompt: string,
    title?: string,
    display?: "confirmation" | "select" | "text",
    options?: { id: string; label: string; description?: string; style?: "primary" | "danger" | "default" }[],
    allowFreeform?: boolean,
  },
  { signal?: AbortSignal },
);
// | { status: "answered"; optionId?: string; text?: string }
// | { status: "cancelled" }
// | { status: "unavailable" }
```

`ctx.ask` opens a question on the session, reported as `interaction.opened`, and channels show it
like a tool approval. Awaiting the answer pauses the run. The provided `ask_question` tool is built
on `ctx.ask`.

| `status`      | Meaning                                                                                                                                                                                                                              |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `answered`    | The person picked an option (`optionId`) or typed a reply (`text`).                                                                                                                                                                  |
| `cancelled`   | The question was withdrawn before anyone answered, because `signal` or the call's `abortSignal` aborted or a `serve` body replied to the call. The stream reports `interaction.settled` as `withdrawn`, so clients stop offering it. |
| `unavailable` | The session cannot ask for input, such as a scheduled run. `ctx.ask` returns this immediately.                                                                                                                                       |

A plain follow-up message answers the question when it is the only one open. The message must match
an option, or the question must allow free text. Answer a question from a subagent with a structured
response.

Race `ctx.ask` against `sleep` for a deadline:

```ts
const pending = ctx.ask({ prompt: `Deploy ${service}?`, options: APPROVE_OR_CANCEL });
const answer = await Promise.race([pending, sleep("4h")]);
if (answer === undefined) return { deployed: false, reason: "timed out" };
```

- The question belongs to the run, not the turn. It stays open until someone answers it, it is
  withdrawn, or the run ends by returning, throwing, or cancellation.
- Each question is answered once, so ask again for the next answer. Several questions can be open
  at once.
- A response carries only the option `id`, so choose ids that read well on their own, such as
  `approve`. If a person answers after the question ended, for example from an old Slack menu, the
  agent receives the `id` as a new message.
- A response never steers. A new message that answers no open question follows the session's
  `turnPolicy`. Steering withdraws the questions of the `execute` calls the turn waits on, such as
  `ask_question`'s, but not a task's. See [Stop early for a new message](#stop-early-for-a-new-message).
- An aborted `signal` asks the session to withdraw the question. If a person's answer reached the
  session first, the ask resolves as `answered` and the channel shows it answered. Branch on
  `status`, not on the signal.

Compare the [`approval`](/docs/human-in-the-loop) policy, which gates the call before `execute` runs
and can only show the model's input. Use both together: `approval` before the run, `ctx.ask` inside
it.

## Delegate work: `ctx.agent`

`ctx.agent(name, options?)` returns a session with an agent, shaped like the TypeScript client's
session. The session opens on its first `send`, and each `response.result()` resolves when that
message's turn ends:

```ts
import { z } from "zod";

const reviewer = ctx.agent("reviewer");
const response = await reviewer.send("Review the deployment plan for security risks.", {
  outputSchema: z.object({ findings: z.array(z.string()) }),
  signal: ctx.abortSignal,
});
const { data, reply, status } = await response.result();
```

Set `model` to run a local child on a different AI Gateway model. Every message sent through the
returned session uses it:

```ts
const reviewer = ctx.agent("agent", {
  model: "anthropic/claude-opus-5.5",
});

const response = await reviewer.send("Review the migration plan.", {
  signal: ctx.abortSignal,
});
```

The override changes only the model. The child keeps the target agent's instructions, tools, skills,
connections, sandbox behavior, limits, reasoning, and explicit compaction model. Pass a selection
object to override model-specific settings too:

```ts
const reviewer = ctx.agent("agent", {
  model: {
    model: "anthropic/claude-opus-5.5",
    modelContextWindowTokens: 200_000,
    reasoning: "high",
  },
});
```

Omit `modelContextWindowTokens` to read the context window from the AI Gateway model catalog
recorded at build time. Set it for a model the catalog doesn't list; otherwise the first `send`
rejects.

The selection must contain an AI Gateway model id, because a live AI SDK `LanguageModel` can't be
saved across waits. A remote agent rejects `model`, because its deployment owns its model
configuration.

The name is an available agent target:

- On the root agent, `"agent"` starts a copy of the root agent. Delegated root copies and declared
  subagents can't open that copy.
- A declared subagent uses its path-derived name, and stays callable when hidden with `tool: false`
  or `disableTool()`.
- The name `agent` is reserved, so a declared subagent can't use it.
- An unavailable name rejects the first `send`.

`send(message, options?)` accepts text or `MessageContent<string | URL>`: up to 64 text and file
parts. File `data` must be an HTTP(S) URL, as a string or `URL` object. eve rejects inline bytes,
base64, and `data:` URLs before sending, so upload a local file first and send its URL:

```ts
await reviewer.send([
  { type: "text", text: "Review Alice's report." },
  { type: "file", data: "https://files.example/report.pdf", mediaType: "application/pdf" },
]);
```

| Option         | Description                                                                                              |
| -------------- | -------------------------------------------------------------------------------------------------------- |
| `outputSchema` | Structured output for the turn, as a Standard JSON Schema such as a Zod schema, or a JSON Schema object. |
| `signal`       | Aborting it cancels the turn the message started or joined. The turn still reports, as cancelled.        |

A message sent while the child's turn is running joins that turn, and its response resolves with
that turn's result. If the turn ends before the agent reads the message, the message starts the
next turn. Its response, and those of earlier messages still waiting, resolve with that turn's
result. A message sent while the session is idle starts the next turn. The session keeps its
conversation between turns.

`result()` returns:

| Field          | Description                                                                                               |
| -------------- | --------------------------------------------------------------------------------------------------------- |
| `data`         | The structured value, when the turn requested `outputSchema`.                                             |
| `reply`        | The agent's final reply text, when available.                                                             |
| `status`       | `"completed"`, `"failed"`, or `"cancelled"`. Completion does not by itself mean the session ended.        |
| `sessionEnded` | Whether the session ended with this turn.                                                                 |
| `error`        | `{ message }` with the reason the turn failed. Required when `status` is `"failed"` and absent otherwise. |

Each `ctx.agent(name, options?)` call returns a new session that only this workflow run can send to.
The model sees only what the tool returns. The session ends when the run finishes, which cancels any
turn still running.

- **Local child:** runs with the caller's auth.
- **Remote child:** its identity comes from the outbound request, not the caller's session. To
  forward the caller's identity, set `forwardPrincipal: true` on the remote agent and configure the
  receiver's `trustedForwarders`. See
  [Forwarding the caller identity](../guides/remote-agents#forwarding-the-caller-identity).
- **Questions and sign-ins:** a person answers the child's questions and sign-ins at the root
  session, as for any subagent.

The parent stream announces each session with `child.opened`. The event carries the agent `name`,
the child's `sessionId`, its `stream` path, and its `owner`. `owner` is the task whose run opened
the session, or the tool call when the call doesn't run as a task. Pass the event to
[`session.agent(opened).stream()`](/docs/guides/client/streaming#follow-a-subagent) to follow the
child.

### Route to a hidden subagent with Jev

Use an authored workflow tool when the parent model should decide to delegate, but [Jev](https://vercel.com/i/what-is-jev) should choose the specialist. Set `tool: false` on each specialist so the parent model sees only the routing tool. `ctx.agents` and `ctx.agent()` still reach the specialists.

If the router may select the root-copy `agent`, give the root a `description` that says which work should stay with a copy of the root. Without one, `ctx.agents.agent.description` is an empty string, which `agentRouter()` skips.

```ts title="agent/agent.ts"
import { defineAgent } from "eve";

export default defineAgent({
  description: "Coordinate cross-specialist tasks that need the root agent's complete toolset.",
  model: "anthropic/claude-opus-5.5",
});
```

```ts title="agent/subagents/researcher/agent.ts"
import { defineAgent } from "eve";

export default defineAgent({
  description: "Investigation, analysis, and explanation",
  model: "anthropic/claude-opus-5.5",
  tool: false,
});
```

```ts title="agent/subagents/operator/agent.ts"
import { defineAgent } from "eve";

export default defineAgent({
  description: "Execution and operational changes",
  model: "openai/gpt-6-sol",
  tool: false,
});
```

Define the model-visible router under `agent/tools/`. Build the Jev criteria from the subagents' descriptions, then pass its typed choice to `ctx.agent()`:

```ts title="agent/tools/agent-router.ts"
import { decide } from "eve/ai";
import { defineWorkflowTool } from "eve/tools";
import { z } from "zod";

async function chooseTarget(
  task: string,
  criteria: Record<"researcher" | "operator", string>,
  abortSignal: AbortSignal,
) {
  "use step";

  const result = await decide({
    signal: abortSignal,
    state: { task },
    questions: {
      route: {
        type: "choice",
        instructions: "Which specialist should handle this task?",
        criteria,
      },
    },
  });

  return result.answers.route.choice;
}

export default defineWorkflowTool({
  description: "Route a task to the appropriate specialist.",
  inputSchema: z.object({ task: z.string().min(1).max(8000) }),
  async execute({ task }, ctx) {
    "use workflow";

    const target = await chooseTarget(
      task,
      {
        researcher: ctx.agents.researcher.description,
        operator: ctx.agents.operator.description,
      },
      ctx.abortSignal,
    );

    const response = await ctx.agent(target).send(task, { signal: ctx.abortSignal });
    const { error, reply, status } = await response.result();
    if (status === "failed") throw new Error(`${target} failed: ${error.message}`);
    return reply ?? null;
  },
});
```

The decision runs in a step, so replay reuses it instead of repeating the routing request. The parent model sees only `agent-router`, and the selected subagent's reply becomes its tool result.

`ctx.agents` holds agent metadata as it was when the session accepted the call, and replay doesn't change it. In a `serve` task, it follows the call being served. It includes agents hidden with `tool: false` or `disableTool()`, but no model definitions, credentials, or callbacks. In a top-level root workflow, `ctx.agents.agent` always exists as the root-copy target. A delegated root copy omits `agent`, so `agentRouter()` can't select another root copy. `ctx.agent()` still checks each target's availability.

#### Route across every agent target

To replace the model-facing `agent` tool with a router across every target in `ctx.agents`, export `agentRouter()` from the `agent` slot:

```ts title="agent/tools/agent.ts"
import { agentRouter } from "eve/tools/agent_router";

export default agentRouter();
```

Its input is `{ message: string }`. `agentRouter()` considers only targets with a non-empty description. With two or more, Jev picks one from the message and descriptions; a sole target gets the message directly. The router sends the message to a new session with that agent. Each call runs as a [task](#run-calls-as-tasks-task), so the reply reaches the model as the task's result. In a top-level root session, the candidates include the root-copy `agent`; delegated root copies and declared subagent contexts include only their declared targets.

When an authored tool accepts a JSON Schema supplied by the model, represent that input with a permissive object such as `z.looseObject({})`. Avoid `z.record(z.string(), z.json())`: its generated JSON Schema uses `propertyNames`, which OpenAI does not support. Validate the supplied value at the point where your tool consumes it.

This pattern controls specialist selection, not whether the parent model delegates at all. Route before the parent model runs if every incoming request must go through Jev.

## Add a runtime-generated workflow tool

Export the provided `workflow` factory from `agent/tools/workflow.ts` when the model should write the
JavaScript program at runtime:

```ts title="agent/tools/workflow.ts"
import { workflow } from "eve/tools/workflow";

export default workflow({ maxSubagents: 20 });
```

The model supplies an async function body through the tool's `js` input. Each call runs as a
[task](#run-calls-as-tasks-task), so the program's return value reaches the model in a
`task.result` message.

The program can only call `ctx.agent(name, { message, outputSchema? })`. It sends the message to a
new session with that agent, and resolves to the reply or, when `outputSchema` is set, to the
structured value. The program can call subagents hidden with `tool: false` or a same-named
`disableTool()` file, so list their path-derived names in the tool description or agent
instructions:

```js
const [triage, review] = await Promise.all([
  ctx.agent("ticket-triage", { message: JSON.stringify(tickets) }),
  ctx.agent("ticket-review", { message: JSON.stringify(tickets) }),
]);
return { triage, review };
```

Omit `maxSubagents` to allow `100` agent calls; set an integer from `1` through `128`. The program
can't use the workflow context, session state, credentials, imports, or ordinary tools. Each
`ctx.agent` call starts a new child session, so a program can't continue a child from an earlier
call. Agent authorization, questions, approvals, and cancellation work as they do for sessions an
authored workflow tool opens with `ctx.agent`.

The program continues after every pending call in the current batch finishes, so `Promise.all`
supports fan-out followed by fan-in. A `ctx.agent` call still pending when the program returns
fails the workflow, such as one never awaited or one that loses a `Promise.race` or `Promise.any`.

A failed child turn, an invalid call, or a call over the `maxSubagents` budget throws a catchable
`WorkflowProgramAgentError` at that `ctx.agent` call. The error message names the cause.

The return value and each `ctx.agent` input pass through `JSON.stringify`, so a `Date` becomes a
string, and functions and `undefined` members drop. A return value that converts to nothing, such
as `undefined`, becomes `null`.

| Limit                                     | Value      |
| ----------------------------------------- | ---------- |
| Return value or `ctx.agent` input as JSON | 1 MiB      |
| Child reply as JSON                       | 4 MiB      |
| Program source                            | 256 KiB    |
| JavaScript time per start or resume       | 30 seconds |
| Total time per start or resume            | 5 minutes  |

Time spent waiting for child agents does not count toward either time limit.

## Start channel sessions: `ctx.to`

A workflow tool reaches the agent's channels with [`ctx.to(channel, target)`](/docs/tools#start-channel-sessions-ctxto), as an ordinary tool does. Call it in a `"use step"` helper that receives `ctx`, like `ctx.getSandbox()`; the workflow body throws when it calls it:

```ts title="agent/tools/open_incident_channel.ts"
import { defineWorkflowTool, type WorkflowStepToolContext } from "eve/tools";
import { z } from "zod";
import slack from "../channels/slack";
import { createIncidentChannel } from "../lib/incidents";

export default defineWorkflowTool({
  description: "Open a Slack channel for an incident and continue the investigation there.",
  inputSchema: z.object({ title: z.string(), summary: z.string() }),
  async task({ title, summary }, ctx) {
    "use workflow";
    const channelId = await createChannel(title);
    return await openIncident(ctx, channelId, summary);
  },
});

async function createChannel(title: string) {
  "use step";
  return await createIncidentChannel(title);
}

async function openIncident(ctx: WorkflowStepToolContext, channelId: string, summary: string) {
  "use step";
  const delivery = await ctx
    .to(slack, { channelId })
    .send("Continue the investigation in this channel.", {
      auth: ctx.session.auth.current,
      history: [{ role: "user", text: summary }],
      idempotencyKey: `${ctx.session.id}:${ctx.callId}:incident`,
    });
  return { channelId, sessionId: delivery.sessionId };
}
```

A failed step retries, so pass an `idempotencyKey` that the retry repeats. Include `ctx.session.id`, because the target session dedupes keys from every sender. Add a label for each send, because `ctx.callId` is the same for every step of the call. Create the channel in its own step, so a retried send step reuses the saved `channelId` instead of creating another channel.

`ctx.channel` holds the `kind`, `continuationToken`, and `metadata` of the channel serving the calling session, in the workflow body and in steps, as in [ordinary tools](/docs/tools#the-ctx-parameter).

## Report progress: `yield`

A workflow body can be an async generator whose yields report progress. A yield doesn't pause the
body; await a workflow operation to pause.

| Operation       | Behavior                                                          |
| --------------- | ----------------------------------------------------------------- |
| `yield value`   | Emits a `call.progress` snapshot for the pending tool call.       |
| `return value`  | Ends the tool call with its output.                               |
| No return value | Uses the last yield as output, or `null` if there were no yields. |

An explicit `return null` also falls back to the last yield, so return an object when progress
snapshots and the final result have different shapes. Each snapshot replaces the previous one and
stays out of the model's history, unless it becomes the final output.

```ts title="agent/tools/remind_with_progress.ts"
import { defineWorkflowTool } from "eve/tools";
import { sleep } from "eve/workflow";
import { z } from "zod";

export default defineWorkflowTool({
  description: "Schedule a reminder.",
  inputSchema: z.object({ note: z.string(), delay: z.string() }),
  async *execute({ note, delay }) {
    "use workflow";
    yield { status: "preparing reminder" }; // Published as progress for the pending call.
    await sleep(delay); // Durable suspension while the timer is pending.
    return { reminder: note }; // The tool result.
  },
});
```

See [yield and return](/docs/tools#yield-and-return) for the final-output rules.

## Cancel and clean up: `ctx.abortSignal`

`ctx.abortSignal` aborts when the call is cancelled by `session.cancel()`. For a task, it also aborts
on `eve__task_cancel`, a failed turn, or the end of the session. For an `execute` call, a steering
message aborts it too; see [Stop early for a new message](#stop-early-for-a-new-message). The signal
survives replay, and a step that receives it sees the abort. Pass it into the steps that should
stop, and clean up in `try/finally`:

```ts
async execute({ projectId }, ctx) {
  "use workflow";
  const jobId = await submitRender(projectId);
  try {
    return await waitForRender(jobId, ctx.abortSignal);
  } finally {
    if (ctx.abortSignal.aborted) await abortRender(jobId);
  }
}
```

After a cancel, the run waits up to 30 seconds for the body to stop, then ends as cancelled whether
or not it did. A body paused on a hook or a `sleep` doesn't see the signal. eve abandons it when
the 30 seconds end, so clean up in steps that received the signal.

The caller waits up to 35 seconds for a cancelled run to report its outcome, then forces the run to
stop.

## Stop early for a new message

A steering message is one sent with `turnPolicy: "steer"` (the default) that answers no open
question. It aborts the `ctx.abortSignal` of each `execute` call the turn waits on. A message that
answers a question never stops a call, and steering never touches a
[task](#run-calls-as-tasks-task).

Steering does not cancel the call or end the turn. The call ends with what the body returns, and the
model reads the new message after the step's workflow tool calls finish:

- **Questions:** an open `ctx.ask` question is withdrawn and resolves as `cancelled`, unless a
  person's answer reached the session first.
- **Rejections:** a body that rejects after the abort, such as one whose step received the signal,
  ends as `{ interrupted: true }` instead of failing.
- **Paused bodies:** a body paused on a hook or a `sleep` doesn't see the signal, and it keeps the
  turn waiting until it finishes.

The body doesn't need to tell a steering message from a cancel: stop and return what you have.
After a cancel, eve discards the result. The provided `sleep` tool races its timer against the
signal:

```ts
import { sleep } from "eve/workflow";

async execute(input, ctx) {
  "use workflow";
  const stopped = new Promise<"stopped">((resolve) =>
    ctx.abortSignal.addEventListener("abort", () => resolve("stopped"), { once: true }),
  );
  const elapsed = sleep(input.seconds * 1_000).then(() => "elapsed" as const);
  const woke = await Promise.race([elapsed, stopped]);
  return woke === "elapsed" ? { waitedSeconds: input.seconds } : { interrupted: true };
}
```

To keep a question or long work going while the conversation continues, define
[`task`](#run-calls-as-tasks-task) instead of `execute`.

## Authorize inside a step

Pass `ctx` directly to a step helper and call `ctx.getToken(provider)` there, with the provider
declaration and the API request inside the step. User-scoped providers resolve as the person whose
call started the workflow tool, even if another person speaks in the session while it waits:

```ts
import { connect } from "@vercel/connect/eve";
import type { WorkflowStepToolContext } from "eve/tools";

async function readRepository(ctx: WorkflowStepToolContext, repository: string) {
  "use step";
  const provider = connect("github/my-agent");
  const { token } = await ctx.getToken(provider);
  const response = await fetch(`https://api.github.com/repos/${repository}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (response.status === 401) ctx.requireAuth(provider);
  if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
  return response.json();
}
```

The workflow body calls `await readRepository(ctx, repository)`.

> **Keep tokens out of step results:**
>
> eve records step results in the workflow's history, so do not return the token from the helper.
> The step input is recorded too and, after sign-in, carries the provider's callback parameters,
> such as a one-time authorization code. Bearer tokens are never written.

When sign-in is required, the step attempt ends and the channel shows the sign-in prompt. The
workflow waits for the callback without holding compute. After the callback, eve retries **the whole
interrupted step**, resolves the token, and continues. Completed steps don't run again.

- Resolve auth before other side effects in that step, and make operations before `requireAuth`
  safe to retry.
- A token rejected immediately after sign-in fails instead of prompting again.
- Provider declarations can be shared imports. Pass `ctx` directly, not nested inside another
  argument or captured in a closure.
- If the step fails and retries after a successful sign-in, eve reads the token from the provider
  instead of reusing the callback. The provider must therefore persist the grant or token.
- Cancelling an authorization wait withdraws its callback.

## Use the session sandbox in a step

Pass `ctx` directly to a step helper and call `ctx.getSandbox()` there. The step opens the
[sandbox](/docs/sandbox) that the calling session's turns use. That includes a sandbox shared with
a parent session, as in a copy started by the built-in `agent` tool. The step and the session see
each other's files:

```ts title="agent/tools/analyze.ts"
import { defineWorkflowTool, type WorkflowStepToolContext } from "eve/tools";
import { z } from "zod";

export default defineWorkflowTool({
  description: "Prepare the input file for an analysis run.",
  inputSchema: z.object({ value: z.string() }),
  async task(input, ctx) {
    "use workflow";
    await prepareInput(ctx, input);
    return { path: "/workspace/input.json" };
  },
});

async function prepareInput(ctx: WorkflowStepToolContext, input: { value: string }) {
  "use step";
  const sandbox = await ctx.getSandbox();
  await sandbox.writeTextFile({ path: "/workspace/input.json", content: JSON.stringify(input) });
}
```

- The workflow body can't open the sandbox, because it replays. `ctx.getSandbox()` in the body
  throws and tells you to pass `ctx` to a step helper.
- The step works with any sandbox provider. If the session recorded its sandbox before it called
  the tool, the step resumes it. Otherwise, or if that sandbox is gone, the step runs your
  `defineSandbox()` definition as the session's first turn would. Later turns open the same sandbox.
- Your definition can run once for each caller that opens the sandbox before the session records
  it, so make its setup safe to repeat.
- The sandbox belongs to the session, not the step. When the step ends, even by throwing, the
  sandbox keeps running. `sandbox.stop()` and `sandbox.delete()` in a step fail without a retry,
  because the session's turns may be using the sandbox.
- The session removes the sandbox when it ends, even when only a step opened it. A step still
  running at that point can open the sandbox again, and nothing removes the new one.
- When the eve server shuts down, it stops the sandboxes its sessions opened, but not one that only
  a step opened. With Docker, that container keeps running until the session ends or you stop it.
- A step can use the sandbox while the session's turns use it, as subagents that share a sandbox
  do. Give each one its own files to write.
- Steps run at least once and retry after a failure, so make sandbox writes safe to repeat. Write
  whole files instead of appending, or check for earlier output first.
- When the session shares its parent's sandbox, the step opens it with the parent's sandbox
  definition, and the parent removes it when the parent ends.
- A step runs wherever the workflow runs. A sandbox on one machine's disk, such as just-bash, is
  reachable from a step only on that machine, as in local development.

## Workflow tool examples

### Approve with a deadline and an escalation

```ts
async task({ service }, ctx) {
  "use workflow";
  const plan = await planDeploy(service);
  const pending = ctx.ask({ prompt: `Deploy ${service}?`, display: "confirmation", options: APPROVE_OR_CANCEL });

  let answer = await Promise.race([pending, sleep("4h")]);
  if (answer === undefined) {
    await pageOnCall(service);
    answer = await Promise.race([pending, sleep("20h")]);
  }

  if (answer === undefined) return { deployed: false, reason: "timed out" };
  if (answer.status !== "answered" || answer.optionId !== "approve") {
    return { deployed: false, reason: "rejected" };
  }
  return { deployed: true, url: await applyDeploy(plan) };
}
```

One question stays on the channel the whole time. As a `task`, the call doesn't hold the
conversation, and steering doesn't withdraw the question. `pageOnCall` is a step, and returning
withdraws the question.

### Wait for an external system to call back

```ts title="agent/tools/render_video.ts"
import { defineWorkflowTool } from "eve/tools";
import { createWebhook, FatalError } from "eve/workflow";
import { z } from "zod";
import { submitRender } from "../lib/render";

export default defineWorkflowTool({
  description: "Render a video. Returns the URL once the render farm finishes.",
  inputSchema: z.object({ projectId: z.string() }),
  async execute({ projectId }) {
    "use workflow";
    const done = createWebhook();
    const jobId = await submitRender(projectId, done.url);
    const callback = await done;
    const { status, url } = await callback.json();

    if (status !== "ok") throw new FatalError(`Render ${jobId} failed: ${status}`);
    return { url };
  },
});
```

`createWebhook` mints a URL under `/.well-known/workflow/v1/webhook/` that eve serves. The external
system posts to it when it is done, and nothing runs in between. Use `createHook` with `resumeHook`
for a deterministic token. To customize the HTTP response, pass `respondWith: new Response(...)` to
`createWebhook`.

### Ask now, act when answered

```ts title="agent/tools/refund_order.ts"
import { defineWorkflowTool } from "eve/tools";
import { z } from "zod";
import { issueRefund } from "../lib/refunds";

export default defineWorkflowTool({
  description: "Request approval to refund an order, then issue the refund once approved.",
  inputSchema: z.object({ orderId: z.string(), amount: z.number() }),
  async execute({ orderId, amount }, ctx) {
    "use workflow";
    const decision = await ctx.ask({
      prompt: `Refund $${amount} on order ${orderId}?`,
      display: "confirmation",
      options: [
        { id: "approve", label: "Refund", style: "primary" },
        { id: "deny", label: "Deny" },
      ],
    });

    if (decision.status !== "answered" || decision.optionId !== "approve") {
      return { refunded: false };
    }
    return { refunded: true, receipt: await issueRefund(orderId, amount) };
  },
});
```

The turn waits while the approval card stays on the channel. When someone answers, the refund runs
and the agent receives the outcome as the tool result.

## Semantics

A workflow tool call ends once, with the return value, the error, or a cancellation.

While an `execute` call runs, the turn waits, and a message sent with `turnPolicy: "queue"` waits
for it. A task doesn't hold the turn. See [Stop early for a new message](#stop-early-for-a-new-message)
for `steer` messages.

Errors follow the SDK. A thrown error in a step retries per the step's policy; `FatalError` does
not. An error that escapes the body fails the tool call.

An interrupted dispatch can start a second run for the same call, and both runs may execute. Use an
application idempotency key for side effects that must happen only once.

The workflow id comes from the entry point's module path and function name. Inline entry points use
the tool module and the name `execute`, `task`, or `serve`. Imported ones use their declaring module
and name. Renaming or moving that function creates a new workflow. Runs in flight finish on the
deployment that started them. A run that resumes on a deployment without its tool fails with an
error naming the missing workflow.

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)