{"schemaVersion":1,"id":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns","title":"LLM Function Calling Patterns for Reliable Tool Use in AI Agents","description":"A technical guide to LLM tool use, structured output, parallel tool calls, retry strategies, error recovery, orchestration patterns, and lessons from production AI agent development.","language":"en","datePublished":"2026-01-17","dateModified":"2026-10-21","contentFormat":"text/markdown","content":"AI/ML\n\n# LLM Function Calling Patterns for Reliable Tool Use in AI Agents\n\n2026-01-1713 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/llm-function-calling-tool-use-patterns>)\n\n## The Mental Model: Tools Are Structured Output with Side Effects\n\nAt its core, function calling is just the model outputting JSON instead of text. Instead of saying \"I looked up the weather and it's 72 degrees,\" the model outputs:\n\n```json\n{\n \"tool\": \"get_weather\",\n \"arguments\": {\n \"location\": \"San Francisco\",\n \"units\": \"fahrenheit\"\n }\n}\n```\n\nYour code executes the function, gets the result, and feeds it back to the model. The model then generates a natural language response incorporating the tool result.\n\nA critical distinction: the model does not \"call\" anything. It generates text that *looks like* a function call. Your orchestration code parses that text, executes the real function, and manages the conversation loop. The model is a JSON generation engine with really good intent detection. Every design decision should flow from this understanding.\n\n## Tool Schema Design: Where 80% of Reliability Comes From\n\nThe single biggest lever for tool-call reliability is the schema. A well-designed schema constrains the model's output space, making correct calls more likely and hallucinated calls harder.\n\n```javascript\n// Bad: too much freedom\nconst badTool = {\n name: \"manage_subscription\",\n description: \"Manage a user's subscription\",\n parameters: {\n type: \"object\",\n properties: {\n action: { type: \"string\", description: \"What to do\" },\n details: { type: \"string\", description: \"Additional details\" },\n },\n },\n};\n\n// Good: constrained, explicit, with reasoning\nconst goodTool = {\n name: \"update_subscription\",\n description: \"Change a user's subscription plan. Only call this when the user has explicitly requested a plan change and confirmed the new plan.\",\n parameters: {\n type: \"object\",\n properties: {\n reason: {\n type: \"string\",\n description: \"Why you are changing this subscription (quote the user's request)\",\n },\n user_id: {\n type: \"string\",\n description: \"The user's ID from the conversation context\",\n pattern: \"^usr_[a-zA-Z0-9]{20}$\",\n },\n new_plan: {\n type: \"string\",\n enum: [\"starter\", \"professional\", \"enterprise\"],\n description: \"The target plan\",\n },\n billing_cycle: {\n type: \"string\",\n enum: [\"monthly\", \"annual\"],\n description: \"Billing frequency for the new plan\",\n },\n effective_date: {\n type: \"string\",\n enum: [\"immediate\", \"next_billing_cycle\"],\n description: \"When the change takes effect\",\n },\n },\n required: [\"reason\", \"user_id\", \"new_plan\", \"billing_cycle\", \"effective_date\"],\n },\n};\n```\n\nKey patterns:\n\n- **Enums over free text**: Every field that has a known set of valid values should be an enum. The model picks from a list instead of generating arbitrary strings.\n- **Reason field**: Forces the model to articulate its reasoning before the action parameters. This acts like chain-of-thought prompting but within the structured output.\n- **Regex patterns**: For IDs, emails, dates, constrain the format so obviously wrong values are rejected at the schema level.\n- **Descriptive constraints**: \"Only call this when the user has explicitly requested...\" in the tool description gives the model clear criteria for when to use the tool.\n\n## The Orchestration Loop: Beyond Single-Shot\n\nReal agents don't make one tool call. They make chains of calls, sometimes in parallel, with branching logic based on results. Here is a proven pattern:\n\n```javascript\nasync function runAgent(messages, tools, maxIterations = 10) {\n let iterations = 0;\n\n while (iterations < maxIterations) {\n iterations++;\n\n const response = await callModel({\n messages,\n tools,\n tool_choice: iterations === 1 ? 'auto' : 'auto',\n });\n\n // If the model wants to respond with text, we're done\n if (response.stop_reason === 'end_turn') {\n return {\n response: response.content,\n toolCalls: messages.filter(m => m.role === 'tool_call'),\n };\n }\n\n // Process tool calls\n if (response.stop_reason === 'tool_use') {\n const toolCalls = response.tool_calls;\n\n // Add the assistant's message (with tool calls) to history\n messages.push({\n role: 'assistant',\n content: response.content,\n tool_calls: toolCalls,\n });\n\n // Execute tools, parallel when safe, sequential when dependent\n const toolResults = await executeToolCalls(toolCalls, {\n parallel: canRunInParallel(toolCalls),\n });\n\n // Add each tool result to the conversation\n for (const result of toolResults) {\n messages.push({\n role: 'tool',\n tool_call_id: result.id,\n content: JSON.stringify(result.output),\n });\n }\n\n // Continue the loop, the model will see the results and decide what's next\n continue;\n }\n\n // Unexpected stop reason\n throw new AgentError(`Unexpected stop reason: ${response.stop_reason}`);\n }\n\n throw new AgentError(`Agent exceeded maximum iterations (${maxIterations})`);\n}\n```\n\nThe ` maxIterations ` guard is critical. Without it, a confused model can loop forever, calling a tool, getting an error, trying a slightly different variation, getting the same error, ad infinitum. Agents have been observed burning through thousands of API calls in minutes without this guard.\n\n## Parallel Tool Calls: When the Model Gets Ambitious\n\nModern models can request multiple tool calls in a single response. This is powerful for things like \"look up the user's profile AND their recent orders\", two independent lookups that can happen simultaneously.\n\n```javascript\nasync function executeToolCalls(toolCalls, options = {}) {\n // Classify calls as parallelizable or sequential\n const { parallel, sequential } = classifyToolCalls(toolCalls);\n\n const results = [];\n\n // Run parallel calls concurrently\n if (parallel.length > 0) {\n const parallelResults = await Promise.allSettled(\n parallel.map(call => executeSingleTool(call))\n );\n\n for (let i = 0; i < parallelResults.length; i++) {\n const result = parallelResults[i];\n if (result.status === 'fulfilled') {\n results.push({\n id: parallel[i].id,\n output: result.value,\n });\n } else {\n results.push({\n id: parallel[i].id,\n output: {\n error: true,\n message: result.reason.message,\n retryable: isRetryable(result.reason),\n },\n });\n }\n }\n }\n\n // Run sequential calls in order\n for (const call of sequential) {\n try {\n const output = await executeSingleTool(call);\n results.push({ id: call.id, output });\n } catch (err) {\n results.push({\n id: call.id,\n output: {\n error: true,\n message: err.message,\n retryable: isRetryable(err),\n },\n });\n }\n }\n\n return results;\n}\n\nfunction classifyToolCalls(toolCalls) {\n const writeTools = new Set(['update_subscription', 'create_order', 'send_email']);\n const parallel = [];\n const sequential = [];\n\n for (const call of toolCalls) {\n if (writeTools.has(call.function.name)) {\n sequential.push(call); // Mutations go sequential\n } else {\n parallel.push(call); // Reads can parallelize\n }\n }\n\n return { parallel, sequential };\n}\n```\n\nThe read/write classification is important. Two simultaneous reads are fine. Two simultaneous writes to the same resource can cause race conditions. And a read followed by a conditional write *must* be sequential.\n\n## Error Handling: Teaching the Model to Recover\n\nWhen a tool call fails, you need to tell the model what happened in a way that helps it recover:\n\n```javascript\nasync function executeSingleTool(call) {\n const tool = toolRegistry.get(call.function.name);\n\n if (!tool) {\n // Don't throw, return a helpful error the model can understand\n return {\n error: true,\n message: `Unknown tool \"${call.function.name}\". Available tools: ${[...toolRegistry.keys()].join(', ')}`,\n };\n }\n\n let args;\n try {\n args = JSON.parse(call.function.arguments);\n } catch (e) {\n return {\n error: true,\n message: `Invalid JSON in arguments: ${e.message}. Please format arguments as valid JSON.`,\n };\n }\n\n // Validate arguments against schema\n const validation = validateArgs(tool.schema, args);\n if (!validation.valid) {\n return {\n error: true,\n message: `Invalid arguments: ${validation.errors.join('; ')}`,\n hint: `Expected schema: ${JSON.stringify(tool.schema.properties)}`,\n };\n }\n\n try {\n const result = await tool.execute(args);\n return { success: true, data: result };\n } catch (err) {\n if (err instanceof NotFoundError) {\n return {\n error: true,\n message: `${args.id} was not found. It may have been deleted or the ID may be incorrect.`,\n suggestion: 'Try searching for the resource by name instead of ID.',\n };\n }\n if (err instanceof PermissionError) {\n return {\n error: true,\n message: `Access denied: ${err.message}. The current user does not have permission for this operation.`,\n suggestion: 'Inform the user that this action requires elevated permissions.',\n };\n }\n if (err instanceof RateLimitError) {\n return {\n error: true,\n message: 'Rate limit exceeded. Please wait before retrying.',\n retryable: true,\n retryAfter: err.retryAfter,\n };\n }\n // Unknown errors, don't expose internals\n logger.error('Tool execution failed', { tool: call.function.name, args, error: err });\n return {\n error: true,\n message: 'An internal error occurred while executing this tool. Please try a different approach or ask the user for more information.',\n };\n }\n}\n```\n\nThe essential principle: error messages are prompts. The model reads them and decides what to do next. \"An error occurred\" gives the model nothing to work with. \"User not found, try searching by name instead of ID\" gives it a recovery path.\n\n## Structured Output: Guaranteed Schema Compliance\n\nFor critical operations, you want the model's output to be guaranteed valid, not just probably valid:\n\n```javascript\n// OpenAI strict mode\nconst response = await openai.chat.completions.create({\n model: \"gpt-4o\",\n messages: [...],\n tools: [{\n type: \"function\",\n function: {\n name: \"create_invoice\",\n description: \"Create an invoice for a customer\",\n strict: true, // Guarantees schema compliance\n parameters: {\n type: \"object\",\n properties: {\n customer_id: { type: \"string\" },\n line_items: {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n description: { type: \"string\" },\n quantity: { type: \"integer\" },\n unit_price_cents: { type: \"integer\" },\n },\n required: [\"description\", \"quantity\", \"unit_price_cents\"],\n additionalProperties: false,\n },\n },\n due_date: { type: \"string\" },\n currency: { type: \"string\", enum: [\"USD\", \"EUR\", \"GBP\"] },\n },\n required: [\"customer_id\", \"line_items\", \"due_date\", \"currency\"],\n additionalProperties: false,\n },\n },\n }],\n});\n```\n\nWith strict mode, the JSON structure is guaranteed to match. No missing fields, no extra fields, no wrong types. The model's constrained decoding ensures it. However, the *values* can still be wrong. The model might output ` \"customer_id\": \"cust_abc123\" ` when the actual customer ID is ` \"cust_xyz789\" `. Schema compliance is not semantic correctness.\n\n## The Confirmation Pattern: Humans in the Loop\n\nFor high-stakes actions (money, data deletion, external communications), never let the agent execute directly. Use a confirmation step:\n\n```javascript\nconst DANGEROUS_TOOLS = new Set([\n 'send_email', 'process_refund', 'delete_account',\n 'update_billing', 'create_order',\n]);\n\nasync function executeWithConfirmation(call, context) {\n if (!DANGEROUS_TOOLS.has(call.function.name)) {\n return executeSingleTool(call);\n }\n\n // Instead of executing, return a confirmation request\n return {\n requires_confirmation: true,\n tool: call.function.name,\n arguments: JSON.parse(call.function.arguments),\n summary: await generateHumanReadableSummary(call),\n confirmation_token: generateToken(call), // signed, time-limited\n };\n}\n\n// The model sees this and tells the user:\n// \"I'd like to process a $49.99 refund to order #12345. Should I go ahead?\"\n// User confirms → your code validates the token and executes\n```\n\nThis pattern is non-negotiable for production agents. No matter how good the model gets, there will always be ambiguous situations where a human should decide. The agent's job is to prepare the action, not to authorize it.\n\n## Retry Strategies That Actually Work\n\n```javascript\nclass ToolCallRetryPolicy {\n constructor(options = {}) {\n this.maxRetries = options.maxRetries || 2;\n this.retryableErrors = options.retryableErrors || [\n 'rate_limit', 'timeout', 'temporary_failure',\n ];\n }\n\n async executeWithRetry(call, executeFunction) {\n let lastError;\n\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n try {\n const result = await executeFunction(call);\n\n if (result.error && result.retryable && attempt < this.maxRetries) {\n lastError = result;\n const backoff = Math.min(1000 * Math.pow(2, attempt), 10000);\n await sleep(backoff);\n continue;\n }\n\n return result;\n } catch (err) {\n lastError = err;\n if (!this.isRetryable(err) || attempt >= this.maxRetries) {\n throw err;\n }\n const backoff = Math.min(1000 * Math.pow(2, attempt), 10000);\n await sleep(backoff);\n }\n }\n\n return {\n error: true,\n message: `Tool call failed after ${this.maxRetries + 1} attempts: ${lastError.message}`,\n };\n }\n}\n```\n\nImportant: don't retry *model* calls when a tool fails. Feed the error back to the model and let it decide what to do. The model might choose to try a different tool, ask the user for clarification, or give up gracefully. Blindly retrying the same tool call with the same parameters rarely helps.\n\n## Observability: What to Log\n\nEvery production agent needs comprehensive logging. Here's the minimum:\n\n```javascript\nfunction logToolCall(call, result, metadata) {\n const logEntry = {\n timestamp: new Date().toISOString(),\n conversation_id: metadata.conversationId,\n turn_number: metadata.turnNumber,\n model: metadata.model,\n\n // The full model response that triggered this call\n model_response_id: metadata.responseId,\n\n // Tool call details\n tool_name: call.function.name,\n tool_arguments: JSON.parse(call.function.arguments),\n tool_call_id: call.id,\n\n // Result\n success: !result.error,\n result_summary: summarizeResult(result),\n execution_time_ms: metadata.executionTimeMs,\n\n // Context\n user_id: metadata.userId,\n session_id: metadata.sessionId,\n };\n\n // Structured log, ship to your observability stack\n logger.info('tool_call', logEntry);\n\n // Metrics\n metrics.increment('tool_calls_total', {\n tool: call.function.name,\n success: String(!result.error),\n });\n metrics.histogram('tool_call_duration_ms', metadata.executionTimeMs, {\n tool: call.function.name,\n });\n}\n```\n\nWhen something goes wrong at 2 AM, and it will, the ability to reconstruct the entire conversation: what the user said, what the model decided, what tools it called, what results it got, and what it said next. Without this, debugging agent failures is guesswork.\n\n## The Routing Pattern: Scaling Beyond 20 Tools\n\nWhen you have more tools than a model can reliably choose from, use a two-stage approach:\n\n```javascript\nconst TOOL_CATEGORIES = {\n billing: ['get_invoices', 'create_refund', 'update_payment_method', 'get_subscription'],\n support: ['create_ticket', 'get_ticket_status', 'escalate_ticket', 'close_ticket'],\n account: ['get_profile', 'update_profile', 'reset_password', 'get_login_history'],\n product: ['search_products', 'get_product_details', 'check_inventory', 'get_pricing'],\n};\n\n// Stage 1: Route to category\nconst routerTools = [{\n name: \"select_tool_category\",\n description: \"Select the most relevant tool category for the user's request\",\n parameters: {\n type: \"object\",\n properties: {\n category: {\n type: \"string\",\n enum: Object.keys(TOOL_CATEGORIES),\n },\n reason: { type: \"string\" },\n },\n required: [\"category\", \"reason\"],\n },\n}];\n\n// Stage 2: Use category-specific tools\nasync function routedAgent(messages) {\n // First, determine the category\n const routingResponse = await callModel({\n messages,\n tools: routerTools,\n tool_choice: { type: \"function\", function: { name: \"select_tool_category\" } },\n });\n\n const category = routingResponse.tool_calls[0].function.arguments.category;\n const relevantTools = TOOL_CATEGORIES[category].map(name => toolRegistry.get(name));\n\n // Then, run the agent with only the relevant tools\n return runAgent(messages, relevantTools);\n}\n```\n\nThis two-stage pattern keeps each model call focused on a small set of tools, dramatically improving selection accuracy. The overhead of the extra API call is negligible compared to the cost of a wrong tool selection.\n\n## Key Lessons from Production Agent Development\n\n1. **Schema design is your primary lever.** Spend more time designing tool schemas than writing orchestration code. Constrained enums, required reason fields, and clear descriptions do more for reliability than any retry logic.\n2. **The model is not your executor, it's your planner.** Don't give the model tools that do atomic, irreversible actions. Give it tools that prepare actions, and let your code handle execution with proper safeguards.\n3. **Error messages are prompts.** Every string your tool returns is a prompt to the model. Write error messages as instructions for recovery, not as developer debug output.\n4. **Parallel tool calls are a feature, not a bug.** Design your tools to be parallelizable by default. Read operations should always be safe to parallelize. Mutations should be explicit about their dependencies.\n5. **Log everything.** The first time you debug a production agent issue without full conversation logs, the value of logging becomes unmistakable.\n6. **The max iteration guard is not optional.** Models in loops can burn through your API budget in minutes. Always cap iterations and always have a cost circuit breaker.\n\nThe tooling is improving fast. Structured output modes make schema compliance guaranteed. Multi-turn conversation handling is getting more reliable. But the fundamental challenge remains: you're building a system where a probabilistic text generator makes decisions about deterministic operations. Respect that gap, design for it, and your agents will be the ones that actually work in production.\n\n## Reproducible evaluation: hostile instructions and permissions\n\n[Agent Injection Evals](<https://lucioduran.com/projects/agent-injection-evals>) crosses plain or instruction-isolated prompts with permissive or task-scoped tool policies. It uses the same pinned local model and preserves all 120 evaluation generations, including malformed outputs and failed legitimate tasks.\n\nIsolation allowed five unauthorized writes; combined protection attempted the same five and policy blocked them. No strategy completed any of the six clean tasks. Calling that zero attack rate a winning defense would hide the problem: the agent was not doing its job either. The project page separates capability, attempts, effects, blocked calls and resource use. These are simulated tools and a small synthetic set, not a security validation for arbitrary agents.\n\n## Frequently asked questions\n\n### What's the difference between function calling and tool use?\n\nThey're the same thing with different branding. OpenAI calls it 'function calling' (now 'tool use'), Anthropic calls it 'tool use'. The mechanism is identical: the model outputs a structured JSON object indicating which function to call with what arguments, and your code executes it.\n\n### Can I trust LLMs to generate valid JSON for tool calls?\n\nWith structured output modes (OpenAI's strict mode, Anthropic's tool\\_use), the JSON schema is guaranteed to be valid. The values within the JSON can still be wrong, the model might call the right function with a hallucinated ID, but the structure will match your schema.\n\n### How many tools can I give a model?\n\nPractically, 10-20 tools work well. Beyond 30, accuracy starts degrading as the model has more options to confuse. If you need more, use a routing layer, have one model pick a tool category, then a second call with only the relevant tools.\n\n## Tips\n\n1. **Tip:** Define tool parameters with ` enum ` constraints wherever possible instead of free-text strings. An LLM picking from ` [\"buy\", \"sell\", \"hold\"] ` is dramatically more reliable than interpreting natural language intent.\n2. **Tip:** Always include a ` reason ` parameter in your tool schemas. Forcing the model to articulate *why* it's calling a tool before providing the arguments measurably reduces hallucinated parameters.\n3. **Tip:** Set ` tool_choice: 'required' ` only when you're certain the user's message needs a tool call. Leaving it on ` 'auto' ` by default prevents the model from forcing unnecessary tool calls on conversational messages.\n4. **Tip:** Log every tool call with the full model response, not just the extracted parameters. When debugging production issues, you need the model's chain of thought, not just what it decided to call.\n\n- llm\n- function-calling\n- tool-use\n- ai-agents\n- structured-output\n- openai\n- anthropic\n- langchain\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"LLM Function Calling Patterns for Reliable Tool Use in AI Agents","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#title"},{"level":2,"text":"The Mental Model: Tools Are Structured Output with Side Effects","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#the-mental-model-tools-are-structured-output-with-side-effects"},{"level":2,"text":"Tool Schema Design: Where 80% of Reliability Comes From","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#tool-schema-design-where-80-of-reliability-comes-from"},{"level":2,"text":"The Orchestration Loop: Beyond Single-Shot","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#the-orchestration-loop-beyond-single-shot"},{"level":2,"text":"Parallel Tool Calls: When the Model Gets Ambitious","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#parallel-tool-calls-when-the-model-gets-ambitious"},{"level":2,"text":"Error Handling: Teaching the Model to Recover","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#error-handling-teaching-the-model-to-recover"},{"level":2,"text":"Structured Output: Guaranteed Schema Compliance","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#structured-output-guaranteed-schema-compliance"},{"level":2,"text":"The Confirmation Pattern: Humans in the Loop","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#the-confirmation-pattern-humans-in-the-loop"},{"level":2,"text":"Retry Strategies That Actually Work","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#retry-strategies-that-actually-work"},{"level":2,"text":"Observability: What to Log","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#observability-what-to-log"},{"level":2,"text":"The Routing Pattern: Scaling Beyond 20 Tools","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#the-routing-pattern-scaling-beyond-20-tools"},{"level":2,"text":"Key Lessons from Production Agent Development","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#key-lessons-from-production-agent-development"},{"level":2,"text":"Reproducible evaluation: hostile instructions and permissions","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#reproducible-evaluation-hostile-instructions-and-permissions"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#questions"},{"level":3,"text":"What's the difference between function calling and tool use?","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#question-1"},{"level":3,"text":"Can I trust LLMs to generate valid JSON for tool calls?","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#question-2"},{"level":3,"text":"How many tools can I give a model?","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#article-tips"}],"sections":[{"heading":"LLM Function Calling Patterns for Reliable Tool Use in AI Agents","level":1,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns","content":"AI/ML"},{"heading":"LLM Function Calling Patterns for Reliable Tool Use in AI Agents","level":1,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#title","content":"2026-01-1713 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/llm-function-calling-tool-use-patterns>)"},{"heading":"The Mental Model: Tools Are Structured Output with Side Effects","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#the-mental-model-tools-are-structured-output-with-side-effects","content":"At its core, function calling is just the model outputting JSON instead of text. Instead of saying \"I looked up the weather and it's 72 degrees,\" the model outputs:\n\n```json\n{\n \"tool\": \"get_weather\",\n \"arguments\": {\n \"location\": \"San Francisco\",\n \"units\": \"fahrenheit\"\n }\n}\n```\n\nYour code executes the function, gets the result, and feeds it back to the model. The model then generates a natural language response incorporating the tool result.\n\nA critical distinction: the model does not \"call\" anything. It generates text that *looks like* a function call. Your orchestration code parses that text, executes the real function, and manages the conversation loop. The model is a JSON generation engine with really good intent detection. Every design decision should flow from this understanding."},{"heading":"Tool Schema Design: Where 80% of Reliability Comes From","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#tool-schema-design-where-80-of-reliability-comes-from","content":"The single biggest lever for tool-call reliability is the schema. A well-designed schema constrains the model's output space, making correct calls more likely and hallucinated calls harder.\n\n```javascript\n// Bad: too much freedom\nconst badTool = {\n name: \"manage_subscription\",\n description: \"Manage a user's subscription\",\n parameters: {\n type: \"object\",\n properties: {\n action: { type: \"string\", description: \"What to do\" },\n details: { type: \"string\", description: \"Additional details\" },\n },\n },\n};\n\n// Good: constrained, explicit, with reasoning\nconst goodTool = {\n name: \"update_subscription\",\n description: \"Change a user's subscription plan. Only call this when the user has explicitly requested a plan change and confirmed the new plan.\",\n parameters: {\n type: \"object\",\n properties: {\n reason: {\n type: \"string\",\n description: \"Why you are changing this subscription (quote the user's request)\",\n },\n user_id: {\n type: \"string\",\n description: \"The user's ID from the conversation context\",\n pattern: \"^usr_[a-zA-Z0-9]{20}$\",\n },\n new_plan: {\n type: \"string\",\n enum: [\"starter\", \"professional\", \"enterprise\"],\n description: \"The target plan\",\n },\n billing_cycle: {\n type: \"string\",\n enum: [\"monthly\", \"annual\"],\n description: \"Billing frequency for the new plan\",\n },\n effective_date: {\n type: \"string\",\n enum: [\"immediate\", \"next_billing_cycle\"],\n description: \"When the change takes effect\",\n },\n },\n required: [\"reason\", \"user_id\", \"new_plan\", \"billing_cycle\", \"effective_date\"],\n },\n};\n```\n\nKey patterns:\n\n- **Enums over free text**: Every field that has a known set of valid values should be an enum. The model picks from a list instead of generating arbitrary strings.\n- **Reason field**: Forces the model to articulate its reasoning before the action parameters. This acts like chain-of-thought prompting but within the structured output.\n- **Regex patterns**: For IDs, emails, dates, constrain the format so obviously wrong values are rejected at the schema level.\n- **Descriptive constraints**: \"Only call this when the user has explicitly requested...\" in the tool description gives the model clear criteria for when to use the tool."},{"heading":"The Orchestration Loop: Beyond Single-Shot","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#the-orchestration-loop-beyond-single-shot","content":"Real agents don't make one tool call. They make chains of calls, sometimes in parallel, with branching logic based on results. Here is a proven pattern:\n\n```javascript\nasync function runAgent(messages, tools, maxIterations = 10) {\n let iterations = 0;\n\n while (iterations < maxIterations) {\n iterations++;\n\n const response = await callModel({\n messages,\n tools,\n tool_choice: iterations === 1 ? 'auto' : 'auto',\n });\n\n // If the model wants to respond with text, we're done\n if (response.stop_reason === 'end_turn') {\n return {\n response: response.content,\n toolCalls: messages.filter(m => m.role === 'tool_call'),\n };\n }\n\n // Process tool calls\n if (response.stop_reason === 'tool_use') {\n const toolCalls = response.tool_calls;\n\n // Add the assistant's message (with tool calls) to history\n messages.push({\n role: 'assistant',\n content: response.content,\n tool_calls: toolCalls,\n });\n\n // Execute tools, parallel when safe, sequential when dependent\n const toolResults = await executeToolCalls(toolCalls, {\n parallel: canRunInParallel(toolCalls),\n });\n\n // Add each tool result to the conversation\n for (const result of toolResults) {\n messages.push({\n role: 'tool',\n tool_call_id: result.id,\n content: JSON.stringify(result.output),\n });\n }\n\n // Continue the loop, the model will see the results and decide what's next\n continue;\n }\n\n // Unexpected stop reason\n throw new AgentError(`Unexpected stop reason: ${response.stop_reason}`);\n }\n\n throw new AgentError(`Agent exceeded maximum iterations (${maxIterations})`);\n}\n```\n\nThe ` maxIterations ` guard is critical. Without it, a confused model can loop forever, calling a tool, getting an error, trying a slightly different variation, getting the same error, ad infinitum. Agents have been observed burning through thousands of API calls in minutes without this guard."},{"heading":"Parallel Tool Calls: When the Model Gets Ambitious","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#parallel-tool-calls-when-the-model-gets-ambitious","content":"Modern models can request multiple tool calls in a single response. This is powerful for things like \"look up the user's profile AND their recent orders\", two independent lookups that can happen simultaneously.\n\n```javascript\nasync function executeToolCalls(toolCalls, options = {}) {\n // Classify calls as parallelizable or sequential\n const { parallel, sequential } = classifyToolCalls(toolCalls);\n\n const results = [];\n\n // Run parallel calls concurrently\n if (parallel.length > 0) {\n const parallelResults = await Promise.allSettled(\n parallel.map(call => executeSingleTool(call))\n );\n\n for (let i = 0; i < parallelResults.length; i++) {\n const result = parallelResults[i];\n if (result.status === 'fulfilled') {\n results.push({\n id: parallel[i].id,\n output: result.value,\n });\n } else {\n results.push({\n id: parallel[i].id,\n output: {\n error: true,\n message: result.reason.message,\n retryable: isRetryable(result.reason),\n },\n });\n }\n }\n }\n\n // Run sequential calls in order\n for (const call of sequential) {\n try {\n const output = await executeSingleTool(call);\n results.push({ id: call.id, output });\n } catch (err) {\n results.push({\n id: call.id,\n output: {\n error: true,\n message: err.message,\n retryable: isRetryable(err),\n },\n });\n }\n }\n\n return results;\n}\n\nfunction classifyToolCalls(toolCalls) {\n const writeTools = new Set(['update_subscription', 'create_order', 'send_email']);\n const parallel = [];\n const sequential = [];\n\n for (const call of toolCalls) {\n if (writeTools.has(call.function.name)) {\n sequential.push(call); // Mutations go sequential\n } else {\n parallel.push(call); // Reads can parallelize\n }\n }\n\n return { parallel, sequential };\n}\n```\n\nThe read/write classification is important. Two simultaneous reads are fine. Two simultaneous writes to the same resource can cause race conditions. And a read followed by a conditional write *must* be sequential."},{"heading":"Error Handling: Teaching the Model to Recover","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#error-handling-teaching-the-model-to-recover","content":"When a tool call fails, you need to tell the model what happened in a way that helps it recover:\n\n```javascript\nasync function executeSingleTool(call) {\n const tool = toolRegistry.get(call.function.name);\n\n if (!tool) {\n // Don't throw, return a helpful error the model can understand\n return {\n error: true,\n message: `Unknown tool \"${call.function.name}\". Available tools: ${[...toolRegistry.keys()].join(', ')}`,\n };\n }\n\n let args;\n try {\n args = JSON.parse(call.function.arguments);\n } catch (e) {\n return {\n error: true,\n message: `Invalid JSON in arguments: ${e.message}. Please format arguments as valid JSON.`,\n };\n }\n\n // Validate arguments against schema\n const validation = validateArgs(tool.schema, args);\n if (!validation.valid) {\n return {\n error: true,\n message: `Invalid arguments: ${validation.errors.join('; ')}`,\n hint: `Expected schema: ${JSON.stringify(tool.schema.properties)}`,\n };\n }\n\n try {\n const result = await tool.execute(args);\n return { success: true, data: result };\n } catch (err) {\n if (err instanceof NotFoundError) {\n return {\n error: true,\n message: `${args.id} was not found. It may have been deleted or the ID may be incorrect.`,\n suggestion: 'Try searching for the resource by name instead of ID.',\n };\n }\n if (err instanceof PermissionError) {\n return {\n error: true,\n message: `Access denied: ${err.message}. The current user does not have permission for this operation.`,\n suggestion: 'Inform the user that this action requires elevated permissions.',\n };\n }\n if (err instanceof RateLimitError) {\n return {\n error: true,\n message: 'Rate limit exceeded. Please wait before retrying.',\n retryable: true,\n retryAfter: err.retryAfter,\n };\n }\n // Unknown errors, don't expose internals\n logger.error('Tool execution failed', { tool: call.function.name, args, error: err });\n return {\n error: true,\n message: 'An internal error occurred while executing this tool. Please try a different approach or ask the user for more information.',\n };\n }\n}\n```\n\nThe essential principle: error messages are prompts. The model reads them and decides what to do next. \"An error occurred\" gives the model nothing to work with. \"User not found, try searching by name instead of ID\" gives it a recovery path."},{"heading":"Structured Output: Guaranteed Schema Compliance","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#structured-output-guaranteed-schema-compliance","content":"For critical operations, you want the model's output to be guaranteed valid, not just probably valid:\n\n```javascript\n// OpenAI strict mode\nconst response = await openai.chat.completions.create({\n model: \"gpt-4o\",\n messages: [...],\n tools: [{\n type: \"function\",\n function: {\n name: \"create_invoice\",\n description: \"Create an invoice for a customer\",\n strict: true, // Guarantees schema compliance\n parameters: {\n type: \"object\",\n properties: {\n customer_id: { type: \"string\" },\n line_items: {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n description: { type: \"string\" },\n quantity: { type: \"integer\" },\n unit_price_cents: { type: \"integer\" },\n },\n required: [\"description\", \"quantity\", \"unit_price_cents\"],\n additionalProperties: false,\n },\n },\n due_date: { type: \"string\" },\n currency: { type: \"string\", enum: [\"USD\", \"EUR\", \"GBP\"] },\n },\n required: [\"customer_id\", \"line_items\", \"due_date\", \"currency\"],\n additionalProperties: false,\n },\n },\n }],\n});\n```\n\nWith strict mode, the JSON structure is guaranteed to match. No missing fields, no extra fields, no wrong types. The model's constrained decoding ensures it. However, the *values* can still be wrong. The model might output ` \"customer_id\": \"cust_abc123\" ` when the actual customer ID is ` \"cust_xyz789\" `. Schema compliance is not semantic correctness."},{"heading":"The Confirmation Pattern: Humans in the Loop","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#the-confirmation-pattern-humans-in-the-loop","content":"For high-stakes actions (money, data deletion, external communications), never let the agent execute directly. Use a confirmation step:\n\n```javascript\nconst DANGEROUS_TOOLS = new Set([\n 'send_email', 'process_refund', 'delete_account',\n 'update_billing', 'create_order',\n]);\n\nasync function executeWithConfirmation(call, context) {\n if (!DANGEROUS_TOOLS.has(call.function.name)) {\n return executeSingleTool(call);\n }\n\n // Instead of executing, return a confirmation request\n return {\n requires_confirmation: true,\n tool: call.function.name,\n arguments: JSON.parse(call.function.arguments),\n summary: await generateHumanReadableSummary(call),\n confirmation_token: generateToken(call), // signed, time-limited\n };\n}\n\n// The model sees this and tells the user:\n// \"I'd like to process a $49.99 refund to order #12345. Should I go ahead?\"\n// User confirms → your code validates the token and executes\n```\n\nThis pattern is non-negotiable for production agents. No matter how good the model gets, there will always be ambiguous situations where a human should decide. The agent's job is to prepare the action, not to authorize it."},{"heading":"Retry Strategies That Actually Work","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#retry-strategies-that-actually-work","content":"```javascript\nclass ToolCallRetryPolicy {\n constructor(options = {}) {\n this.maxRetries = options.maxRetries || 2;\n this.retryableErrors = options.retryableErrors || [\n 'rate_limit', 'timeout', 'temporary_failure',\n ];\n }\n\n async executeWithRetry(call, executeFunction) {\n let lastError;\n\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n try {\n const result = await executeFunction(call);\n\n if (result.error && result.retryable && attempt < this.maxRetries) {\n lastError = result;\n const backoff = Math.min(1000 * Math.pow(2, attempt), 10000);\n await sleep(backoff);\n continue;\n }\n\n return result;\n } catch (err) {\n lastError = err;\n if (!this.isRetryable(err) || attempt >= this.maxRetries) {\n throw err;\n }\n const backoff = Math.min(1000 * Math.pow(2, attempt), 10000);\n await sleep(backoff);\n }\n }\n\n return {\n error: true,\n message: `Tool call failed after ${this.maxRetries + 1} attempts: ${lastError.message}`,\n };\n }\n}\n```\n\nImportant: don't retry *model* calls when a tool fails. Feed the error back to the model and let it decide what to do. The model might choose to try a different tool, ask the user for clarification, or give up gracefully. Blindly retrying the same tool call with the same parameters rarely helps."},{"heading":"Observability: What to Log","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#observability-what-to-log","content":"Every production agent needs comprehensive logging. Here's the minimum:\n\n```javascript\nfunction logToolCall(call, result, metadata) {\n const logEntry = {\n timestamp: new Date().toISOString(),\n conversation_id: metadata.conversationId,\n turn_number: metadata.turnNumber,\n model: metadata.model,\n\n // The full model response that triggered this call\n model_response_id: metadata.responseId,\n\n // Tool call details\n tool_name: call.function.name,\n tool_arguments: JSON.parse(call.function.arguments),\n tool_call_id: call.id,\n\n // Result\n success: !result.error,\n result_summary: summarizeResult(result),\n execution_time_ms: metadata.executionTimeMs,\n\n // Context\n user_id: metadata.userId,\n session_id: metadata.sessionId,\n };\n\n // Structured log, ship to your observability stack\n logger.info('tool_call', logEntry);\n\n // Metrics\n metrics.increment('tool_calls_total', {\n tool: call.function.name,\n success: String(!result.error),\n });\n metrics.histogram('tool_call_duration_ms', metadata.executionTimeMs, {\n tool: call.function.name,\n });\n}\n```\n\nWhen something goes wrong at 2 AM, and it will, the ability to reconstruct the entire conversation: what the user said, what the model decided, what tools it called, what results it got, and what it said next. Without this, debugging agent failures is guesswork."},{"heading":"The Routing Pattern: Scaling Beyond 20 Tools","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#the-routing-pattern-scaling-beyond-20-tools","content":"When you have more tools than a model can reliably choose from, use a two-stage approach:\n\n```javascript\nconst TOOL_CATEGORIES = {\n billing: ['get_invoices', 'create_refund', 'update_payment_method', 'get_subscription'],\n support: ['create_ticket', 'get_ticket_status', 'escalate_ticket', 'close_ticket'],\n account: ['get_profile', 'update_profile', 'reset_password', 'get_login_history'],\n product: ['search_products', 'get_product_details', 'check_inventory', 'get_pricing'],\n};\n\n// Stage 1: Route to category\nconst routerTools = [{\n name: \"select_tool_category\",\n description: \"Select the most relevant tool category for the user's request\",\n parameters: {\n type: \"object\",\n properties: {\n category: {\n type: \"string\",\n enum: Object.keys(TOOL_CATEGORIES),\n },\n reason: { type: \"string\" },\n },\n required: [\"category\", \"reason\"],\n },\n}];\n\n// Stage 2: Use category-specific tools\nasync function routedAgent(messages) {\n // First, determine the category\n const routingResponse = await callModel({\n messages,\n tools: routerTools,\n tool_choice: { type: \"function\", function: { name: \"select_tool_category\" } },\n });\n\n const category = routingResponse.tool_calls[0].function.arguments.category;\n const relevantTools = TOOL_CATEGORIES[category].map(name => toolRegistry.get(name));\n\n // Then, run the agent with only the relevant tools\n return runAgent(messages, relevantTools);\n}\n```\n\nThis two-stage pattern keeps each model call focused on a small set of tools, dramatically improving selection accuracy. The overhead of the extra API call is negligible compared to the cost of a wrong tool selection."},{"heading":"Key Lessons from Production Agent Development","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#key-lessons-from-production-agent-development","content":"1. **Schema design is your primary lever.** Spend more time designing tool schemas than writing orchestration code. Constrained enums, required reason fields, and clear descriptions do more for reliability than any retry logic.\n2. **The model is not your executor, it's your planner.** Don't give the model tools that do atomic, irreversible actions. Give it tools that prepare actions, and let your code handle execution with proper safeguards.\n3. **Error messages are prompts.** Every string your tool returns is a prompt to the model. Write error messages as instructions for recovery, not as developer debug output.\n4. **Parallel tool calls are a feature, not a bug.** Design your tools to be parallelizable by default. Read operations should always be safe to parallelize. Mutations should be explicit about their dependencies.\n5. **Log everything.** The first time you debug a production agent issue without full conversation logs, the value of logging becomes unmistakable.\n6. **The max iteration guard is not optional.** Models in loops can burn through your API budget in minutes. Always cap iterations and always have a cost circuit breaker.\n\nThe tooling is improving fast. Structured output modes make schema compliance guaranteed. Multi-turn conversation handling is getting more reliable. But the fundamental challenge remains: you're building a system where a probabilistic text generator makes decisions about deterministic operations. Respect that gap, design for it, and your agents will be the ones that actually work in production."},{"heading":"Reproducible evaluation: hostile instructions and permissions","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#reproducible-evaluation-hostile-instructions-and-permissions","content":"[Agent Injection Evals](<https://lucioduran.com/projects/agent-injection-evals>) crosses plain or instruction-isolated prompts with permissive or task-scoped tool policies. It uses the same pinned local model and preserves all 120 evaluation generations, including malformed outputs and failed legitimate tasks.\n\nIsolation allowed five unauthorized writes; combined protection attempted the same five and policy blocked them. No strategy completed any of the six clean tasks. Calling that zero attack rate a winning defense would hide the problem: the agent was not doing its job either. The project page separates capability, attempts, effects, blocked calls and resource use. These are simulated tools and a small synthetic set, not a security validation for arbitrary agents."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#questions","content":""},{"heading":"What's the difference between function calling and tool use?","level":3,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#question-1","content":"They're the same thing with different branding. OpenAI calls it 'function calling' (now 'tool use'), Anthropic calls it 'tool use'. The mechanism is identical: the model outputs a structured JSON object indicating which function to call with what arguments, and your code executes it."},{"heading":"Can I trust LLMs to generate valid JSON for tool calls?","level":3,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#question-2","content":"With structured output modes (OpenAI's strict mode, Anthropic's tool\\_use), the JSON schema is guaranteed to be valid. The values within the JSON can still be wrong, the model might call the right function with a hallucinated ID, but the structure will match your schema."},{"heading":"How many tools can I give a model?","level":3,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#question-3","content":"Practically, 10-20 tools work well. Beyond 30, accuracy starts degrading as the model has more options to confuse. If you need more, use a routing layer, have one model pick a tool category, then a second call with only the relevant tools."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#article-tips","content":"1. **Tip:** Define tool parameters with ` enum ` constraints wherever possible instead of free-text strings. An LLM picking from ` [\"buy\", \"sell\", \"hold\"] ` is dramatically more reliable than interpreting natural language intent.\n2. **Tip:** Always include a ` reason ` parameter in your tool schemas. Forcing the model to articulate *why* it's calling a tool before providing the arguments measurably reduces hallucinated parameters.\n3. **Tip:** Set ` tool_choice: 'required' ` only when you're certain the user's message needs a tool call. Leaving it on ` 'auto' ` by default prevents the model from forcing unnecessary tool calls on conversational messages.\n4. **Tip:** Log every tool call with the full model response, not just the extracted parameters. When debugging production issues, you need the model's chain of thought, not just what it decided to call.\n\n- llm\n- function-calling\n- tool-use\n- ai-agents\n- structured-output\n- openai\n- anthropic\n- langchain\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/llm-function-calling-tool-use-patterns"},{"text":"Agent Injection Evals","url":"https://lucioduran.com/projects/agent-injection-evals"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns"},"headline":"LLM Function Calling Patterns for Reliable Tool Use in AI Agents","description":"A technical guide to LLM tool use, structured output, parallel tool calls, retry strategies, error recovery, orchestration patterns, and lessons from production AI agent development.","datePublished":"2026-01-17","dateModified":"2026-10-21","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"AI/ML","keywords":"llm, function-calling, tool-use, ai-agents, structured-output, openai, anthropic, langchain","wordCount":2424,"timeRequired":"PT13M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/llm-function-calling-tool-use-patterns"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"LLM Function Calling Patterns for Reliable Tool Use in AI Agents","item":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What's the difference between function calling and tool use?","acceptedAnswer":{"@type":"Answer","text":"They're the same thing with different branding. OpenAI calls it 'function calling' (now 'tool use'), Anthropic calls it 'tool use'. The mechanism is identical: the model outputs a structured JSON object indicating which function to call with what arguments, and your code executes it."}},{"@type":"Question","name":"Can I trust LLMs to generate valid JSON for tool calls?","acceptedAnswer":{"@type":"Answer","text":"With structured output modes (OpenAI's strict mode, Anthropic's tool_use), the JSON schema is guaranteed to be valid. The values within the JSON can still be wrong, the model might call the right function with a hallucinated ID, but the structure will match your schema."}},{"@type":"Question","name":"How many tools can I give a model?","acceptedAnswer":{"@type":"Answer","text":"Practically, 10-20 tools work well. Beyond 30, accuracy starts degrading as the model has more options to confuse. If you need more, use a routing layer, have one model pick a tool category, then a second call with only the relevant tools."}}]}]}],"contentHash":"0799dd00bb35a2fa1e9a2353f375ca645ba5ba1a8cdf258ea57805713760a895","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/llm-function-calling-tool-use-patterns","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":81357,"contentBytes":20726}}
