← 输入框|eve.dev|刷新

Tools

A tool is a typed action the agent can call, such as hitting an API, running a query, or writing a file. Each tool is a file under agent/tools/, and the filename is the tool name the model sees. Define a tool when you implement the action in code you control.

Define a tool

agent/
├── agent.ts
└── tools/
    └── get_weather.ts   # the model calls this tool `get_weather`
agent/tools/get_weather.ts
import { defineTool } from "eve/tools";
import { z } from "zod";

export default defineTool({
  // required: what the tool does, written for the model
  description: "Get the current weather for a city.",
  // required: use z.object({}) for a tool with no input
  inputSchema: z.object({ city: z.string().min(1) }),
  // required: may be sync, async, or an async generator
  async execute({ city }, ctx) {
    return { city, condition: "Sunny", temperatureF: 72 };
  },
});
  • Tools run in your app runtime, not in the sandbox. They can read process.env and import shared code from lib/.
  • inputSchema is a Zod schema, any Standard Schema, or a plain JSON Schema object. Zod and Standard Schema infer the input type, and a plain JSON Schema types it as Record<string, unknown>.
  • eve validates the input before execute runs. Zod and Standard Schema use their own library, so any installed Zod version works.
  • eve sends a plain JSON Schema to the model without its $schema keyword. It validates with JSON Schema 2020-12 rules, which cover draft-07 keywords.
  • For a JSON Schema, eve fills omitted properties from their default values. format is an annotation only.
  • An unknown type name or an unresolvable $ref stops eve from checking a JSON Schema. The model still receives the schema, and execute gets the input unchecked.
  • Optional input fields stay optional, because eve turns off provider strict mode for function tools.
  • Tool outputs must be JSON-serializable. Convert Date, Map, Set, NaN, and cyclic values before you return them.
  • Add an outputSchema for structured output. With Zod or Standard Schema, it also types the execute return.
  • Schemas can come from helper modules and be composed with Zod.

The ctx parameter

execute gets a ctx with these accessors:

  • ctx.session: session metadata, turn, auth, and parent agent.
  • ctx.callId: the call's id, also on its stream events and approval context.
  • ctx.toolName: the name the model called, with any namespace prefix.
  • ctx.messages: the model input for the step that requested this call, as a dynamic tool's ctx.messages() sees it.
  • ctx.abortSignal: aborts when the turn is cancelled. Pass it to work that supports cancellation. Sandbox sessions from ctx.getSandbox() already use it.
  • ctx.getSandbox(): the live sandbox handle.
  • ctx.channel: the channel serving the session, as hooks and dynamic resolvers see it: kind, continuationToken, and metadata. metadata is what the channel's metadata(state) hook returns, such as a Slack thread's channelId and threadTs.
  • ctx.to(channel, target): starts or continues a session on one of the agent's channels. See Start channel sessions.

Options

FieldDefaultWhat it does
descriptionrequiredTells the model what the tool does.
inputSchemarequiredValidates and types the model's input.
executerequiredRuns the tool.
outputSchemanoneDescribes and types the return value.
approvalnever()Asks a person to approve calls.
toModelOutputfull returnChanges what the model sees.
labelreadable tool nameSets activity text.
endsTurnfalseEnds the turn after success.
deferredfalseLeaves the tool out of the tool list.
availableInSubagentstrueOffers the tool in delegated sessions.

When a tool throws

If execute throws, the call ends with call.settled outcome: "failed", and the model gets the error. It can respond, try something else, or call the tool again. eve never retries a tool itself, whatever the exception type, HTTP status, or retryable property.

Retry inside the tool when the operation is safe to repeat. Otherwise, return or throw an error that tells the model what to do. In-tool retries don't protect a write, because the model can make a fresh call with a new tool-call ID. Authored workflow steps have their own retries.

Protect non-idempotent operations, such as charges or emails, with the strongest mechanism the service supports:

  1. Pass a stable idempotency key to the upstream API.
  2. Otherwise, record a unique application operation before writing, and check it on every attempt.
  3. Use human approval when a person must authorize each execution. Approval doesn't replace deduplication after an unclear network result.

After a crash, completed steps replay their saved result, and an interrupted step runs again. A call that the step already started doesn't run again. The model reads that the call may have taken effect and decides whether to repeat it. See Crash recovery.

For one-way provider notifications, see Durable cross-channel notifications.

Gate a tool on human approval

A tool can require a person to approve each call before it runs. Set approval with the helpers from eve/tools/approval:

agent/tools/refund_charge.ts
import { defineTool } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";

export default defineTool({
  description: "Refund a charge.",
  inputSchema: z.object({ chargeId: z.string(), amount: z.number() }),
  approval: always(), // or auto() / once() / never() / a policy
  async execute(input) {
    return refund(input);
  },
});

See Human-in-the-loop for the auto, always, once, and never helpers, input-dependent policies, and how a call waits for approval.

Shape what the model sees with toModelOutput

Omit toModelOutput to give the model the full execute return. When a channel needs rich data but the model needs only the gist, return a smaller value:

toModelOutput(output) {
  return { type: "text", value: `Report for ${output.domain}: score ${output.score}.` };
},

toModelOutput receives the typed execute return and changes only what the model sees. Channel handlers and hooks still get the full output on call.settled, so a channel can render Slack Block Kit the model never sees.

Return { type: "text", value } for a summary or { type: "json", value } for a smaller, JSON-serializable object. Return { type: "error-text", value }, or toolOutput.errorText(value), to show the model a failure, such as a command that exited non-zero. Providers mark it as an error result, and so does the MCP channel (isError). The call still completes, so channels and hooks get the full output.

Do not return secrets, credentials, unnecessary personal data, or unbounded sensitive content from tools. Filter, minimize, and redact tool outputs before you return them.

Send images to the model with content parts

To give a vision-capable model an image, such as a screenshot or chart, return a content output from toModelOutput. Build outputs with toolOutput and parts with toolOutputPart, both from eve/tools:

import { defineTool, toolOutput, toolOutputPart } from "eve/tools";

export default defineTool({
  description: "Capture a screenshot of the current page",
  inputSchema: z.object({ url: z.string() }),
  async execute(input) {
    const png = await captureScreenshot(input.url);
    return { path: png.path, screenshotBase64: png.base64 };
  },
  toModelOutput(output) {
    return toolOutput.content([
      toolOutputPart.text(`Screenshot of ${output.path}:`),
      // file payloads must be base64 strings
      toolOutputPart.file(output.screenshotBase64, { mediaType: "image/png" }),
    ]);
  },
});

toolOutput.text and toolOutput.json build the other two shapes, and hand-written literals also work. eve warns above 3 MiB. A model without vision support fails with its provider's error.

eve writes each returned file to the session sandbox and keeps a reference in session history. Every later model call sends the file again, so it stays visible on later turns. See Inbound attachments.

When compaction drops older tool results, each file becomes a text reference such as Attached file /workspace/.eve/attachments/<hash>/chart.png (image/png). The built-in read_file tool can open it again. It shows the model PNG, JPEG, GIF, and WebP images up to 3 MiB and PDFs up to 20 MiB.

Send a file by URL

For a large file in object storage, such as a PDF behind a presigned S3 link, return its URL with toolOutputPart.fileUrl:

toModelOutput(output) {
  return toolOutput.content([
    toolOutputPart.text(`Contract ${output.id}:`),
    // only https: URLs are accepted
    toolOutputPart.fileUrl(output.signedUrl, { mediaType: "application/pdf" }),
  ]);
},

eve keeps only the URL in session history and doesn't write the file to the sandbox. The provider fetches the URL when it supports that file type. Otherwise, your app runtime downloads the file before the call.

Every later model call sends the URL again, so it must stay readable for the whole session. The provider counts the file's input tokens on each call, as for an inline file.

When compaction drops the tool result, the file becomes Attached file <filename> (<mediaType>), and the URL is gone. Also return a text part with an id or path, so the tool can fetch the file again later.

More tool options

Label tool activity

Use label.start(input) to describe a call in the activity users see. The callback receives the validated input:

agent/tools/deploy.ts
export default defineTool({
  description: "Deploy a project to an environment.",
  inputSchema: z.object({
    project: z.string(),
    environment: z.enum(["preview", "production"]),
  }),
  label: {
    start: ({ project, environment }) => `Deploy ${project} to ${environment}`,
  },
  async execute(input) {
    return deploy(input);
  },
});

The label changes only what users see, not the tool name, input, or behavior. eve records it on the call's public action event, so channels show Deploy storefront to production and stream consumers can read it. If the callback throws or returns an empty label, activity shows the tool name in readable form, such as Sync accounts for sync_accounts.

The provided bash, edit_file, glob, grep, read_file, web_fetch, web_search, and write_file tools define labels. A deferred tool, agent, or connection tool called through eve__tool shows its own label. Other built-in labels:

  • An eve__search call shows Search tools for “linear issues”, then Searched tools for “linear issues”, found 5.
  • A skill load shows Load skill release-notes, then Loaded skill release-notes.
  • A connection's sign-in entry shows Sign in to Linear.

Stream preliminary tool results

A defineTool executor written as an async generator sends complete output snapshots before it finishes. Each yield replaces the previous snapshot, and the final yield is the tool result:

agent/tools/build_report.ts
export default defineTool({
  description: "Build a project report.",
  inputSchema: z.object({ project: z.string() }),
  label: {
    start: ({ project }) => `Build report for ${project}`,
    delta: (_input, partial) => partial.phase, // runs for each snapshot
    complete: (_input, output) => `Report ready with ${output.report.sections.length} sections`,
  },
  async *execute({ project }) {
    yield { phase: "Collecting sources", report: null };
    const report = await buildReport(project);
    yield { phase: "Complete", report }; // the tool result
  },
});

Channels, hooks, and clients see earlier yields as call.progress stream records. Only the final yield reaches model history and toModelOutput. eve ignores a generator's return value; see Yield and return.

Keep the latest snapshot for each tool call id. eve may skip snapshots, and a retried step can send some again.

label.delta(input, partial) turns a snapshot into activity text, and label.complete(input, output) describes success. Each non-empty result replaces the current label. If a callback throws or returns an empty string, the label stays as it was. label.complete doesn't run for failed or rejected calls.

Renderers receive only the label text. The full values stay in call.progress and call.settled.

Yield and return

yield doesn't make the tool wait for a person, timer, or external event. What a yielded value means depends on the executor:

ExecutorOrdinary yield valueFinal output
defineToolEarlier values are call.progress snapshots; the final yield becomes the tool result.Last yielded value; a generator return value is ignored.
defineWorkflowToolEvery yield is a call.progress snapshot.Explicit return value; falls back to the last yield if the return is null or undefined, then to null.

Progress snapshots never enter model history. Only the final output does.

End the turn after a tool call

Set endsTurn: true when a successful call is the whole answer, such as reacting to a message. eve ends the turn without calling the model again, so channels post no reply:

agent/tools/react.ts
export default defineTool({
  description: "React to the current message. Use this when a reaction is the whole answer.",
  endsTurn: true,
  inputSchema: z.object({ emoji: z.string() }),
  async execute({ emoji }) {
    await addReaction(emoji);
    return { emoji };
  },
});

eve adds this sentence, after a blank line, to the description the model sees:

Calling this tool ends your turn once it succeeds: do not write a reply or call other tools in the same step. If it fails, you will see the error and can continue.

Leave the turn-ending rule out of description. eve info shows the description as you wrote it, without the added sentence.

To decide from the result, pass a function. eve calls it with the output of execute after each successful call, and the turn ends only when it returns or resolves to true:

agent/tools/react.ts
export default defineTool({
  description:
    "React to the current message. When the reaction posts, your turn ends; otherwise, reply in text.",
  inputSchema: z.object({ emoji: z.string() }),
  async execute({ emoji }) {
    return { posted: await addReaction(emoji) };
  },
  // declare after execute so TypeScript infers `output`
  endsTurn: (output) => output.posted,
});

If the function throws or rejects, eve logs a warning and treats the result as false. With an outputSchema, output takes that schema's type wherever endsTurn appears. eve doesn't add the sentence for a function, so say in description when the call ends the turn.

The turn ends only when every tool call in the step can end the turn, succeeds, and passes any endsTurn function. Otherwise, the model gets the results and continues. Channels don't post text the model wrote before the call as the reply. While tasks are still working, the turn waits for them and then calls the model again.

eve ignores endsTurn in delegated sessions, which must reply to their caller, and on turns that request structured output. There, the model doesn't see the sentence, and eve doesn't call the function.

Dynamic tool entries accept both forms of endsTurn. defineWorkflowTool rejects it at build time. To end a turn without any other action, add the opt-in no_reply tool.

Keep a tool out of the tool list

Set deferred: true for a tool the model rarely needs, or when an agent has too many tools to send in every request:

agent/tools/export_report.ts
export default defineTool({
  deferred: true,
  description: "Export a finished report as a PDF.",
  inputSchema: z.object({ reportId: z.string() }),
  execute({ reportId }) {
    return exportReport(reportId);
  },
});

eve leaves a deferred tool out of the tool list and doesn't name it in context. The model finds it with eve__search and calls it with eve__tool({ name: "export_report", input }). Approvals, endsTurn, toModelOutput, hooks, and stream events then see export_report, as for a direct call.

The option works with defineTool, defineWorkflowTool, and dynamic tool entries. To defer an agent, set tool: "deferred" on its defineAgent definition.

Hide a tool from delegated agents

Set availableInSubagents: false when only a top-level root session should call a tool:

agent/tools/coordinator.ts
export default defineTool({
  availableInSubagents: false, // omit to offer the tool in every session
  description: "Coordinate work that only the top-level root agent should start.",
  inputSchema: z.object({ task: z.string() }),
  execute({ task }) {
    return coordinate(task);
  },
});

The option works with defineTool and defineWorkflowTool. Copies of the root agent from the built-in agent tool don't get the tool. A declared subagent's own tool with this option is never offered, because a subagent always runs delegated. The option doesn't change a workflow's ctx.agents registry.

Start channel sessions: ctx.to

ctx.to(channel, target).send(message, options) starts a session on one of the agent's channels, or continues the session at that target. It's the same call, with the same options, as a route handler's cross-channel hand-off. It returns the delivery, whose sessionId names the session. Use it when work should continue where people can follow it, such as a new Slack thread:

agent/tools/escalate.ts
import { defineTool } from "eve/tools";
import { z } from "zod";
import slack from "../channels/slack";

export default defineTool({
  description: "Hand this investigation to the on-call channel in Slack.",
  inputSchema: z.object({ summary: z.string() }),
  async execute({ summary }, ctx) {
    const delivery = await ctx
      .to(slack, { channelId: "C0123ABC" })
      .send("Take over this investigation.", {
        auth: ctx.session.auth.current, // start the session as the caller
        history: [{ role: "user", text: summary }],
        idempotencyKey: `${ctx.session.id}:${ctx.callId}`, // a retried call sends once
      });
    return { sessionId: delivery.sessionId };
  },
});
  • The new session belongs to the channel. People on that channel continue it, it has no parent, and it keeps running after the tool returns.
  • It runs the root agent with the auth you pass.
  • A retried tool call runs the tool again, so pass an idempotencyKey and the target session gets the message once. Include the session id, because the target session dedupes keys from every sender and ctx.callId is unique only within the calling session. Built-in channels map a retry to the same session, including a Slack target with only a channelId. A platform post made before the session exists, such as an initialMessage, repeats.
  • ctx.to() throws in a delegated session, such as a subagent, a root copy, or a remote agent call. A session it started would outlive the delegation and act for the caller. Return the result instead, and send from a tool on the root agent.

See Fork conversations into a new thread for a tool that copies the current conversation into a new Slack thread.

Workflows as tools

defineWorkflowTool from eve/tools runs each call as a Workflow run that survives restarts and redeploys. Its executor starts with "use workflow" and receives ctx.agent and ctx.ask. It can wait for a person, webhook, or timer without holding compute. See Workflows as tools for the authoring rules and examples.

Durable waits

A tool call with an execute function finishes before the model continues. The definition decides whether the executor can pause and wait:

DefinitionCan wait inside executeWhat the model receives
defineToolNo; the executor runs inside the step that started it.Executor output after it finishes.
defineWorkflowToolYes; the tool call waits while the workflow can suspend.Workflow output after it finishes.

A workflow tool that defines task(input, ctx) instead runs each call as a task. The call returns a receipt at once, and the output arrives later in a task.result message. One that defines serve(receive, ctx) runs a resumable task, which takes more calls by its taskId.

Subagent tools, whether built-in, declared, or remote, run as resumable tasks, so the child's reply also arrives in task.result. In an authored workflow, awaiting a ctx.agent session's response.result() waits for the child's turn to end instead.

In a workflow body, keep values needed after a wait in local variables, because workflow replay rebuilds them. Use ctx.ask when the body needs an answer from a person.

Troubleshooting

What you seeWhat to do
The build rejects deferred on a toolRemove it from provider tools such as webSearch(), because the provider has to see their definition.
eve rejects a file partPass the file as a base64 string. Raw bytes (Uint8Array, Buffer) can't be saved as JSON.
Later model calls fail after a fileUrl partUse a URL that stays readable for the whole session. A presigned URL that expires makes later calls fail.

When to use a connection or skill instead

To give the model tools from an external MCP or OpenAPI service, use a connection. For a written procedure the model loads only when needed, use a skill.