1. SDK
  2. Tool Calls

​
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

FieldTypeRequiredDescription
typestringYesAlways "function"
function.namestringYesFunction name the model will call
function.descriptionstringRecommendedWhat the function does — helps the model decide when to call it
function.parametersobjectYesJSON Schema for function parameters
function.parameters.propertiesobjectYesIndividual parameter definitions
function.parameters.requiredarrayNoNames 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 typeWhen it firesKey fields
content_streamingEach piece of the assistant’s textcontent
tool_call_startA tool call’s name is known, arguments still streamingrun_id, tool_call_id, name
tool_call_readyA tool call’s arguments have finished streamingrun_id, tool_call
tool_submit_requiredModel is requesting tool callstool_calls, run_id, model_provider, model_name, usage fields
run_endedThe run finished successfullythread_id, assistant_id, status, usage fields
run_failedThe run failedrun_id, error, error_type
errorThe request could not be processederror

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.

EventArrives whenPayload
tool_call_startThe call’s id and name are known; arguments still streaming{ run_id, tool_call_id, name }
tool_call_readyThe 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_required arrives, match by tool_call_id and discard anything it does not confirm.
  • Deduplicate by tool_call_id. A single run can emit these events across multiple rounds. Ignore a repeated tool_call_id you have already handled. When tool_call.id is null there is nothing to deduplicate on — skip the early event and wait for tool_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_ready arrives, 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:

EndpointDescription
POST /threads/tool-outputsRecommended. Pass thread_id in the body — run_id is auto-resolved
POST /threads/{thread_id}/runs/{run_id}/submit-tool-outputsLegacy. 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.