Tools

Give an agent your own functions, with typed input and a clear promise about what happens if a call is interrupted.

A tool is a function the model can call. You describe its input with a schema (Zod in TypeScript, Pydantic in Python). That one schema is what the model sees and what checks the model's arguments before your code runs, so they never drift apart.

Define a tool

const getWeather = tool({
  name: "get_weather",
  description: "Get the current weather for a city.",
  input: z.object({
    city: z.string().describe("City name, e.g. Paris"),
    unit: z.enum(["c", "f"]).default("c"),
  }),
  runs: "host",
  effect: "read_only",
  execute: async ({ city, unit }) => ({ city, unit, temperature: 21 }),
});

Then pass it to an agent with tools: [getWeather] (tools=[weather_tool] in Python).

namestringrequired

Lowercase letters, digits and underscores. Unique within the agent.

descriptionstringrequired

What the tool does. The model reads this to decide when to call it.

inputschemarequired

A Zod schema (TypeScript) or a Pydantic model class (Python). Unknown keys are rejected.

runs"host"required

Where the tool runs. Tools run in your process today, so this is always "host".

execute(input, ctx) => Promise<Output>required

Your function. It gets the parsed input and a run context with your deps. A string result is shown to the model as is; anything else as JSON.

effectstringdefault unguarded

What your tool does to the outside world. See below.

endsTurnbooleandefault false

ends_turn in Python. When the call succeeds, the turn ends without another model call.

outputZod schema

TypeScript only. A result that fails this schema is reported to the model as an error.

Errors go back to the model

If the model sends arguments that don't match the schema, your function is never called and the model gets an error it can fix. If your function throws, the error message becomes the tool result and the run continues.

execute: async ({ a, b }) => {
  if (b === 0) throw new Error("b must not be zero");
  return a / b;
},

Effects: what happens after a crash

If your process dies in the middle of a tool call, threads has to decide whether it is safe to run that call again. The effect you declare answers that question. threads never retries a call that might already have happened unless you told it how to check.

effectUse it whenAfter an interruption
read_onlyThe tool changes nothing anywhere (a lookup, a search)Safe to run again
idempotentYour provider drops duplicate requests with the same key for a known time (Stripe-style idempotency keys)Sent again with the same key while inside the window; parks after it
reconcilableYou can ask your provider whether the call happenedthreads calls your reconcile.lookup to find out
unguarded (default)Anything else, like sending an emailThe run parks and a person decides

effect also sets the default permission: in the default mode, read_only tools run freely and every other tool asks for approval first. See Permissions to allow them.

Idempotent tools

Send ctx.effectKey (ctx.effect_key) to your provider as the idempotency key, and declare how long the provider remembers it.

const charge = tool({
  name: "charge_card",
  description: "Charge the customer's card.",
  input: z.object({ amount: z.int().positive() }),
  runs: "host",
  effect: "idempotent",
  dedupWindowMs: 24 * 60 * 60 * 1000, // the provider dedups a key for 24 hours
  execute: async ({ amount }, ctx) => payments.charge(amount, ctx.effectKey ?? ""),
});

Reconcilable tools

Give threads a lookup that answers "did the call with this key happen?". finality: "final" means a not_found answer proves it never happened, so threads may run it again. With "nonfinal", a not_found is treated as "not yet known" and the call parks.

const chargeChecked = tool({
  name: "charge_card_checked",
  description: "Charge the customer's card.",
  input: z.object({ amount: z.int().positive() }),
  runs: "host",
  effect: "reconcilable",
  execute: async ({ amount }, ctx) => payments.charge(amount, ctx.effectKey ?? ""),
  reconcile: {
    finality: "final", // a not_found answer proves the charge never happened
    lookup: async (effectKey): Promise<LookupResult<string>> => {
      const id = await payments.find(effectKey);
      return id === undefined ? { status: "not_found" } : { status: "found", value: id };
    },
  },
});

A lookup can also answer unknown (LookupUnknown(reason) in Python) when it can't tell. The call then parks.

Unguarded tools

Leave effect out when you can't check or dedupe. If a call is interrupted, threads parks the run instead of guessing, and you settle it by hand. See Durability.

const sendEmail = tool({
  name: "send_email",
  description: "Send an email to a customer.",
  input: z.object({ to: z.email(), body: z.string() }),
  runs: "host",
  execute: async ({ to, body }) => {
    await mailer.send(to, body);
    return "sent";
  },
});

End the turn from a tool

Set endsTurn (ends_turn) on a tool that means "I'm done". When it succeeds, the run completes without asking the model again. The run's output is the model's last text, which may be empty; the tool's result is in the log.

const finish = tool({
  name: "finish",
  description: "Call when the task is done.",
  input: z.object({ summary: z.string() }),
  runs: "host",
  effect: "read_only",
  endsTurn: true,
  execute: async ({ summary }) => summary,
});

Setup errors

tool() checks its definition and raises ConfigError when it can't run: dedupWindowMs without effect: "idempotent" (or the reverse), reconcile without effect: "reconcilable" (or the reverse), or runs: "sandbox", which isn't supported yet.

Edit on GitHub

On this page