- SDK
- Tool Calls
SDK
Tool Calls
Define custom functions for your assistant to call, handle tool call requests, and submit outputs.
Overview
Define custom functions (tools) for the model to call. When the model decides to use a tool, it returns REQUIRES_ACTION status with one or more tool calls. You execute the functions locally and submit the outputs back to continue.
The model may request multiple tool calls in parallel (each with a unique tool_call_id) or chain tool calls across rounds — keep looping until status is COMPLETED.
Tool definition format
Tools follow the OpenAI function-calling schema:
weather_tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. San Francisco",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit",
},
},
"required": ["city"],
},
},
}
Schema reference
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Always "function" |
function.name | string | Yes | Function name the model will call |
function.description | string | Recommended | What the function does — helps the model decide when to call it |
function.parameters | object | Yes | JSON Schema for function parameters |
function.parameters.properties | object | Yes | Individual parameter definitions |
function.parameters.required | array | No | Names of required parameters |
Non-streaming: single tool call
Python
JavaScript
TypeScript
import asyncio
import json
from backboard import BackboardClient
# Simulates looking up weather data
def get_weather(city: str) -> dict:
return {"city": city, "temperature": "72°F", "condition": "Sunny"}
async def main():
client = BackboardClient(api_key="YOUR_API_KEY")
weather_tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
},
},
}
# Step 1: send a message with tools
response = await client.send_message(
"What's the weather like in Tokyo?",
tools=[weather_tool],
)
# Step 2: check if the model wants to call a tool
if response.status == "REQUIRES_ACTION" and response.tool_calls:
tool_outputs = []
for tool_call in response.tool_calls:
args = tool_call.function.parsed_arguments
result = get_weather(city=args["city"])
tool_outputs.append({
"tool_call_id": tool_call.id,
"output": json.dumps(result),
})
# Step 3: submit tool outputs — the model generates a final reply
final_response = await client.submit_tool_outputs_simple(
thread_id=response.thread_id,
tool_outputs=tool_outputs,
)
print(final_response.content)
if __name__ == "__main__":
asyncio.run(main())
Streaming: tool calls
When streaming, tool calls arrive as a tool_submit_required SSE event instead of a REQUIRES_ACTION status.
SSE events during a tool call flow
| Event type | When it fires | Key fields |
|---|---|---|
content_streaming | Each piece of the assistant’s text | content |
tool_call_start | A tool call’s name is known, arguments still streaming | run_id, tool_call_id, name |
tool_call_ready | A tool call’s arguments have finished streaming | run_id, tool_call |
tool_submit_required | Model is requesting tool calls | tool_calls, run_id, model_provider, model_name, usage fields |
run_ended | The run finished successfully | thread_id, assistant_id, status, usage fields |
run_failed | The run failed | run_id, error, error_type |
error | The request could not be processed | error |
This table covers the events involved in a tool call flow. See Messages for the full list.
tool_submit_required does not carry thread_id. Track it from the request you made, or from an event that does include it, as the example below does.
Python
JavaScript
import asyncio
import json
from backboard import BackboardClient
def get_weather(city: str) -> dict:
return {"city": city, "temperature": "72°F", "condition": "Sunny"}
async def main():
client = BackboardClient(api_key="YOUR_API_KEY")
weather_tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
},
},
}
# Step 1: stream a message with tools
thread_id = None
async for chunk in await client.send_message(
"What's the weather like in Tokyo?",
tools=[weather_tool],
stream=True,
):
# Not every event carries thread_id — hold on to it when it appears.
if chunk.get("thread_id"):
thread_id = chunk["thread_id"]
if chunk.get("type") == "content_streaming":
print(chunk.get("content", ""), end="", flush=True)
elif chunk.get("type") == "tool_submit_required":
tool_calls = chunk.get("tool_calls", [])
# Execute each tool call
tool_outputs = []
for tc in tool_calls:
args = json.loads(tc["function"]["arguments"])
result = get_weather(city=args["city"])
tool_outputs.append({
"tool_call_id": tc["id"],
"output": json.dumps(result),
})
# Submit outputs and stream the final reply
async for sub_chunk in await client.submit_tool_outputs_simple(
thread_id=thread_id,
tool_outputs=tool_outputs,
stream=True,
):
if sub_chunk.get("type") == "content_streaming":
print(sub_chunk.get("content", ""), end="", flush=True)
print()
if __name__ == "__main__":
asyncio.run(main())
Early tool-call events
tool_call_start and tool_call_ready arrive before tool_submit_required. Use them to show a tool call in your UI as soon as the model commits to it, instead of waiting for the turn to finish.
| Event | Arrives when | Payload |
|---|---|---|
tool_call_start | The call’s id and name are known; arguments still streaming | { run_id, tool_call_id, name } |
tool_call_ready | The call’s arguments have finished streaming | { run_id, tool_call } where tool_call matches the shape in tool_submit_required |
For a given tool call, start always precedes ready, and each fires at most once.
The SDKs pass every SSE chunk through to you, including events they do not model as typed objects. Switch on chunk["type"] to handle these events today — no SDK upgrade is required.
Python
JavaScript
TypeScript
pending = {}
async for chunk in await client.send_message(
"What's the weather like in Tokyo?",
tools=[weather_tool],
stream=True,
):
event_type = chunk.get("type")
if event_type == "tool_call_start":
# Render a spinner as soon as the tool name is known.
pending[chunk["tool_call_id"]] = chunk["name"]
print(f"\n[calling {chunk['name']}...]", flush=True)
elif event_type == "tool_call_ready":
call = chunk["tool_call"]
print(f"[{call['function']['name']} args ready]", flush=True)
elif event_type == "tool_submit_required":
# Authoritative list — always reconcile against this.
for tc in chunk.get("tool_calls", []):
...
Rules for using them safely
Never submit tool outputs based on tool_call_ready alone. tool_submit_required is the authoritative frame — a call announced early may be absent from the final list, and submitting for a call that was never confirmed will desynchronize the run.
- Reconcile, don’t assume. Treat early events as UI hints or speculative work. When
tool_submit_requiredarrives, match bytool_call_idand discard anything it does not confirm. - Deduplicate by
tool_call_id. A single run can emit these events across multiple rounds. Ignore a repeatedtool_call_idyou have already handled. Whentool_call.idisnullthere is nothing to deduplicate on — skip the early event and wait fortool_submit_required, whose calls always carry ids. - Only external tools are announced. Tools Backboard runs itself — document search, web search, and image generation — never produce these events.
- Executing early is opt-in. If you start work when
tool_call_readyarrives, restrict it to side-effect-free operations, and be ready to discard the result if the call is not confirmed.
Parallel tool calls
The model can request multiple tools in a single response. Each tool call has its own tool_call_id. Execute all of them and submit all outputs together:
# The model might return two tool calls at once:
# - get_weather(city="Tokyo")
# - get_weather(city="London")
tool_outputs = []
for tool_call in response.tool_calls:
args = tool_call.function.parsed_arguments
result = get_weather(city=args["city"])
tool_outputs.append({
"tool_call_id": tool_call.id,
"output": json.dumps(result),
})
# Submit all outputs in one call
final = await client.submit_tool_outputs_simple(
thread_id=response.thread_id,
tool_outputs=tool_outputs,
)
print(final.content)
Chained tool calls (multi-round)
The model may need several rounds of tool calls. After submitting outputs, check if the response has another REQUIRES_ACTION — keep looping until COMPLETED:
Python
JavaScript
TypeScript
import json
from backboard import BackboardClient
def dispatch_tool(name: str, args: dict) -> dict:
"""Route tool calls to the right function."""
if name == "get_weather":
return {"city": args["city"], "temperature": "72°F", "condition": "Sunny"}
if name == "get_forecast":
return {"city": args["city"], "forecast": "Sunny for the next 3 days"}
return {"error": f"Unknown tool: {name}"}
async def run_with_tools(client, message, tools):
response = await client.send_message(message, tools=tools)
# Keep going until the model is done calling tools
while response.status == "REQUIRES_ACTION" and response.tool_calls:
tool_outputs = []
for tc in response.tool_calls:
result = dispatch_tool(tc.function.name, tc.function.parsed_arguments)
tool_outputs.append({
"tool_call_id": tc.id,
"output": json.dumps(result),
})
response = await client.submit_tool_outputs_simple(
thread_id=response.thread_id,
tool_outputs=tool_outputs,
)
return response.content
Submitting tool outputs
There are two endpoints for submitting tool outputs:
| Endpoint | Description |
|---|---|
POST /threads/tool-outputs | Recommended. Pass thread_id in the body — run_id is auto-resolved |
POST /threads/{thread_id}/runs/{run_id}/submit-tool-outputs | Legacy. Supports tools override and per-step thinking (reasoning) |
Reasoning after tool calls
On the legacy endpoint, pass thinking when submitting outputs to control the model’s reasoning on the continuation step. The response includes a reasoning field when thinking is enabled.
response = await client.submit_tool_outputs(
thread_id=thread_id,
run_id=run_id,
tool_outputs=outputs,
thinking={"effort": "medium"},
)
print(response.reasoning)
Both endpoints accept the same tool_outputs array:
{
"thread_id": "...",
"tool_outputs": [
{
"tool_call_id": "call_abc123",
"output": "{\"temperature\": \"72°F\"}"
}
]
}
Per-turn tools
tools applies to the current turn only. If you omit tools on the next message, the assistant’s stored tools (set via create_assistant) apply instead:
# Turn 1: pass tools explicitly
first = await client.send_message(
"Look up the latest headlines",
thread_id=thread_id,
tools=[news_tool],
)
# Turn 2: no tools passed — falls back to assistant's stored tools (if any)
second = await client.send_message(
"Summarize what you found",
thread_id=thread_id,
)
Legacy methods: add_message + submit_tool_outputs (which require a pre-created assistant/thread and explicit run_id) are still fully supported. See the API reference for details.