Threads AI

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"),
  }),
  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" | "sandbox"default host

Where the tool runs. Tools run in your process today, so leave it out. "sandbox" is reserved and is a setup error.

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.

concurrentbooleandefault false

A read_only tool may run at the same time as the other concurrent calls of one model response. See Run read-only tools in parallel.

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() }),
  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() }),
  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() }),
  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() }),
  effect: "read_only",
  endsTurn: true,
  execute: async ({ summary }) => summary,
});

Run read-only tools in parallel

When the model asks for several lookups in one response, mark the tool concurrent and they run at the same time instead of one after another.

const searchDocs = tool({
  name: "search_docs",
  description: "Search the product docs.",
  input: z.object({ query: z.string() }),
  effect: "read_only",
  concurrent: true,
  execute: async ({ query }) => docs.search(query),
});
  • Only reads. concurrent needs effect: "read_only" and no endsTurn. A tool with side effects always runs alone: it starts after every earlier call is recorded, and nothing starts until it finishes.
  • Recorded in call order. Results land in the log in the order the model asked for them, never in the order they finished, so a replay reads the same log.
  • At most 8 at once. The 9th call starts when the 1st is recorded.
  • Stopping. A cancel lets the calls already running finish and skips the rest. If the process stops mid-way, the reads that weren't recorded run again on the next run; a costly read may run twice.
  • Built-in and MCP tools, and calls from different model responses, still run one at a time.

Turning concurrent on or off changes the agent's config, so a thread started with the old setting can't be continued with the new one; start a new thread.

Many tools: load them on demand

An agent with dozens or hundreds of tools pays for every schema on every request. Mark the big ones defer and the model sees only their names until it loads them.

const support = agent({
  name: "support",
  model: anthropic("claude-sonnet-5"),
  tools: [
    lookupOrder, // always loaded
    mcp({ name: "jira", url: JIRA_MCP, defer: true }), // 120 tools, loaded on demand
    tool({ name: "refund", description: "Refund an order.", input, effect: "reconcilable", reconcile, defer: true, execute }),
  ],
});
  • What the model sees. Only the names of the deferred tools, listed in the description of a tool_search tool. A deferred tool's own description and schema are sent only once it is loaded, so name your tools well: the model picks by name.
  • Exact names or keywords. tool_search({query: "mcp__jira__create_issue, mcp__jira__add_comment"}) loads exactly those (up to 10). Any other query loads the best keyword matches from the names and descriptions (limit, default 5).
  • Loaded for good. A loaded tool stays loaded for the rest of the thread, and a call to a tool that isn't loaded yet fails with a message telling the model to search first.
  • Each load rewrites the provider's prompt cache. A load changes the tool list the provider receives, and providers treat any change to tool definitions as a change to the whole prompt: Anthropic, for example, invalidates the entire cache (tools, system prompt and messages). So the first request after a load pays a full cache write for the whole context, and later requests read from the cache again. Load in few, well-chosen batches.
  • The recorded prompt prefix never changes. threads records a load in the conversation history, not in the thread's declared prefix, so the thread's settings and its replay checks stay the same. That is a separate guarantee from the provider cache above.
  • context.defer_tools: auto (the default) honours defer, never defers nothing, always defers every app, extension and MCP tool that doesn't end the turn. Subagents, team members and handoff targets use their parent's setting unless they set their own; they start with nothing loaded.

Defer large catalogs, not two or three small tools. Changing which tools are deferred, or a deferred tool's schema, changes the agent's config, so existing threads need a new thread.

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), concurrent on a tool that isn't read_only or that ends the turn, defer together with endsTurn, or runs: "sandbox", which isn't supported yet. A tool of your own named tool_search is refused while anything is deferred.

Edit on GitHub

On this page