---
title: "Workflows as Tools"
description: "Define durable workflow tools that wait for people, webhooks, or timers without holding compute."
url: /tools/workflows
---

A workflow tool is a static tool defined with `defineWorkflowTool` from `eve/tools`, with
`"use workflow"` as the first statement of its executor. Each call starts a durable Workflow run. Use one when a tool must wait for a person, webhook, or timer,
delegate work to subagents, or coordinate retryable steps over a long period.

A workflow tool defines exactly one entry point, and the entry point decides how its calls run. A
call to an `execute(input, ctx)` tool blocks its turn until the workflow settles, and the parent
agent receives the workflow's output as the tool result. While the body waits durably, the turn
parks without holding compute. A `task(input, ctx)` tool runs each call as a
[task](#run-calls-as-tasks-task) instead: the model gets a receipt at once and the result arrives
later. A `serve(receive, ctx)` tool runs a [resumable task](#resumable-tasks-serve), which 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 provides `ctx.ask` for questions answered through
the session's channel, `ctx.agents` for effective callable-agent descriptions, and `ctx.agent` for sessions with other agents. Use `yield` to report
progress and `await` on a workflow operation to wait durably. Values needed after a durable wait
stay in local variables in the workflow body.

A workflow tool runs code you wrote and appears to the model under its path-derived tool name, such
as `deploy`.

## 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 model calls `deploy`. The turn parks while the human reads the plan. When they answer, minutes
or days later, the run resumes, deploys, and returns. The model sees one tool result.

### Rules

- Export `defineWorkflowTool({ ... })` as the default export. Define exactly one entry point,
  `execute`, `task`, or `serve`, written inline or referenced as a top-level `async function` in
  the same module or an imported application module. `execute` and `task` can be async functions
  or async generators; `serve` is an async function. Defining none, or more than one, throws at
  definition time. Start the entry point with `"use workflow"` as its
  first statement. 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. Side effects, clocks, randomness, `process.env`, and Node.js APIs belong in steps; the body
  is replayed and must stay deterministic.
- Import `createHook`, `createWebhook`, `sleep`, and `FatalError` from `workflow` in the body.
  `start`, `getRun`, and `resumeHook` from `workflow/api` belong in steps. Your app does not install
  the SDK; for types, new projects list `eve/workflow-modules` in the tsconfig `types`.
- In an `execute` or `task` body, `ctx` is a `WorkflowToolContext` with `session`, `callId`,
  `toolName`, `abortSignal`, `agent`, `agents`, and `ask`. A `serve` body gets a
  `WorkflowServeContext` with `session`, `toolName`,
  `agent`, `agents`, `ask`, and `reply`; each call it receives carries its own `callId` and
  `abortSignal`.
  A `"use step"` helper that receives `ctx` directly gets a restricted `WorkflowStepToolContext` with
  `session`, `callId`, `toolName`, `abortSignal`, `getToken`, and `requireAuth`. Read `ctx.agents`, call
  `ctx.agent()`, and call `ctx.ask()` in the workflow body; pass only the serializable values the step needs.
  `getSandbox` remains unavailable.
- The tool's input must be a JSON object. Workflow bodies are for static tools under `agent/tools/`,
  not tools returned from `defineDynamic` resolvers.

`ctx.agent`, `ctx.agents`, and `ctx.ask` are available only on `WorkflowToolContext` and
`WorkflowServeContext` in the workflow body.
Ordinary tools, channel handlers, schedule handlers, and workflow steps do not receive these methods. Type
step helpers with `WorkflowStepToolContext` from `eve/tools` when they need step-safe context capabilities.

Workflow executors require `defineWorkflowTool`. Adding `"use workflow"` to `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 your application's `tsconfig.json` or
`jsconfig.json`, including when the application is a workspace package. A member of an
[agent workspace](/docs/concepts/project-structure#several-root-agents) can also import
workflow and step modules from the shared root package, such as a root `lib/` directory.
eve includes only workflow and step modules reachable from the agent's runtime modules.
Unrelated workflows in the host application stay outside the agent bundle. An unresolved
workflow import fails the build with the missing import in the error.

### Add a runtime-generated workflow tool

Create `agent/tools/workflow.ts` and export the provided `workflow` factory when the model should
supply the JavaScript program at runtime:

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

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

The `workflow` tool runs each call as a [task](#run-calls-as-tasks-task), so the program's return
value reaches the model in a `task.result` message. The model supplies an async function body
through the tool's `js` input. Its only host capability is
`ctx.agent(name, { message, outputSchema? })`, which sends the message to a new session with that
agent and resolves to its reply, or to the structured value when `outputSchema` is set. This can invoke subagents configured with `tool: false` or hidden by a same-named `disableTool()` file. Include those path-derived names in the workflow tool description or agent instructions when the model writes the program. For example:

```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 };
```

`maxSubagents` defaults to `100` and must be an integer from `1` through `128`. The generated
program cannot access the workflow context, session state,
credentials, imports, or ordinary tools. Each call starts a new child session; a program cannot
continue a child from an earlier call. Agent authorization, questions, approvals, and cancellation
behave as they do for sessions an authored workflow tool opens with `ctx.agent`.

The sandbox resumes after every pending call in the current batch settles. `Promise.all` supports
fan-out followed by fan-in, but `Promise.race` does not resume after only the first child settles.
A failed child turn throws at the corresponding `ctx.agent` call, so generated code can catch it.
The program must return a JSON-serializable value.

### Migrate from `experimental_workflow`

The experimental uppercase `Workflow` framework tool and its exports have been removed. Replace the
old sentinel:

```diff
-import { experimental_workflow } from "eve/tools/workflow";
-
-export default experimental_workflow({ maxSubagents: 20 });
```

with the lowercase factory:

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

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

The path gives the authored tool the model-facing name `workflow`. eve does not discover or inject
an agent catalog. Calls use the same target resolution, availability, and authorization checks as
`ctx.agent` in any other authored workflow tool.

### Migrate an authored workflow tool

Replace `defineTool` with `defineWorkflowTool`, keep the executor's `"use workflow"` directive,
and replace `agent(ctx, input)` and `ask(ctx, request)` with an agent session from
[`ctx.agent(name)`](#delegate-work-ctxagent) and `ctx.ask(request)`. The `eve/workflow` entry point
has been removed. Import `WorkflowToolContext`, `AgentSession`,
`ToolInputRequest`, `ToolInputRequestOptions`, and `ToolInputResponse` from `eve/tools` when you
need explicit types.

`ctx.ask` no longer accepts `dismissible`, and its `dismissed` status is now `cancelled`. A
steering message withdraws a question an `execute` call asks, so drop the option. To keep a
question open while the conversation continues, ask it from a [`task`](#run-calls-as-tasks-task).

```diff
-const answer = await ctx.ask({ prompt, dismissible: true });
+const answer = await ctx.ask({ prompt });
```

See [Upgrade to Tasks](/docs/tools/tasks-upgrade) for `task` replacing `execution: "background"`
and the other changes in this release.

## Wait for the result

A call to an `execute` tool works like any other tool call from the model's point of view:

- **Tool result:** the workflow's output after the body finishes.
- **Parent turn:** waits for the call to settle, then continues with the result.
- **Durable wait inside the body:** suspends the workflow while the tool call stays pending.
- **Cancel:** cancelling the turn cancels the run.
- **Steering:** a new message aborts the call's `ctx.abortSignal`, and the call settles with what
  the body returns. 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).

`defineWorkflowTool` does not accept an `execution` option. Passing one throws
`"execution" was replaced by task(). Define task(input, ctx) to run each call as a task.`

### How suspension works

Suspension happens when a workflow must wait for an unresolved durable operation, such as
`ctx.ask`, an awaited hook or webhook, or `sleep`. The runtime persists the wait and releases the
workflow's compute. When the answer, event, or timer arrives, the runtime replays the workflow,
reuses recorded step results, and continues past the wait. Put side effects in `"use step"`
functions so replay does not repeat completed effects.

Consider a body that reports progress and then asks for approval:

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

The yield reports a snapshot, and eve advances the generator to `ctx.ask`. Awaiting the unanswered
request is the durable wait. The original tool call remains pending until the answer arrives and
the body returns.

`yield` itself does not wait for approval. An ordinary
Promise or Node.js timer inside a step also does not create a durable workflow suspension; use
the workflow operations for waits that must 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, such as a twenty-minute deploy or research the model may not need right away. An `execute`
tool holds the conversation until each call settles, however long it runs. A question, a sleep, or
an approval inside an `execute` call already parks durably and holds no compute, so waiting alone
doesn't need a task.

```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 is written like an `execute` body, with the same `WorkflowToolContext`: returning
settles the call, a throw fails it, and an `async *task` generator can `yield` progress. A
steering message never aborts a task's `abortSignal`.

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, which eve appends at the next step boundary. `toModelOutput` projects the
  result, not the receipt.

The model waits for results with `eve__task_wait` and stops a task with `eve__task_cancel`, and a turn can't
end while tasks are working. `eve__task_cancel`, `session.cancel()`, a failed turn, and the
end of the session abort the call's `abortSignal`; the run waits up to 30 seconds for the body to
finish unwinding, then ends as cancelled. See [Tasks](/docs/tools/tasks) for what the model sees,
how held turns look on the stream, steering, 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()`
instead of taking an input:

```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) and the waiting, turn rule, ownership, and limit
described in [Tasks](/docs/tools/tasks). What differs:

- **`receive()`:** resolves with the next call, `{ input, callId, abortSignal }`, with `input`
  validated by `inputSchema`. The first `receive()` resolves at once with the call that started
  the task, without a step or a durable wait, and replays deterministically. After that, it
  resolves with each call made with the task's `taskId`, in arrival order. Calls that arrive while
  the body isn't waiting are kept. A pending `receive()` is shared, so calling it again returns the
  same promise. A call counts as received as soon as its promise resolves, even if that promise
  lost a `Promise.race`, and the next `ctx.reply()` settles it. If you race `receive()`, keep its
  promise until you read the call instead of calling `receive()` again.
- **`taskId` on the model input:** eve adds an optional `taskId` to the tool's model input and
  appends a sentence explaining it to the description. A call without `taskId` starts a new task,
  whose receipt names it and ends with
  `To send it another message, call release-plan again with taskId release-plan-7k2m9q.` A call
  with `taskId` goes to that task and returns a receipt that starts
  `Sent to task release-plan-7k2m9q.` The build fails if `inputSchema` declares its own
  `taskId`.
- **Unknown tasks:** the id must name an unfinished task this tool started; otherwise the call
  fails with `UNKNOWN_TASK`. The call can come from any caller in the session.
- **`ctx.session` and `ctx.agents` describe the call being served:** a later call can come in a
  later turn than the one that started the task. Its `task.started` and `task.settled` carry that
  call's `callId` and `turnId`, and so do the questions, sign-ins, and `agent.started` events the
  body causes while serving it. From the moment `receive()` resolves with a call, `ctx.session` is that call's view of
  the session, including in a step that receives `ctx`: `ctx.session.turn` is the call's turn, and
  `ctx.session.auth` is the auth the session admitted the call with, which may belong to a
  different caller than the one that started the task. `getToken`, `requireAuth`, and connections
  in a step use the current call's auth. `ctx.agents` lists the
  agents the current call may open, including dynamic agents selected in its turn.
- **Body state spans callers:** variables the body keeps between calls, such as `plan` in the
  example, may hold what an earlier call from another caller produced. Key per-caller data on
  the whole principal in `ctx.session.auth.current`: `authenticator`, `issuer`, `principalType`,
  and `principalId` together, because 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).
- **Agents belong to the call that opened them:** a session the body opens with `ctx.agent` while
  serving a call is that call's child. Its `session.parent` names the call's `callId` and turn.
  The first turn of a local child uses the call's tool span as its parent when trace context is
  available. A remote child starts its own trace; its first turn links to the call's tool span
  with `eve.link.type=agent.dispatch` when trace context is available. Later child turns start new
  traces. Parent session lineage stays tied to the opening call, even after the task receives
  another call; a remote receiver accepts that lineage only from a trusted forwarder. Each message
  uses the auth of the call being served; remote identity follows the
  [forwarding rules](../guides/remote-agents#forwarding-the-caller-identity).
- **Results:** `ctx.reply(output)` settles every call received so far with `output`, all at once.
  Each settled call reports its own `task.settled`, and the model receives the reply once. The task
  then goes idle until its next call. A reply with
  no call left to settle is dropped, and `ctx.ask` throws while no call is waiting for a result.
  A reply also withdraws the questions still pending for the calls it settles, but doesn't abort
  their `abortSignal`, so work the body continues after replying keeps running.
  Returning settles the calls still waiting and ends the task; a throw fails them. `serve` is not
  a generator, so it can't `yield` progress.
- **Working and idle:** a resumable task is working while one of its calls has no result, and idle
  otherwise. An idle task doesn't hold the turn and doesn't count toward the limit of 32; a call
  that makes it work again does. The `[Tasks]` note lists the 10 most recently used idle tasks
  after the working ones, so the model can find them again.
- **One `abortSignal` per stretch of work:** a stretch starts when a call reaches an idle task and
  ends when a reply or a cancel settles its calls. Calls that arrive during a stretch share its
  signal, and the next stretch gets a new one, so a task can be cancelled any number of times.
- **Cancellation:** `eve__task_cancel`, `session.cancel()`, and a failed turn settle the task's waiting
  calls as `cancelled` and abort the stretch's `abortSignal`. The run keeps waiting for more calls,
  so the task becomes idle and stays available. `eve__task_cancel` on an idle task says the task had no
  work to stop. To stay available, a body must catch its stretch's abort and
  return to `receive()`, as the example does; an abort that escapes the body finishes the task. A
  body that ignores the signal keeps working and its reply is dropped, but a body that doesn't
  return to `receive()` within 30 seconds of a cancel ends, and the task finishes.
- **Finishing:** the task finishes when its body returns or throws, or when the session ends,
  which aborts the current stretch's signal and rejects a pending `receive()`.

Every [agent tool](/docs/subagents), including the built-in `agent` tool and remote agents, is a
`serve` tool that eve builds this way on public API: each task opens one session with
[`ctx.agent`](#delegate-work-ctxagent), sends each call's `message` with that call's `abortSignal`,
and replies with the final response of each agent turn.

## Authorize inside a step

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

```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();
}
```

Connection tools, ordinary tools, and workflow steps use the same
authorization machinery for requester identity, token caching, callback completion, and rejection
after sign-in. The execution runtime owns the wait: an agent returns to its model after authorization,
while a workflow retries the interrupted step and continues the authored body.

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

<Callout type="warning" title="Keep tokens out of step results">
  Step results enter the workflow's durable history, so do not return the token from the helper.
  eve's token cache stays inside the step. The step input is recorded too and, after sign-in,
  carries the provider's callback parameters, such as a one-time authorization code. This matches
  how agent turns record callbacks. Bearer tokens are never written.
</Callout>

When sign-in is required, the step attempt ends and the workflow waits on its own callback hook,
without holding compute. The channel renders the sign-in challenge. After the callback, eve retries
**the whole interrupted step**, resolves the token, and continues. Previously completed steps are
not rerun. 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, but context must be passed directly, not nested
inside another argument or captured in a closure.

After a successful callback exchange, eve records a completion marker before returning to authored
code. If that code later fails and the step retries, eve reads the token from the provider instead
of exchanging the same callback again. The provider must persist the grant or token; the marker
itself contains no credentials.

Cancelling an authorization wait withdraws its callback. Cancellation uses the existing turn
cancellation path rather than a separate authorization completion event.

## Ask a human: `ctx.ask`

```ts
const answer = await ctx.ask(
  {
    prompt: 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` publishes an `input.requested` event on the session — rendered the way channels render
tool approvals — and returns an awaitable answer. Awaiting it suspends the run until a response arrives.
The `ask_question` tool is a workflow tool built on `ctx.ask`.

The answer's `status` tells the body what happened:

- `answered`: the person picked an option (`optionId`) or typed a reply (`text`).
- `cancelled`: the request was withdrawn before anyone answered it, because the `signal` passed to
  `ctx.ask` or the call's `abortSignal` aborted, or because a `serve` body replied to the call. The
  session emits `input.resolved` with
  `outcome: "cancelled"`, so clients stop offering the question.
- `unavailable`: the session cannot request input, such as a scheduled run. `ctx.ask` returns this
  immediately instead of waiting for an answer no one can give.

A plain follow-up message answers the request when it is the only pending question and the message
matches an option or the request allows free text. Answer a question from a subagent with a
structured response.

`ctx.ask` composes with the SDK's own constructs; race it against 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 request belongs to the run, not the turn. It stays answerable until it is answered, withdrawn,
  or the run ends.
- A request is answered once. Ask again for the next answer.
- Choose option `id`s that read well on their own, such as `approve` or the option's label. A
  response carries only the `id`. If a person answers after the request has already resolved, for
  example from an old Slack menu, the agent receives the `id` as a new message, not the label.
- Ending the run, by returning, throwing, or cancellation, withdraws its pending requests.
- Several requests may be outstanding at once.
- A response never steers. A new human message that does not answer a pending request follows the
  session's normal `turnPolicy`. A steering message aborts the `abortSignal` of each `execute` call
  the turn waits on, which withdraws that call's questions, such as `ask_question`'s. A task's
  questions stay open.
- An aborted `signal` asks the session to withdraw the question, and the session decides. If a
  person's answer reached the session first, the ask resolves as `answered` even though the signal
  aborted, 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. Both compose: `approval` before the run, `ctx.ask` inside it.

## Delegate work: `ctx.agent`

`ctx.agent(name)` 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, message, status } = await response.result();
```

The name is an available agent target. On the root agent, `"agent"` starts a copy of the root agent.
A declared subagent uses its path-derived name and remains callable when hidden with `tool: false`
or `disableTool()`. Delegated root copies and declared subagent contexts do not expose the built-in
root-copy target. The invocation name `agent` is reserved, so a declared subagent cannot shadow it.
An unavailable name rejects the first `send`.

`send(message, options?)` accepts:

| 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 session'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 instead, and its response resolves with that turn's result. The responses of earlier messages
that are still waiting resolve with the same result. A message sent while the session is idle starts
the next turn, and the session keeps its conversation between turns. `result()` returns the
client's `MessageResult` fields, plus `error` when the turn failed:

| Field     | Description                                                                                                                                                                                                         |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data`    | The structured value, when the turn requested `outputSchema`.                                                                                                                                                       |
| `message` | The agent's final reply text, when the turn did not request `outputSchema`.                                                                                                                                         |
| `status`  | `"waiting"` when the session waits for its next message, `"completed"` when the session ended with the turn, or `"failed"` when the turn failed. A cancelled turn is `"waiting"` with neither `data` nor `message`. |
| `error`   | `{ message }` with the reason the turn failed, present only when `status` is `"failed"`.                                                                                                                            |

Every `ctx.agent(name)` call returns a new session that only the workflow run can address. The model
does not see it; it sees what the tool returns. A local child runs as the caller's principal with the
caller's capabilities. A remote child does not inherit the caller's session principal by default;
the receiving deployment determines its identity from the outbound request. 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)).
A person answers the child's questions and sign-ins at the root session, as for any subagent.
The session ends when the run finishes, which cancels any turn still running.

The parent stream announces each session with `agent.started`, which carries the `callId` and
`turnId` of the tool call the run is serving when it opens the session (for a `serve` task, the
latest call it received), the
`taskId` when the call runs as a task, the agent `name`, the child's `sessionId`, and a
`streamPath`. Pass the event to
[`session.agent(started).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. The specialists remain available through `ctx.agents` and callable through `ctx.agent()`.

If the router may select the root-copy `agent`, author a root `description` that explains which work should stay with a copy of the root. `ctx.agents.agent` always exists in a top-level root workflow, but its `description` is an empty string when omitted from `agent.ts`. `agentRouter()` considers only entries with a non-empty description, so it does not route to an undescribed root copy.

```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' effective descriptions, then pass its typed choice directly 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({
    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, message, status } = await response.result();
    if (status === "failed") throw new Error(`${target} failed: ${error?.message}`);
    return message ?? null;
  },
});
```

The decision runs in a step so workflow replay records its result instead of making the routing request again. The parent model receives `agent-router`, but not `researcher` or `operator`. Jev returns the typed `"researcher" | "operator"` choice, and the selected subagent's reply becomes the `agent-router` tool result.

`ctx.agents` is a replay-stable metadata snapshot taken when the session admits the call. In a `serve` task, it follows the call being served. In a top-level root workflow, `ctx.agents.agent` always represents the root-copy target and carries the root's authored `description` when provided. The snapshot also includes declared agents hidden from the parent model with `tool: false` or `disableTool()`, but exposes no model definitions, credentials, or callbacks. A delegated root copy omits `agent`, so `agentRouter()` cannot select another root copy recursively. Invocation still checks each target's availability through `ctx.agent()`.

#### 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()` ignores entries without a non-empty description. With two or more described targets, it sends the message and effective descriptions to Jev, then sends the message to a new session with the selected agent through `ctx.agent()` and returns its reply. It invokes a sole target without decision. 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.

## Report progress: `yield`

A workflow body may be an async generator whose yields report progress. After processing a yield,
eve advances the generator; use an awaited workflow operation when the body needs to suspend.

| Operation       | Behavior                                                          |
| --------------- | ----------------------------------------------------------------- |
| `yield value`   | Emits an `action.partial` snapshot for the pending tool call.     |
| `return value`  | Settles 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. Prefer an
explicit object return when progress snapshots and the final result have different shapes.
Progress snapshots are last-write-wins by tool call id and do not enter model history as
intermediate tool results. A snapshot used as the final output does enter history as the tool result.

This workflow reports progress, then suspends until its timer expires:

```ts title="agent/tools/remind_with_progress.ts"
import { defineWorkflowTool } from "eve/tools";
import { sleep } from "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.
  },
});
```

Use [`ctx.ask`](#ask-a-human-ctxask) when the workflow needs a human answer. 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()`, or, for a task, by
`eve__task_cancel`, a failed turn, or the end of the session. A steering message also aborts it for an
`execute` call; see [Stop early for a new message](#stop-early-for-a-new-message). It is durable:
it survives replay, and a step that receives it observes 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 aborts the signal, the run waits up to 30 seconds for the body to finish unwinding,
then ends as cancelled whether or not it did. A body parked on a hook or a `sleep` does not observe
the signal; it is abandoned when the grace period ends. Steps that received the signal are how you
clean up first.

The caller waits up to 35 seconds for a cooperatively cancelled run to settle before forcing it
to stop. This includes time for the run to publish its outcome after body cleanup.

## Stop early for a new message

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

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

- **Questions:** a pending `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,
  settles as `{ interrupted: true }` instead of failing.
- **No grace period:** a body that doesn't observe the signal, such as one parked on a hook or a
  `sleep`, keeps the turn waiting until it finishes.

The body doesn't need to tell a steering message from a cancel: in both cases, 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 "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`.

## 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 request stays on the channel the whole time. As a `task`, the call doesn't hold the
conversation, and a steering message doesn't withdraw the request. `sleep` is the deadline,
`pageOnCall` is a step, and returning withdraws the request.

### Wait for an external system to call back

```ts title="agent/tools/render_video.ts"
import { defineWorkflowTool } from "eve/tools";
import { createWebhook, FatalError } from "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. Nothing runs in between. Webhook tokens are generated for
you; use `createHook` with `resumeHook` if you need 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 parks while the approval card stays on the channel. When it is answered, the refund runs
and the agent receives the outcome as the tool result.

## Semantics

One call, one result. A workflow tool call resolves once, with the return value, the error, or a
cancellation.

While an `execute` call runs, the turn is parked. A `queue` message waits for it. A `steer` message
that answers no pending question aborts the call's `ctx.abortSignal` and reaches the model once the
call settles; it never cancels the run. Input responses never steer. A task doesn't park the
turn, and steering never touches it.

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 invocation.

Starting a workflow tool can also be retried. If dispatch is interrupted after starting a
run, its retry starts another run, and both may execute. The parent tracks the run returned by the
successful dispatch attempt. Use an application idempotency key for side effects that must happen
only once.

The workflow id derives 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.
