Workflows as Tools

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

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

Entry pointHow 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: the model gets a receipt at once, and the result arrives later.
serve(receive, ctx)A resumable task that the model can send more calls by its taskId.

See Tasks for choosing between them and for what the model sees while tasks work.

Workflow tools use the Workflow SDK: "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

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.
  • 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 or schedule handler APIs.
  • Workflow imports resolve paths aliases from tsconfig.json or jsconfig.json, including in a workspace package. An agent workspace 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:

WhereTypeMembers
execute or task bodyWorkflowToolContextsession, callId, toolName, abortSignal, channel, agent, agents, ask
serve bodyWorkflowServeContextsession, toolName, channel, agent, agents, ask, reply; each received call carries callId and abortSignal
"use step" helper that gets ctxWorkflowStepToolContextsession, 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:

import { createHook, sleep } from "eve/workflow";
import { resumeHook } from "eve/workflow/api";
ExportEntryWhat it does
createHook<T>({ token?, metadata? })eve/workflowReturns 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/workflowReturns a WorkflowWebhook whose url resumes it with the incoming Request. With respondWith: "manual", answer each WorkflowWebhookRequest with request.respondWith(response).
sleep(duration)eve/workflowWaits for a duration such as "1h" or a number of milliseconds, or until a Date.
getWorkflowMetadata()eve/workflowReturns the run's workflowName, workflowRunId, workflowStartedAt, and url.
FatalError, RetryableErroreve/workflowThrow FatalError to fail without a retry. Throw RetryableError, with an optional { retryAfter }, to retry a step.
start(workflow, args)eve/workflow/apiStarts 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/apiReturns a WorkflowRun<T> with runId, status, returnValue, and cancel(). start returns the same type.
resumeHook(token, payload)eve/workflow/apiSends 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.

To keep the conversation going while a call works, define task instead.

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:

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.

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 for what aborts a task, and 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():

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

Every agent tool, 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

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.

statusMeaning
answeredThe person picked an option (optionId) or typed a reply (text).
cancelledThe 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.
unavailableThe 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:

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.
  • 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 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:

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:

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:

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:

await reviewer.send([
  { type: "text", text: "Review Alice's report." },
  { type: "file", data: "https://files.example/report.pdf", mediaType: "application/pdf" },
]);
OptionDescription
outputSchemaStructured output for the turn, as a Standard JSON Schema such as a Zod schema, or a JSON Schema object.
signalAborting 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:

FieldDescription
dataThe structured value, when the turn requested outputSchema.
replyThe agent's final reply text, when available.
status"completed", "failed", or "cancelled". Completion does not by itself mean the session ended.
sessionEndedWhether 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.
  • 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() 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 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.

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",
});
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,
});
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():

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:

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, 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:

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, 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:

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.

LimitValue
Return value or ctx.agent input as JSON1 MiB
Child reply as JSON4 MiB
Program source256 KiB
JavaScript time per start or resume30 seconds
Total time per start or resume5 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), 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:

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.

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.

OperationBehavior
yield valueEmits a call.progress snapshot for the pending tool call.
return valueEnds the tool call with its output.
No return valueUses 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.

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

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.

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:

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 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:

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 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:

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

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

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

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