Most “agent” demos start with a dozen tools, a complex planner, and a loop that can run for minutes. When something goes wrong—and something always goes wrong—the failure is buried under layers of framework code. You cannot see which tool was chosen, what arguments were generated, or why the model decided to call it again.
I no longer start there.
This post builds the smallest useful tool-calling workflow I know how to write with an Arcee model. One or two tools, an explicit decision step, visible arguments, and a hard limit on the number of turns. The result is easy to inspect, easy to debug, and easy to turn into a more capable agent later—once the basic control flow is solid.
Trinity Mini is the model I use for these examples. It has been reliable enough at following tool schemas when the prompt is strict and the temperature is low. The same pattern works with Nano for the simplest cases and with Large Thinking when the planning needs more depth.
What You Will Build

A single Python function that:
Accepts a user request and a list of available tools (name, description, JSON schema for arguments).
Asks the model to choose at most one tool and supply valid arguments.
Validates the tool call.
Executes the tool (or simulates it).
Feeds the result back and either returns a final answer or stops after a fixed number of steps.
No autonomous loop that can wander forever. No hidden state. Everything the model sees and decides is printed.
Defining Tools as Plain Data
I keep tool definitions boring and serializable.
Python
from pydantic import BaseModel, Field
from typing import Callable, Any
class Tool(BaseModel):
name: str
description: str
parameters: dict # JSON Schema
function: Callable[..., Any] # the actual Python function
def get_weather(city: str) -> str:
# Replace with a real API call; this is a stub for the example
return f"Weather in {city}: 18°C, clear"
weather_tool = Tool(
name="get_weather",
description="Return the current weather for a given city",
parameters={
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"],
},
function=get_weather,
)You can add a second tool the same way. Keep the list short while you are learning the control flow.
The Decision Prompt
The model is not asked to “be an agent.” It is asked to make one explicit choice.
Python
def build_tool_decision_prompt(user_message: str, tools: list[Tool]) -> list[dict]:
tool_list = "\n".join(
f"- {t.name}: {t.description}\n Parameters: {t.parameters}"
for t in tools
)
system = (
"You are a careful tool-using assistant. "
"You have access to the following tools:\n"
f"{tool_list}\n\n"
"If a tool is needed, respond with a JSON object of the form:\n"
'{"tool": "tool_name", "arguments": {...}}\n'
"If no tool is needed, respond with:\n"
'{"tool": null, "answer": "your final answer"}\n'
"Never call more than one tool at a time. "
"Do not invent tools or arguments. "
"Output only the JSON object, nothing else."
)
return [
{"role": "system", "content": system},
{"role": "user", "content": user_message},
]The schema is simple on purpose. Complex parallel tool calling and multi-step planning come later, after this path is reliable.
The Inspectable Loop
Python
import json
from typing import Optional
def run_tool_workflow(
user_message: str,
tools: list[Tool],
generate_fn,
max_steps: int = 3,
) -> dict:
messages = build_tool_decision_prompt(user_message, tools)
tool_map = {t.name: t for t in tools}
history = []
for step in range(max_steps):
raw = generate_fn(messages) # your existing local or API call
text = raw.strip()
# Defensive cleanup
if text.startswith("```"):
text = text.strip("`").removeprefix("json").strip()
try:
decision = json.loads(text)
except json.JSONDecodeError as e:
return {
"status": "error",
"error": f"Invalid JSON from model: {e}",
"raw": text,
"history": history,
}
history.append({"step": step, "decision": decision})
tool_name = decision.get("tool")
if tool_name is None:
return {
"status": "final",
"answer": decision.get("answer", ""),
"history": history,
}
if tool_name not in tool_map:
return {
"status": "error",
"error": f"Unknown tool: {tool_name}",
"history": history,
}
tool = tool_map[tool_name]
args = decision.get("arguments", {})
# Execute
try:
result = tool.function(**args)
except Exception as e:
result = f"Tool error: {e}"
history[-1]["result"] = result
# Feed the observation back
messages.append({"role": "assistant", "content": text})
messages.append({
"role": "user",
"content": f"Tool result: {result}\n\nDecide the next action (another tool call or final answer)."
})
return {
"status": "max_steps_reached",
"history": history,
}Every decision, every argument list, and every tool result is stored in history. When the workflow finishes (or hits the step limit) you can print the entire trace. That trace is the feature.

Design Choices That Keep It Debuggable
One tool at a time. Parallel calls and multi-tool planning add failure modes that are hard to diagnose while you are still learning. Add them only after single-tool calling is solid.
Hard step limit. Three is enough for most simple workflows. If the model has not finished by then, something is wrong with the prompt or the tool design.
JSON-only responses. The same validation discipline from the structured-output post applies. If the model drifts, the parse fails loudly instead of producing a half-working agent.
Explicit final-answer path. The model must actively choose to stop. This prevents the common failure where the model keeps calling tools forever because it was never told how to finish.
Visible history. I print the full trace in development. In production I log it. Either way the information is not thrown away.
What I Observe With Trinity Mini
On clean, single-tool requests (weather, simple calculator, document lookup) Mini produces valid tool calls at a high rate when temperature is 0.0–0.2 and the schema is shown clearly. Argument errors still happen—missing required fields, wrong types, hallucinated parameter names—but the validation step catches them before execution.
When the user request is ambiguous, Mini sometimes chooses a tool that is only marginally relevant. The visible history makes those choices obvious so you can tighten the tool descriptions or the decision prompt.
I do not treat these observations as permanent. A new model revision can shift the behavior, which is why the revision pin and a small smoke suite of tool-calling examples stay in the repository.
Common Failure Modes and How the Trace Reveals Them
Symptom | What the history usually shows |
|---|---|
Model invents a tool name | Decision JSON contains a name not in the tool list |
Wrong argument types | Arguments fail when passed to the real Python function |
Infinite tool loop | Step limit is hit; each step calls a tool instead of returning an answer |
Tool called when none is needed | Decision contains a tool name on a pure information request |
Final answer ignores tool result | The observation was appended, but the next decision never referenced it |
All of these are trivial to spot once the history is printed. They are painful to diagnose inside a large agent framework.
A Practical Note From Use
The first tool-calling system I put into a real internal tool started as a full agent with five tools and an open-ended loop. It worked in the demo and failed in subtle ways once real users asked real questions. I stripped it down to the pattern above—one tool, visible decisions, hard step limit—and the failure rate dropped sharply. Only after the simple path was reliable did I carefully add a second tool and a second optional step. The discipline of starting small has been more valuable than any framework feature.
Next Steps
Once this workflow is stable you can:
Add a second tool and watch how the decision quality changes
Introduce the structured-output validation loop around the decision JSON
Persist the history for later analysis
Wrap the same function behind a FastAPI endpoint
Only then consider a more open-ended agent loop
Tool calling is not magic. It is a constrained decision step plus an execution step plus a clear stopping condition. Keep those pieces visible and the Arcee model becomes a reliable component instead of an opaque planner.
Test it. Understand it. Then ship it.
— Theo Calder
Independent AI developer, San Francisco