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.
yarn add @warlock.js/ai @warlock.js/ai-openai @warlock.js/sealThe tools
Section titled “The tools”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.
The agent
Section titled “The agent”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,});Run it
Section titled “Run it”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:
- Trip 0 — Model calls
lookup_order({ orderId: "A-7711" })andupdate_preferences({ language: "es" })in the same generation. - Agent dispatches both.
lookup_orderreturns data;update_preferenceswrites to the DB silently. - Trip 1 — Model reads the order data, then either calls
track_shipmentor replies in prose. - 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.
Tool error handling
Section titled “Tool error handling”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.
Inject context via artifacts
Section titled “Inject context via artifacts”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.
Related
Section titled “Related”- Define tools — the tool factory in depth.
- Silent tools recipe —
mode: "silent"patterns and provider behavior. - Streaming tool guard — recovering tool calls leaked as text.