Skip to content

tool approval

require user approval before executing tools

tool approval lets you intercept tool calls before execution and decide whether to allow or deny them. useful for dangerous operations or interactive agents.

basic approval with callback

simple approval callback that runs synchronously

Note

this is a contrived example that just blocks a tool immediately. in practice, you'd want to actually ask for approval instead of hardcoding decisions. see web app approval or cli approval for interactive approval patterns.

// in your server or workflow code
import { compose, model, scope } from "@threaded/ai";

const workflow = compose(
  scope(
    {
      tools: [fileDeleteTool, fileReadTool],
      toolConfig: {
        requireApproval: true,
        approvalCallback: async (call) => {
          if (call.function.name === "delete_file") {
            console.log("blocking delete_file call");
            return false;
          }
          return true;
        },
      },
    },
    model(),
  ),
);

await workflow("delete config.json");

when model tries to call delete_file, approvalCallback receives the tool call object and returns false to deny it. model gets error message and continues.

web app approval (sse)

this is how you build interactive approval for web apps. server sends approval requests to frontend via sse, frontend sends approval decision back via post.

import express from "express";
import { getOrCreateThread, compose, model, scope } from "@threaded/ai";

const app = express();
app.use(express.json());

const pendingApprovals = new Map();

const weatherTool = {
  name: "get_weather",
  description: "Get weather for a city",
  schema: {
    city: { type: "string", description: "City name" },
  },
  execute: async ({ city }) => {
    return { city, temp: "72°F", condition: "sunny" };
  },
};

app.post("/chat/:threadId", async (req, res) => {
  const { threadId } = req.params;
  const { message } = req.body;

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  const thread = getOrCreateThread(threadId);

  const approvalCallback = async (toolCall) => {
    return new Promise((resolve) => {
      const approvalId = `${threadId}-${toolCall.id}`;
      pendingApprovals.set(approvalId, resolve);

      res.write(
        `data: ${JSON.stringify({
          type: "tool_approval_required",
          toolName: toolCall.function.name,
          arguments: JSON.parse(toolCall.function.arguments),
          approvalId,
        })}\n\n`
      );
    });
  };

  const workflow = compose(
    scope(
      {
        tools: [weatherTool],
        toolConfig: {
          requireApproval: true,
          approvalCallback,
        },
        stream: (event) => {
          if (event.type === "content") {
            res.write(`data: ${JSON.stringify({ type: "content", content: event.content })}\n\n`);
          }
          if (event.type === "tool_complete") {
            res.write(`data: ${JSON.stringify({ type: "tool_complete", name: event.call.function.name, result: event.result })}\n\n`);
          }
        },
      },
      model(),
    ),
  );

  await thread.message(message, workflow);
  res.write(`data: ${JSON.stringify({ type: "complete" })}\n\n`);
  res.end();
});

app.post("/approve/:approvalId", (req, res) => {
  const { approvalId } = req.params;
  const { approved } = req.body;

  const resolve = pendingApprovals.get(approvalId);
  if (!resolve) {
    return res.status(404).json({ error: "approval not found" });
  }

  pendingApprovals.delete(approvalId);
  resolve(approved);

  res.json({ success: true });
});

app.listen(3000);

approvalCallback creates a promise that waits for client response. when client posts to /approve/:approvalId, promise resolves and tool execution continues.

const eventSource = new EventSource(`/chat/user-123`);

eventSource.onmessage = async (event) => {
  const data = JSON.parse(event.data);

  if (data.type === "tool_approval_required") {
    const { toolName, arguments: args, approvalId } = data;

    const userApproved = confirm(
      `Allow ${toolName}?\nArguments: ${JSON.stringify(args, null, 2)}`
    );

    await fetch(`/approve/${approvalId}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ approved: userApproved }),
    });
  }

  if (data.type === "content") {
    document.getElementById("output").textContent += data.content;
  }

  if (data.type === "tool_complete") {
    console.log(`tool ${data.name} completed:`, data.result);
  }
};

client receives tool_approval_required event, shows confirmation dialog, posts approval decision back to server.

to approve: { approved: true }

to deny: { approved: false }

cli approval

interactive cli approval like in code-agent example

import readline from "readline";
import { compose, model, scope } from "@threaded/ai";

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const askUser = (question) => {
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      resolve(answer.toLowerCase() === "y");
    });
  });
};

const approvalCallback = async (call) => {
  const args = JSON.parse(call.function.arguments);
  console.log(`\n[tool approval required]`);
  console.log(`tool: ${call.function.name}`);
  console.log(`args: ${JSON.stringify(args, null, 2)}`);

  const approved = await askUser("approve? (y/n): ");
  return approved;
};

const workflow = compose(
  scope(
    {
      tools: [readFileTool, writeFileTool, bashTool],
      toolConfig: {
        requireApproval: true,
        approvalCallback,
      },
    },
    model(),
  ),
);

await workflow("list all js files");

prints tool details, waits for user input, returns approval decision.

execute on approval

by default, when the model requests multiple tools, the library waits for ALL approvals before executing ANY tools. this ensures tools run in the order the model intended.

set executeOnApproval: true to execute tools immediately when approved, without waiting for other approvals.

toolConfig: {
  requireApproval: true,
  approvalCallback,
  executeOnApproval: true,
}

default behavior (executeOnApproval: false):

  1. model requests tools A, B, C
  2. user approves A
  3. user approves B
  4. user approves C
  5. all three execute (in order or parallel depending on parallel config)

with executeOnApproval: true:

  1. model requests tools A, B, C
  2. user approves A → A executes immediately
  3. user approves B → B executes immediately
  4. user approves C → C executes immediately

useful when tools are independent and execution order doesn't matter. not recommended when tools depend on each other (eg. read file then write file).

denial handling

when tool is denied, model receives error message in tool response

{
  "error": "Tool execution denied by user"
}

model can see this and adjust - ask for different parameters, try different approach, or explain why tool is needed.

streaming with approval

combine streaming and approval to show real-time status

scope(
  {
    tools: [weatherTool],
    toolConfig: {
      requireApproval: true,
      approvalCallback,
    },
    stream: (event) => {
      switch (event.type) {
        case "tool_calls_ready":
          console.log("model wants to call:", event.calls.map(c => c.function.name));
          break;
        case "tool_executing":
          console.log(`executing ${event.call.function.name}...`);
          break;
        case "tool_complete":
          console.log(`${event.call.function.name} returned:`, event.result);
          break;
        case "tool_error":
          console.log(`${event.call.function.name} failed:`, event.error);
          break;
      }
    },
  },
  model(),
);

stream events fire during approval flow - tool_calls_ready fires before approval, tool_executing fires after approval granted.

next: helpers