Threads AI

2. Tools and approvals

Give the agent a function to call, watch the log record the call, and decide what it may do without asking.

The agent from step 1 can only repeat its instructions. Now it gets a function: looking an order up. A tool is a name, a description, an input schema and your code. The schema is what the model is shown and what checks the model's arguments, so the two never drift apart.

Add the tool

effect: "read_only" is a promise about the outside world: this call changes nothing, so it is safe to run again after a crash — and, as the last section shows, it is what lets the call run without asking anyone.

support.ts
import { agent, scriptedModel, sqlite, tool } from "@threads/core";
import { z } from "zod";

const usage = { input_tokens: 10, output_tokens: 2 };

const lookupOrder = tool({
  name: "lookup_order",
  description: "Look up an order by id.",
  input: z.object({ id: z.string() }),
  effect: "read_only",
  execute: async ({ id }) => `order ${id}: delivered 12 days ago`,
});

const support = agent({
  name: "support",
  instructions: "You answer refund questions. Quote the 30-day refund window.",
  model: scriptedModel({
    responses: [
      {
        content: [{ type: "tool_use", call_id: "c1", name: "lookup_order", input: { id: "42" } }],
        stop_reason: "tool_use",
        usage,
      },
      {
        content: [
          { type: "text", text: "Order 42 arrived 12 days ago, inside the 30-day refund window." },
        ],
        stop_reason: "end_turn",
        usage,
      },
    ],
  }),
  tools: [lookupOrder],
});

const result = await support.run("Can I still return order 42?", { store: sqlite(":memory:") });
if (result.status === "completed") console.log(result.output);
else console.log(result.status);

It prints:

Order 42 arrived 12 days ago, inside the 30-day refund window.

The scripted model takes two turns here: the first is the tool call, the second is the answer it gives once the tool result comes back. A real model decides that for itself.

Read what happened

Nothing above logged anything by hand, and yet the whole run is on disk. timeline() reads the thread's log back in order.

const timeline = await result.thread.timeline();
if (!timeline.ok) throw new Error(timeline.error.message);
for (const { event } of timeline.value.entries) console.log(event.seq, event.type);
1 thread_started
2 user_input
3 model_request
4 model_response
5 tool_call
6 permission_decision
7 tool_result
8 model_request
9 model_response
10 turn_completed

Note event 6. Every tool call is decided before it runs, and the decision is recorded next to the call. This run's call was allowed on its own because the tool said it was read-only.

A tool that changes something

Refunding money is not read-only. Drop the effect and the same program stops before the call:

const startRefund = tool({
  name: "start_refund",
  description: "Refund an order by id.",
  input: z.object({ id: z.string() }),
  execute: async ({ id }) => `refunded order ${id}`,
});

// ...with start_refund in tools, and the model scripted to call it:
const result = await support.run("Refund order 42.", { store: sqlite(":memory:") });
if (result.status === "parked") console.log(result.reason, result.pending[0]?.kind);
else console.log(result.status);
awaiting_approval approval

The run parked. It did not fail, and nothing was refunded: the call is waiting for a person, and the thread holds everything it needs to carry on once someone decides. That is the default for any tool you have not declared read-only.

Let it through

If the agent really is allowed to refund on its own, say so with a permission rule.

const support = agent({
  name: "support",
  instructions: "You answer refund questions. Quote the 30-day refund window.",
  model,
  tools: [startRefund],
  permissions: { allow: ["start_refund"] },
});

Now the run completes and prints Order 42 is refunded. Rules can be narrower than a whole tool — bash(git status) or web_fetch(domain:docs.example.com) — and there are ask and deny lists too. Permissions & approvals has the decision order, and Human-in-the-loop shows how to answer a parked approval instead of allowing it up front.

Next

Edit on GitHub

On this page