The fastest way to make an agent dangerous is to give it a generic tool that can do anything. The fastest way to make it reliable is to expose a small set of semantic capabilities that map cleanly to the jobs the agent is actually supposed to perform.
A tool is a contract between probabilistic reasoning and deterministic software. The model chooses the intent and supplies structured arguments. The runtime validates those arguments and performs the real operation. That makes tool schemas the equivalent of DTO or API design for LLMs: every field you expose becomes part of the model’s action space.
Prefer semantic tools
1Avoid:2call_any_api(method, url, headers, body)3execute_sql(query)4call_salesforce_api(payload)5 6Prefer:7get_opportunity(opportunity_id)8add_opportunity_note(opportunity_id, note)9update_opportunity_stage(opportunity_id, stage)10post_sales_message(channel_id, message)The narrow version reduces ambiguity, shrinks the blast radius, and makes permissions easy to reason about. It also makes evals much cleaner: you can measure whether the agent selected update_opportunity_stage() when it should have, rather than inspecting a free-form HTTP payload.
What attaching a tool actually looks like
In practice, a tool is three things bundled together: a name the model can select, a described input schema the runtime can validate, and an execute function that performs the real work. The agent loop receives the tool map, the model emits a tool call, the runtime validates the arguments and runs the handler, and the result goes back into the loop as the next observation.
1import { generateText, tool, stepCountIs } from "ai";2import { z } from "zod";3 4// One narrow, semantic capability.5const getMyOrders = tool({6 description: "List the authenticated customer's recent orders.",7 inputSchema: z.object({8 limit: z.number().describe("How many recent orders to return, max 20"),9 }),10 // Identity is NOT a model argument: it comes from trusted runtime context.11 execute: async ({ limit }) => {12 const orders = await orderService.listForCustomer(ctx.customerId, {13 limit: Math.min(limit, 20),14 });15 // Return the smallest trustworthy working set, not the full row.16 return orders.map((o) => ({17 id: o.id,18 placedAt: o.placedAt,19 status: o.status,20 total: o.total,21 }));22 },23});24 25const checkRefundEligibility = tool({26 description: "Ask the policy service whether an order can be refunded.",27 inputSchema: z.object({28 orderId: z.string(),29 reason: z.string(),30 }),31 execute: async ({ orderId, reason }) =>32 policyService.checkRefund(ctx.customerId, orderId, reason),33});34 35const result = await generateText({36 model,37 system: SUPPORT_AGENT_INSTRUCTIONS,38 messages,39 // The tool map is the agent's entire action space.40 tools: { getMyOrders, checkRefundEligibility },41 stopWhen: stepCountIs(50),42});Two details matter more than the framework you use. First, the tool map is the agent's entire action space—anything not in it is unreachable, which is exactly the property you want. Second, notice what is missing from the schemas: there is no customerId field. The handler reads the customer from authenticated runtime context, so no amount of clever prompting lets the model read someone else's orders.
The model controls intent; the runtime controls scope
A particularly strong pattern is to keep identity and tenancy out of model-provided arguments whenever possible. Instead of get_orders(customer_id), expose get_my_orders(). The authenticated runtime injects the customer scope. The model cannot ask to become someone else simply by changing an argument.
Read and write tools have different risk profiles
Reading a CRM opportunity is not equivalent to changing its stage. Searching an internal wiki is not equivalent to deleting a ticket. Treat those capabilities differently. Write tools should have stricter schemas, stronger policy checks, clearer audit trails, and—where appropriate—confirmation or approval requirements.
Tool output is context engineering
Tool outputs should be designed for the next decision, not for completeness. Returning an entire customer object with 200 fields forces the model to sift through irrelevant data, increases cost, and may expose sensitive information. Return the smallest trustworthy working set needed for the next step.
PFPLabs takeaways
- Tool design is part of the safety model. Narrow tools reduce the effective action space.
- Hide security scope from model arguments when possible. Inject identity and tenant context from the runtime.
- Separate read and write capabilities. They deserve different policy, confirmation, and observability.
- Design tool outputs for the next decision. Least context is usually better context.
