Tool calls: asking for a tool
A tool call is a request the model returns instead of text; the agent runs the tool, adds the result and calls the model again, until it answers in words.
Last updated: 28 Sep, 2026 · CrewAI 1.15
The tools lesson built the tool and gave it to no one. The model from the custom-LLM lesson could only say it had no way to look an order up. Given the tool, it needs to ask for it and read the result back.
View the code here
from crewai.tools import tool
ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order's shipping status by its id, such as A17."""
status = ORDERS.get(order_id)
return f"{order_id} {status}." if status else f"{order_id} is not an order we have."
The clerk with the tool
from crewai import Agent, Crew, Task
from shop_llm import ShopLLM
from tools import lookup_order
clerk = Agent(
role="Order clerk",
goal="Find the status of customers' orders",
backstory="You can look up any order in the shop's system.",
llm=ShopLLM(model="shop"),
tools=[lookup_order],
)
task = Task(description="Answer the customer: {question}",
expected_output="The order's status in one sentence.", agent=clerk)
crew = Crew(agents=[clerk], tasks=[task])result = crew.kickoff(inputs={"question": "Where is my order A17?"})
print(result.raw)I have no way to look up A17 yet.
The clerk has the tool, and still cannot look the order up. CrewAI asks a model whether it can make native tool calls, through a method called supports_function_calling. BaseLLM has no such method, so without it, CrewAI describes the tools in the prompt as text and expects the model to write its request in a fixed text format. The custom-LLM lesson's model does neither.
A model that asks for a tool
import json
import os
import re
from crewai import BaseLLM
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
os.environ["CREWAI_TRACING_ENABLED"] = "false"
os.environ["CREWAI_DISABLE_VERSION_CHECK"] = "true"One new import, json: a tool call carries its arguments as a JSON string, which decide builds with json.dumps. The three settings stay as they were.
class ShopLLM(BaseLLM):
script: list = []
def supports_function_calling(self):
return True
def call(self, messages, tools=None, **kwargs):
if isinstance(messages, str):
messages = [{"role": "user", "content": messages}]
if self.script:
return self.script.pop(0)
names = [t["function"]["name"] for t in tools or []]
return self.decide(messages, names)supports_function_calling returning True makes CrewAI pass the tools to call as a list of schemas. call keeps only their names and hands the choice to a new method, decide. script is a list of fixed replies returned first, in order; from the max_iter lesson on it lets a lesson force a particular reply.
def decide(self, messages, tools):
last = messages[-1]
if last["role"] == "tool":
return last["content"]
text = last["content"]
orders = re.findall(r"\b[A-Z]\d+\b", text)
wanted = "refund_order" if "refund" in text.lower() else "lookup_order"
matches = [name for name in tools if name.endswith(wanted)]A message with the role tool is a tool's result coming back, and the model answers with it. Otherwise it picks the tool it wants: refund_order when the text mentions a refund, which arrives in the tool-hooks lesson, and lookup_order otherwise. It matches names by their ending, because tools from an MCP server in the MCP lesson carry a prefix.
if orders and matches:
args = json.dumps({"order_id": orders[0]})
return [{"id": f"call_{orders[0]}", "type": "function",
"function": {"name": matches[0], "arguments": args}}]
if orders:
return f"I have no way to look up {orders[0]} yet."
return "Hello. Which order is this about?"A tool call is a dictionary in the format OpenAI's API uses: an id, and the function's name with its arguments as a JSON string. Returning a list of them, instead of text, is how a model asks. Save the file; every lesson from here on imports it.
The tool-calling loop
result = crew.kickoff(inputs={"question": "Where is my order A17?"})
for message in result.tasks_output[0].messages:
text = message["content"].strip().split("\n")[0]
print(f"{message['role']:<9}", message.get("tool_calls") or text)system You are Order clerk. You can look up any order in the shop's system.
user Current Task: Answer the customer: Where is my order A17?
assistant [{'id': 'call_A17', 'type': 'function', 'function': {'name': 'lookup_order', 'arguments': '{"order_id": "A17"}'}}]
tool A17 shipped on 3 March.
assistant A17 shipped on 3 March.The system and user messages are the agent-prompt lesson's. The first assistant message has no text, only a tool call; CrewAI saw it, ran lookup_order, and added the tool message with the result. The model was called again and the last assistant message is its answer. Text with no tool calls is what ended the loop.
Pick one to watch it run, step by step.
The same five messages as a picture. Trace the second question through it to see the round the model skips when there is no order id.
Two more questions through the loop
print(crew.kickoff(inputs={"question": "Where is my order B22?"}).raw)
print(crew.kickoff(inputs={"question": "Hello"}).raw)B22 is not an order we have. Hello. Which order is this about?
B22 went through the same loop, and the tool's answer became the reply. "Hello" names no order, so the model answered on the first call and no tool ran. The model decides how many times the loop goes round.
Reading the message trail
- supports_function_calling must return True, or CrewAI never sends the tool schemas and the tool is never called.
- An assistant message with a tool call has no text; the tool message that follows carries the result.
- Text with no tool call ends the loop; the model decides how many rounds it takes.
What call returns
| Return value | Means |
|---|---|
| a string | a final answer; the loop ends |
| a list of tool-call dicts | run these tools, then call me again |
When the loop runs more than once
- A lookup then an answer built from its result, as here.
- Two tools in a row, where the second needs the first tool's output.
- A retry, where a failed tool result sends the model round again.
Related
- Previous: Tools: functions an agent can use
- Next: Tool failures mid-run
- Remove
tools=[lookup_order]from the clerk and ask about A17 again. - Ask "Where are A17 and C40?" and read which order the model picks.
- Print
result.tasks_output[0].messages[2]and find the tool call's id.
This is what real progress feels like.