Skip to content
Warlock.js v4

Recipe — Tool-calling agent

A support agent that can look up an order, check shipping status, and quietly update customer preferences. Three tools, three different mode values, real round-trips.

Terminal window
yarn add @warlock.js/ai @warlock.js/ai-openai @warlock.js/seal
import { ai } from "@warlock.js/ai";
import { v } from "@warlock.js/seal";
import { ordersRepo, shippingRepo, preferencesRepo } from "./repos";
const lookupOrderTool = ai.tool({
name: "lookup_order",
description: "Find an order by ID. Returns status, line items, total.",
action: ({ orderId }) => `Looking up order ${orderId}`,
input: v.object({ orderId: v.string() }),
execute: async ({ orderId }) => {
const order = await ordersRepo.find(orderId);
if (!order) return { found: false, message: "no such order" };
return { found: true, status: order.status, total: order.total, items: order.items };
},
});
const trackShipmentTool = ai.tool({
name: "track_shipment",
description: "Get current shipment status for an order. Returns carrier, ETA, last update.",
action: ({ orderId }) => `Checking shipment for ${orderId}`,
input: v.object({ orderId: v.string() }),
execute: async ({ orderId }) => {
const shipment = await shippingRepo.byOrder(orderId);
return shipment ?? { tracked: false };
},
});
const updatePreferencesTool = ai.tool({
name: "update_preferences",
description: "Persist a customer's preference change (language, currency, notifications).",
mode: "silent", // model never sees the return
input: v.object({
language: v.string().optional(),
currency: v.string().optional(),
notifications: v.enum(["all", "important", "none"]).optional(),
}),
execute: async (patch, ctx) => {
const customerId = ctx.artifacts.customerId as string;
await preferencesRepo.update(customerId, patch);
return { ok: true };
},
});

Three tools:

  • lookup_order — feedback mode (default). Result feeds back; the model narrates it.
  • track_shipment — feedback mode. Same pattern.
  • update_preferences — silent mode. Side effect only. The agent loop terminates if it’s the only tool called this trip.
import { OpenAISDK } from "@warlock.js/ai-openai";
const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });
const support = ai.agent({
name: "customer-support",
model: openai.model({ name: "gpt-4o-mini" }),
systemPrompt: ai.systemPrompt()
.persona("You are a calm, helpful customer support agent for Acme Corp.")
.instruction("Always look up the order before answering questions about it.")
.instruction("When the customer mentions a preference change, call `update_preferences` alongside your reply."),
tools: [lookupOrderTool, trackShipmentTool, updatePreferencesTool],
maxTrips: 5,
});
const { text, report } = await support.execute(
"Where is my order #A-7711? And please switch me to Spanish.",
);
console.log(text);
for (const call of report.toolCalls) {
console.log(` tool ${call.name} (trip ${call.tripIndex}, ${call.duration}ms)`);
}

Likely flow:

  1. Trip 0 — Model calls lookup_order({ orderId: "A-7711" }) and update_preferences({ language: "es" }) in the same generation.
  2. Agent dispatches both. lookup_order returns data; update_preferences writes to the DB silently.
  3. Trip 1 — Model reads the order data, then either calls track_shipment or replies in prose.
  4. Trip 2 — Model emits the final reply.

Cost: 2-3 LLM trips, depending on whether the model fetches shipment data. Without silent mode on the preferences tool, the model would have written follow-up prose that nobody asked for.

If lookupOrderTool.execute throws, the agent records the error on the ToolCall, tells the model what failed, and loops up to maxTrips:

execute: async ({ orderId }) => {
if (!isValidOrderFormat(orderId)) {
throw new Error("orderId must look like A-####");
}
// ...
}

The model gets a chance to retry with a corrected input. Inspect the failure on report.toolCalls[i].error.

The silent preferences tool needs to know WHICH customer to update. That context comes from ctx.artifacts — populated by the agent caller, not by the model:

// At the call site:
await support.execute(message, {
// Pre-populating artifacts isn't a direct API today.
// The clean pattern is to scope the tool to the customer at construction time:
});

Better: bind the customer at tool construction:

function makeUpdatePreferencesTool(customerId: string) {
return ai.tool({
name: "update_preferences",
mode: "silent",
input: preferencesSchema,
execute: async (patch) => {
await preferencesRepo.update(customerId, patch);
return { ok: true };
},
});
}

Build a fresh agent per request, or rebuild the tool with the customer in scope. This keeps the model from being able to confuse customers across sessions.