> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/ably/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Tool calls

Modern AI models can invoke tools (also called functions) to perform specific tasks like retrieving data, performing calculations, or triggering actions. Streaming tool call information to users provides visibility into what the AI is doing, creates opportunities for rich generative UI experiences, and builds trust through transparency.

## What are tool calls? <a id="what" />

Tool calls occur when an AI model decides to invoke a specific function or tool to accomplish a task. Rather than only returning text, the model can request to execute tools you've defined, such as fetching weather data, searching a database, or performing calculations.

A tool call consists of:

* Tool name: The identifier of the tool being invoked
* Tool input: Parameters passed to the tool, often structured as JSON
* Tool output: The result returned after execution

As an application developer, you decide how to surface tool calls to users. You may choose to display all tool calls, selectively surface specific tools or inputs/outputs, or keep tool calls entirely private.

Surfacing tool calls supports:

* Trust and transparency: Users see what actions the AI is taking, building confidence in the agent
* Human-in-the-loop workflows: Expose tool calls [resolved by humans](/docs/ai-transport/messaging/human-in-the-loop) where users can review and approve tool execution before it happens
* Generative UI: Build dynamic, contextual UI components based on the structured tool data

## Publish tool calls <a id="publishing" />

Publish tool call and model output messages to the channel.

In the example below, the `responseId` is included in the message [extras](/docs/messages#properties) to allow subscribers to correlate all messages belonging to the same response. The message [`name`](/docs/messages#properties) allows the client to distinguish between the different message types:

<Code>
  ```javascript theme={null}
  const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');

  // Example: stream returns events like:
  // { type: 'tool_call', name: 'get_weather', args: '{"location":"San Francisco"}', toolCallId: 'tool_123', responseId: 'resp_abc123' }
  // { type: 'tool_result', name: 'get_weather', result: '{"temp":72,"conditions":"sunny"}', toolCallId: 'tool_123', responseId: 'resp_abc123' }
  // { type: 'message', text: 'The weather in San Francisco is 72°F and sunny.', responseId: 'resp_abc123' }

  for await (const event of stream) {
    if (event.type === 'tool_call') {
      // Publish tool call arguments
      await channel.publish({
        name: 'tool_call',
        data: {
          name: event.name,
          args: event.args
        },
        extras: {
          headers: {
            responseId: event.responseId,
            toolCallId: event.toolCallId
          }
        }
      });
    } else if (event.type === 'tool_result') {
      // Publish tool call results
      await channel.publish({
        name: 'tool_result',
        data: {
          name: event.name,
          result: event.result
        },
        extras: {
          headers: {
            responseId: event.responseId,
            toolCallId: event.toolCallId
          }
        }
      });
    } else if (event.type === 'message') {
      // Publish model output messages
      await channel.publish({
        name: 'message',
        data: event.text,
        extras: {
          headers: {
            responseId: event.responseId
          }
        }
      });
    }
  }
  ```

  ```python theme={null}
  channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')

  # Example: stream returns events like:
  # { 'type': 'tool_call', 'name': 'get_weather', 'args': '{"location":"San Francisco"}', 'toolCallId': 'tool_123', 'responseId': 'resp_abc123' }
  # { 'type': 'tool_result', 'name': 'get_weather', 'result': '{"temp":72,"conditions":"sunny"}', 'toolCallId': 'tool_123', 'responseId': 'resp_abc123' }
  # { 'type': 'message', 'text': 'The weather in San Francisco is 72°F and sunny.', 'responseId': 'resp_abc123' }

  async for event in stream:
      if event['type'] == 'tool_call':
          # Publish tool call arguments
          message = Message(
              name='tool_call',
              data={
                  'name': event['name'],
                  'args': event['args']
              },
              extras={
                  'headers': {
                      'responseId': event['responseId'],
                      'toolCallId': event['toolCallId']
                  }
              }
          )
          await channel.publish(message)
      elif event['type'] == 'tool_result':
          # Publish tool call results
          message = Message(
              name='tool_result',
              data={
                  'name': event['name'],
                  'result': event['result']
              },
              extras={
                  'headers': {
                      'responseId': event['responseId'],
                      'toolCallId': event['toolCallId']
                  }
              }
          )
          await channel.publish(message)
      elif event['type'] == 'message':
          # Publish model output messages
          message = Message(
              name='message',
              data=event['text'],
              extras={
                  'headers': {
                      'responseId': event['responseId']
                  }
              }
          )
          await channel.publish(message)
  ```

  ```java theme={null}
  Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");

  // Example: stream returns events like:
  // { type: 'tool_call', name: 'get_weather', args: '{"location":"San Francisco"}', toolCallId: 'tool_123', responseId: 'resp_abc123' }
  // { type: 'tool_result', name: 'get_weather', result: '{"temp":72,"conditions":"sunny"}', toolCallId: 'tool_123', responseId: 'resp_abc123' }
  // { type: 'message', text: 'The weather in San Francisco is 72°F and sunny.', responseId: 'resp_abc123' }

  for (Event event : stream) {
      JsonObject extras = new JsonObject();
      JsonObject headers = new JsonObject();

      if (event.getType().equals("tool_call")) {
          // Publish tool call arguments
          JsonObject data = new JsonObject();
          data.addProperty("name", event.getName());
          data.addProperty("args", event.getArgs());

          headers.addProperty("responseId", event.getResponseId());
          headers.addProperty("toolCallId", event.getToolCallId());
          extras.add("headers", headers);

          channel.publish(new Message("tool_call", data, new MessageExtras(extras)));
      } else if (event.getType().equals("tool_result")) {
          // Publish tool call results
          JsonObject data = new JsonObject();
          data.addProperty("name", event.getName());
          data.addProperty("result", event.getResult());

          headers.addProperty("responseId", event.getResponseId());
          headers.addProperty("toolCallId", event.getToolCallId());
          extras.add("headers", headers);

          channel.publish(new Message("tool_result", data, new MessageExtras(extras)));
      } else if (event.getType().equals("message")) {
          // Publish model output messages
          headers.addProperty("responseId", event.getResponseId());
          extras.add("headers", headers);

          channel.publish(new Message("message", event.getText(), new MessageExtras(extras)));
      }
  }
  ```
</Code>

<Aside data-type="note">
  Model APIs like OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) and Anthropic's [Messages API](https://platform.claude.com/docs/en/api/messages) don't include tool results in their streams - instead, you execute tools in your code and return results to the model, but the model's output doesn't echo those results back. Agent SDKs like [OpenAI Agent SDK](https://platform.openai.com/docs/guides/agents-sdk) and [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) maintain context and surface both tool calls and results on the stream. When using model APIs directly, publish tool results to the channel separately if you want to surface them to subscribers.
</Aside>

<Aside data-type="note">
  To learn how to stream individual tokens as they are generated, see the [token streaming](/docs/ai-transport/token-streaming) documentation.
</Aside>

<Aside data-type="note">
  Set [`echoMessages`](/docs/api/realtime-sdk/types#client-options) to `false` on the agent's Ably client to prevent the agent from receiving its own tool call messages, avoiding billing for [echoed messages](/docs/pub-sub/advanced#echo).
</Aside>

## Subscribe to tool calls <a id="subscribing" />

Subscribe to tool call and model output messages on the channel.

In the example below, the `responseId` from the message [`extras`](/docs/api/realtime-sdk/messages#extras) is used to group tool calls and model output messages belonging to the same response. The message [`name`](/docs/messages#properties) allows the client to distinguish between the different message types:

<Code>
  ```javascript theme={null}
  const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');

  // Track responses by ID, each containing tool calls and final response
  const responses = new Map();

  // Subscribe to all events on the channel
  await channel.subscribe((message) => {
    const responseId = message.extras?.headers?.responseId;

    if (!responseId) {
      console.warn('Message missing responseId');
      return;
    }

    // Initialize response object if needed
    if (!responses.has(responseId)) {
      responses.set(responseId, {
        toolCalls: new Map(),
        message: ''
      });
    }

    const response = responses.get(responseId);

    // Handle each message type
    switch (message.name) {
      case 'message':
        response.message = message.data;
        break;
      case 'tool_call':
        const toolCallId = message.extras?.headers?.toolCallId;
        response.toolCalls.set(toolCallId, {
          name: message.data.name,
          args: message.data.args
        });
        break;
      case 'tool_result':
        const resultToolCallId = message.extras?.headers?.toolCallId;
        const toolCall = response.toolCalls.get(resultToolCallId);
        if (toolCall) {
          toolCall.result = message.data.result;
        }
        break;
    }

    // Display the tool calls and response for this turn
    console.log(`Response ${responseId}:`, response);
  });
  ```

  ```python theme={null}
  channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')

  # Track responses by ID, each containing tool calls and final response
  responses = {}

  # Subscribe to all events on the channel
  def on_message(message):
      response_id = message.extras.get('headers', {}).get('responseId')

      if not response_id:
          print('Message missing responseId')
          return

      # Initialize response object if needed
      if response_id not in responses:
          responses[response_id] = {
              'toolCalls': {},
              'message': ''
          }

      response = responses[response_id]

      # Handle each message type
      if message.name == 'message':
          response['message'] = message.data
      elif message.name == 'tool_call':
          tool_call_id = message.extras.get('headers', {}).get('toolCallId')
          response['toolCalls'][tool_call_id] = {
              'name': message.data['name'],
              'args': message.data['args']
          }
      elif message.name == 'tool_result':
          result_tool_call_id = message.extras.get('headers', {}).get('toolCallId')
          tool_call = response['toolCalls'].get(result_tool_call_id)
          if tool_call:
              tool_call['result'] = message.data['result']

      # Display the tool calls and response for this turn
      print(f'Response {response_id}:', response)

  await channel.subscribe(on_message)
  ```

  ```java theme={null}
  Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");

  // Track responses by ID, each containing tool calls and final response
  Map<String, Response> responses = new ConcurrentHashMap<>();

  // Subscribe to all events on the channel
  channel.subscribe(message -> {
      JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
      String responseId = headers != null ? headers.get("responseId").getAsString() : null;

      if (responseId == null) {
          System.err.println("Message missing responseId");
          return;
      }

      // Initialize response object if needed
      responses.putIfAbsent(responseId, new Response());
      Response response = responses.get(responseId);

      // Handle each message type
      switch (message.name) {
          case "message":
              response.setMessage((String) message.data);
              break;
          case "tool_call":
              String toolCallId = headers.get("toolCallId").getAsString();
              JsonObject data = (JsonObject) message.data;
              ToolCall toolCall = new ToolCall();
              toolCall.setName(data.get("name").getAsString());
              toolCall.setArgs(data.get("args").getAsString());
              response.getToolCalls().put(toolCallId, toolCall);
              break;
          case "tool_result":
              String resultToolCallId = headers.get("toolCallId").getAsString();
              ToolCall existingToolCall = response.getToolCalls().get(resultToolCallId);
              if (existingToolCall != null) {
                  JsonObject resultData = (JsonObject) message.data;
                  existingToolCall.setResult(resultData.get("result").getAsString());
              }
              break;
      }

      // Display the tool calls and response for this turn
      System.out.println("Response " + responseId + ": " + response);
  });
  ```
</Code>

<Aside data-type="further-reading">
  To learn about hydrating responses from channel history, including using `rewind` or `untilAttach`, handling in-progress responses, and correlating with database records, see client hydration in the [message-per-response](/docs/ai-transport/token-streaming/message-per-response#hydration) and [message-per-token](/docs/ai-transport/token-streaming/message-per-token#hydration) documentation.
</Aside>

## Generative UI <a id="generative-ui" />

Tool calls provide structured data that can form the basis of generative UI - dynamically creating UI components based on the tool being invoked, its parameters, and the results returned. Rather than just displaying raw tool call information, you can render rich, contextual components that provide a better user experience.

For example, when a weather tool is invoked, instead of showing raw JSON like `{ location: 'San Francisco', temp: 72, conditions: 'sunny' }`, you can render a weather card component with icons, formatted temperature, and visual indicators:

<Code>
  ```javascript theme={null}
  const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');

  await channel.subscribe((message) => {
    // Render component when tool is invoked
    if (message.name === 'tool_call' && message.data.name === 'get_weather') {
      const args = JSON.parse(message.data.args);
      renderWeatherCard({ location: args.location, loading: true });
    }

    // Update component with results
    if (message.name === 'tool_result' && message.data.name === 'get_weather') {
      const result = JSON.parse(message.data.result);
      renderWeatherCard(result);
    }
  });
  ```

  ```python theme={null}
  channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')

  def on_message(message):
      # Render component when tool is invoked
      if message.name == 'tool_call' and message.data['name'] == 'get_weather':
          args = json.loads(message.data['args'])
          render_weather_card({'location': args['location'], 'loading': True})

      # Update component with results
      if message.name == 'tool_result' and message.data['name'] == 'get_weather':
          result = json.loads(message.data['result'])
          render_weather_card(result)

  await channel.subscribe(on_message)
  ```

  ```java theme={null}
  Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");

  channel.subscribe(message -> {
      // Render component when tool is invoked
      if (message.name.equals("tool_call")) {
          JsonObject data = (JsonObject) message.data;
          if (data.get("name").getAsString().equals("get_weather")) {
              JsonObject args = JsonParser.parseString(data.get("args").getAsString()).getAsJsonObject();
              renderWeatherCard(args.get("location").getAsString(), true);
          }
      }

      // Update component with results
      if (message.name.equals("tool_result")) {
          JsonObject data = (JsonObject) message.data;
          if (data.get("name").getAsString().equals("get_weather")) {
              JsonObject result = JsonParser.parseString(data.get("result").getAsString()).getAsJsonObject();
              renderWeatherCard(result);
          }
      }
  });
  ```
</Code>

<Aside data-type="note">
  Models generate tool call arguments token by token, which you can stream to the client. When implementing token-level streaming, your UI should handle parsing partial JSON gracefully to render realtime updates as the arguments stream in. To learn more about approaches to token streaming, see the [token streaming](/docs/ai-transport/token-streaming) documentation.
</Aside>

## Client-side tools <a id="client-tools" />

Some tools need to be executed directly on the client device rather than on the server, allowing agents to dynamically access information available on the end user's device as needed. These include tools that access device capabilities such as GPS location, camera, SMS, local files, or other native functionality.

Client-side tool calls follow a request-response pattern over Ably channels:

1. The agent publishes a tool call request to the channel.
2. The client receives and executes the tool using device APIs.
3. The client publishes the result back to the channel.
4. The agent receives the result and continues processing.

<Aside data-type="further-reading">
  For more information about bi-directional communication patterns between agents and users, see [Accepting user input](/docs/ai-transport/messaging/accepting-user-input) and [Human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop).
</Aside>

The client subscribes to tool call requests, executes the tool using device APIs, and publishes the result back to the channel. The `toolCallId` enables correlation between tool call requests and results:

<Code>
  ```javascript theme={null}
  const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');

  await channel.subscribe('tool_call', async (message) => {
    const { name, args } = message.data;
    const { responseId, toolCallId } = message.extras?.headers || {};

    if (name === 'get_location') {
      const result = await getGeolocationPosition();
      await channel.publish({
        name: 'tool_result',
        data: {
          name: name,
          result: {
            lat: result.coords.latitude,
            lng: result.coords.longitude
          }
        },
        extras: {
          headers: {
            responseId: responseId,
            toolCallId: toolCallId
          }
        }
      });
    }
  });
  ```

  ```python theme={null}
  channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')

  async def on_tool_call(message):
      name = message.data['name']
      args = message.data.get('args')
      headers = message.extras.get('headers', {})
      response_id = headers.get('responseId')
      tool_call_id = headers.get('toolCallId')

      if name == 'get_location':
          result = await get_geolocation_position()
          message = Message(
              name='tool_result',
              data={
                  'name': name,
                  'result': {
                      'lat': result['coords']['latitude'],
                      'lng': result['coords']['longitude']
                  }
              },
              extras={
                  'headers': {
                      'responseId': response_id,
                      'toolCallId': tool_call_id
                  }
              }
          )
          await channel.publish(message)

  await channel.subscribe('tool_call', on_tool_call)
  ```

  ```java theme={null}
  Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");

  channel.subscribe("tool_call", message -> {
      JsonObject data = (JsonObject) message.data;
      String name = data.get("name").getAsString();
      JsonObject headers = message.extras.asJsonObject().get("headers");
      String responseId = headers.get("responseId").getAsString();
      String toolCallId = headers.get("toolCallId").getAsString();

      if (name.equals("get_location")) {
          GeolocationPosition result = getGeolocationPosition();

          JsonObject resultData = new JsonObject();
          resultData.addProperty("name", name);
          JsonObject resultValue = new JsonObject();
          resultValue.addProperty("lat", result.getCoords().getLatitude());
          resultValue.addProperty("lng", result.getCoords().getLongitude());
          resultData.add("result", resultValue);

          JsonObject resultExtras = new JsonObject();
          JsonObject resultHeaders = new JsonObject();
          resultHeaders.addProperty("responseId", responseId);
          resultHeaders.addProperty("toolCallId", toolCallId);
          resultExtras.add("headers", resultHeaders);

          channel.publish(new Message("tool_result", resultData, new MessageExtras(resultExtras)));
      }
  });
  ```
</Code>

<Aside data-type="note">
  Client-side tools often require user permission to access device APIs. These permissions are managed by the device operating system, not the agent. Handle permission denials gracefully by publishing an error tool result so the AI can respond appropriately.
</Aside>

<Aside data-type="note">
  Set [`echoMessages`](/docs/api/realtime-sdk/types#client-options) to `false` in the client options to prevent tool results from being echoed back to the client that published them. This avoids billing for the echoed message. When disabled, update your UI to reflect the tool execution status immediately upon sending rather than waiting for the echoed message. See [echoing messages](/docs/pub-sub/advanced#echo) for more details.
</Aside>

The agent subscribes to tool results to continue processing. The `toolCallId` correlates the result back to the original request:

<Code>
  ```javascript theme={null}
  const pendingToolCalls = new Map();

  await channel.subscribe('tool_result', (message) => {
    const { toolCallId, result } = message.data;
    const pending = pendingToolCalls.get(toolCallId);

    if (!pending) return;

    // Pass result back to the AI model to continue the conversation
    processResult(pending.responseId, toolCallId, result);

    pendingToolCalls.delete(toolCallId);
  });
  ```

  ```python theme={null}
  pending_tool_calls = {}

  def on_tool_result(message):
      tool_call_id = message.data.get('toolCallId')
      result = message.data.get('result')
      pending = pending_tool_calls.get(tool_call_id)

      if not pending:
          return

      # Pass result back to the AI model to continue the conversation
      process_result(pending['responseId'], tool_call_id, result)

      del pending_tool_calls[tool_call_id]

  await channel.subscribe('tool_result', on_tool_result)
  ```

  ```java theme={null}
  Map<String, PendingToolCall> pendingToolCalls = new ConcurrentHashMap<>();

  channel.subscribe("tool_result", message -> {
      JsonObject data = (JsonObject) message.data;
      String toolCallId = data.get("toolCallId").getAsString();
      JsonObject result = data.get("result").getAsJsonObject();
      PendingToolCall pending = pendingToolCalls.get(toolCallId);

      if (pending == null) {
          return;
      }

      // Pass result back to the AI model to continue the conversation
      processResult(pending.getResponseId(), toolCallId, result);

      pendingToolCalls.remove(toolCallId);
  });
  ```
</Code>

## Progress updates <a id="progress-updates" />

Some tool calls take significant time to complete, such as processing large files, performing complex calculations, or executing multi-step operations. For long-running tools, streaming progress updates to users provides visibility into execution status and improves the user experience by showing that work is actively happening.

You can deliver progress updates using two approaches:

* Messages: Best for discrete status updates and milestone events
* LiveObjects: Best for continuous numeric progress and shared state synchronization

### Progress updates via messages <a id="progress-messages" />

Publish progress messages to the channel as the tool executes, using the `toolCallId` to correlate progress updates with the specific tool call:

<Code>
  ```javascript theme={null}
  const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');

  // Publish initial tool call
  await channel.publish({
    name: 'tool_call',
    data: {
      name: 'process_document',
      args: { documentId: 'doc_123', pages: 100 }
    },
    extras: {
      headers: {
        responseId: 'resp_abc123',
        toolCallId: 'tool_456'
      }
    }
  });

  // Publish progress updates as tool executes
  await channel.publish({
    name: 'tool_progress',
    data: {
      name: 'process_document',
      status: 'Processing page 25 of 100',
      percentComplete: 25
    },
    extras: {
      headers: {
        responseId: 'resp_abc123',
        toolCallId: 'tool_456'
      }
    }
  });

  // Continue publishing progress as work progresses
  await channel.publish({
    name: 'tool_progress',
    data: {
      name: 'process_document',
      status: 'Processing page 75 of 100',
      percentComplete: 75
    },
    extras: {
      headers: {
        responseId: 'resp_abc123',
        toolCallId: 'tool_456'
      }
    }
  });

  // Publish final result
  await channel.publish({
    name: 'tool_result',
    data: {
      name: 'process_document',
      result: { processedPages: 100, summary: 'Document processed successfully' }
    },
    extras: {
      headers: {
        responseId: 'resp_abc123',
        toolCallId: 'tool_456'
      }
    }
  });
  ```

  ```python theme={null}
  channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')

  # Publish initial tool call
  await channel.publish(Message(
      name='tool_call',
      data={
          'name': 'process_document',
          'args': {'documentId': 'doc_123', 'pages': 100}
      },
      extras={
          'headers': {
              'responseId': 'resp_abc123',
              'toolCallId': 'tool_456'
          }
      }
  ))

  # Publish progress updates as tool executes
  await channel.publish(Message(
      name='tool_progress',
      data={
          'name': 'process_document',
          'status': 'Processing page 25 of 100',
          'percentComplete': 25
      },
      extras={
          'headers': {
              'responseId': 'resp_abc123',
              'toolCallId': 'tool_456'
          }
      }
  ))

  # Continue publishing progress as work progresses
  await channel.publish(Message(
      name='tool_progress',
      data={
          'name': 'process_document',
          'status': 'Processing page 75 of 100',
          'percentComplete': 75
      },
      extras={
          'headers': {
              'responseId': 'resp_abc123',
              'toolCallId': 'tool_456'
          }
      }
  ))

  # Publish final result
  await channel.publish(Message(
      name='tool_result',
      data={
          'name': 'process_document',
          'result': {'processedPages': 100, 'summary': 'Document processed successfully'}
      },
      extras={
          'headers': {
              'responseId': 'resp_abc123',
              'toolCallId': 'tool_456'
          }
      }
  ))
  ```

  ```java theme={null}
  Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");

  // Helper method to create message extras with headers
  MessageExtras createExtras(String responseId, String toolCallId) {
      JsonObject extrasJson = new JsonObject();
      JsonObject headers = new JsonObject();
      headers.addProperty("responseId", responseId);
      headers.addProperty("toolCallId", toolCallId);
      extrasJson.add("headers", headers);
      return new MessageExtras(extrasJson);
  }

  // Publish initial tool call
  JsonObject toolCallData = new JsonObject();
  toolCallData.addProperty("name", "process_document");
  JsonObject args = new JsonObject();
  args.addProperty("documentId", "doc_123");
  args.addProperty("pages", 100);
  toolCallData.add("args", args);

  Message toolCall = new Message(
      "tool_call",
      toolCallData.toString(),
      createExtras("resp_abc123", "tool_456")
  );
  channel.publish(toolCall);

  // Publish progress updates as tool executes
  JsonObject progress1 = new JsonObject();
  progress1.addProperty("name", "process_document");
  progress1.addProperty("status", "Processing page 25 of 100");
  progress1.addProperty("percentComplete", 25);

  Message progressMsg1 = new Message(
      "tool_progress",
      progress1.toString(),
      createExtras("resp_abc123", "tool_456")
  );
  channel.publish(progressMsg1);

  // Continue publishing progress as work progresses
  JsonObject progress2 = new JsonObject();
  progress2.addProperty("name", "process_document");
  progress2.addProperty("status", "Processing page 75 of 100");
  progress2.addProperty("percentComplete", 75);

  Message progressMsg2 = new Message(
      "tool_progress",
      progress2.toString(),
      createExtras("resp_abc123", "tool_456")
  );
  channel.publish(progressMsg2);

  // Publish final result
  JsonObject resultData = new JsonObject();
  resultData.addProperty("name", "process_document");
  JsonObject result = new JsonObject();
  result.addProperty("processedPages", 100);
  result.addProperty("summary", "Document processed successfully");
  resultData.add("result", result);

  Message resultMsg = new Message(
      "tool_result",
      resultData.toString(),
      createExtras("resp_abc123", "tool_456")
  );
  channel.publish(resultMsg);
  ```
</Code>

Subscribe to progress updates on the client by listening for the `tool_progress` message type:

<Code>
  ```javascript theme={null}
  const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');

  // Track tool execution progress
  const toolProgress = new Map();

  await channel.subscribe((message) => {
    const { responseId, toolCallId } = message.extras?.headers || {};

    switch (message.name) {
      case 'tool_call':
        toolProgress.set(toolCallId, {
          name: message.data.name,
          status: 'Starting...',
          percentComplete: 0
        });
        renderProgressBar(toolCallId, 0);
        break;

      case 'tool_progress':
        const progress = toolProgress.get(toolCallId);
        if (progress) {
          progress.status = message.data.status;
          progress.percentComplete = message.data.percentComplete;
          renderProgressBar(toolCallId, message.data.percentComplete);
        }
        break;

      case 'tool_result':
        toolProgress.delete(toolCallId);
        renderCompleted(toolCallId, message.data.result);
        break;
    }
  });
  ```

  ```python theme={null}
  channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')

  # Track tool execution progress
  tool_progress = {}

  async def handle_message(message):
      headers = message.extras.get('headers', {}) if message.extras else {}
      response_id = headers.get('responseId')
      tool_call_id = headers.get('toolCallId')

      if message.name == 'tool_call':
          tool_progress[tool_call_id] = {
              'name': message.data.get('name'),
              'status': 'Starting...',
              'percentComplete': 0
          }
          render_progress_bar(tool_call_id, 0)

      elif message.name == 'tool_progress':
          progress = tool_progress.get(tool_call_id)
          if progress:
              progress['status'] = message.data.get('status')
              progress['percentComplete'] = message.data.get('percentComplete')
              render_progress_bar(tool_call_id, message.data.get('percentComplete'))

      elif message.name == 'tool_result':
          if tool_call_id in tool_progress:
              del tool_progress[tool_call_id]
          render_completed(tool_call_id, message.data.get('result'))

  # Subscribe to all messages on the channel
  await channel.subscribe(handle_message)
  ```

  ```java theme={null}
  Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");

  // Track tool execution progress
  Map<String, JsonObject> toolProgress = new HashMap<>();

  // Subscribe to all messages on the channel
  channel.subscribe(message -> {
      JsonObject headers = message.extras != null
          ? message.extras.asJsonObject().getAsJsonObject("headers")
          : null;

      String responseId = headers != null && headers.has("responseId")
          ? headers.get("responseId").getAsString()
          : null;
      String toolCallId = headers != null && headers.has("toolCallId")
          ? headers.get("toolCallId").getAsString()
          : null;

      switch (message.name) {
          case "tool_call":
              JsonObject newProgress = new JsonObject();
              newProgress.addProperty("name", ((JsonObject) message.data).get("name").getAsString());
              newProgress.addProperty("status", "Starting...");
              newProgress.addProperty("percentComplete", 0);
              toolProgress.put(toolCallId, newProgress);
              renderProgressBar(toolCallId, 0);
              break;

          case "tool_progress":
              JsonObject progress = toolProgress.get(toolCallId);
              if (progress != null) {
                  JsonObject progressData = (JsonObject) message.data;
                  progress.addProperty("status", progressData.get("status").getAsString());
                  progress.addProperty("percentComplete", progressData.get("percentComplete").getAsInt());
                  renderProgressBar(toolCallId, progressData.get("percentComplete").getAsInt());
              }
              break;

          case "tool_result":
              toolProgress.remove(toolCallId);
              renderCompleted(toolCallId, ((JsonObject) message.data).get("result"));
              break;
      }
  });
  ```
</Code>

Message-based progress is useful for:

* Step-by-step status descriptions
* Milestone notifications
* Workflow stages with distinct phases
* Audit trails requiring discrete event records

### Progress updates via LiveObjects <a id="progress-liveobjects" />

Use [LiveObjects](/docs/liveobjects) for state-based progress tracking. LiveObjects provides a shared data layer where progress state is automatically synchronized across all subscribed clients, making it ideal for continuous progress tracking.

Use [LiveCounter](/docs/liveobjects/counter) for numeric progress values like completion percentages or item counts. Use [LiveMap](/docs/liveobjects/map) to track complex progress state with multiple fields.

First, import and initialize the LiveObjects plugin:

<Code>
  ```javascript theme={null}
  import * as Ably from 'ably';
  import { LiveObjects, LiveMap, LiveCounter } from 'ably/liveobjects';

  // Initialize client with LiveObjects plugin
  const realtime = new Ably.Realtime({
    key: '{{API_KEY}}',
    plugins: { LiveObjects }
  });

  // Get channel with LiveObjects capabilities
  const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', {
    modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH']
  });

  // Get the channel's LiveObjects root
  const root = await channel.object.get();
  ```
</Code>

Create a LiveMap to track tool progress:

<Code>
  ```javascript theme={null}
  // Create a LiveMap to track tool progress
  await root.set('tool_456_progress', LiveMap.create({
    status: 'starting',
    itemsProcessed: LiveCounter.create(0),
    totalItems: 100,
    currentItem: ''
  }));

  // Update progress as tool executes
  const progress = root.get('tool_456_progress');

  await progress.set('status', 'processing');
  await progress.set('currentItem', 'item_25');
  await progress.get('itemsProcessed').increment(25);

  // Continue updating as work progresses
  await progress.set('currentItem', 'item_75');
  await progress.get('itemsProcessed').increment(50);

  // Final increment to reach 100%
  await progress.set('currentItem', 'item_100');
  await progress.get('itemsProcessed').increment(25);

  // Mark complete
  await progress.set('status', 'completed');
  ```
</Code>

Subscribe to LiveObjects updates on the client to render realtime progress:

<Code>
  ```javascript theme={null}
  import * as Ably from 'ably';
  import { LiveObjects } from 'ably/liveobjects';

  // Initialize client with LiveObjects plugin
  const realtime = new Ably.Realtime({
    key: '{{API_KEY}}',
    plugins: { LiveObjects }
  });

  // Get channel with LiveObjects capabilities
  const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', {
    modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH']
  });

  // Get the channel's LiveObjects root
  const root = await channel.object.get();

  // Subscribe to progress updates
  const progress = root.get('tool_456_progress');
  progress.subscribe(() => {
    const status = progress.get('status').value();
    const itemsProcessed = progress.get('itemsProcessed').value();
    const totalItems = progress.get('totalItems').value();
    const percentComplete = Math.round((itemsProcessed / totalItems) * 100);

    renderProgressBar('tool_456', percentComplete, status);
  });
  ```
</Code>

<Aside data-type="note">
  LiveObjects requires enabling [LiveObjects capability](/docs/liveobjects) on your channel. LiveCounter ensures that concurrent increments from multiple sources are correctly synchronized, making it safe to update progress from distributed workers or parallel processes.
</Aside>

LiveObjects-based progress is useful for:

* Continuous progress bars with frequent updates
* Distributed tool execution across multiple workers
* Complex progress state with multiple fields
* Scenarios where multiple agents or processes contribute to the same progress counter

### Choosing the right approach <a id="progress-choosing" />

Choose messages when:

* Progress updates are infrequent (every few seconds or at specific milestones)
* You need a complete audit trail of all progress events
* Progress information is descriptive text rather than numeric
* Each update represents a distinct event or stage transition

Choose LiveObjects when:

* Progress updates are frequent (multiple times per second)
* You're tracking numeric progress like percentages or counts
* Multiple processes or workers contribute to the same progress counter
* You want to minimize message overhead for high-frequency updates

You can combine both approaches for comprehensive progress tracking. Use LiveObjects for high-frequency numeric progress and messages for important milestone notifications:

<Code>
  ```javascript theme={null}
  // Update numeric progress continuously via LiveObjects
  await progress.get('itemsProcessed').increment(1);

  // Publish milestone messages at key points
  if (itemsProcessed === totalItems / 2) {
    await channel.publish({
      name: 'tool_progress',
      data: {
        name: 'process_document',
        status: 'Halfway complete - 50 of 100 items processed'
      },
      extras: {
        headers: {
          responseId: 'resp_abc123',
          toolCallId: 'tool_456'
        }
      }
    });
  }
  ```
</Code>

## Human-in-the-loop workflows <a id="human-in-the-loop" />

Tool calls resolved by humans are one approach to implementing human-in-the-loop workflows. When an agent encounters a tool call that needs human resolution, it publishes the tool call to the channel and waits for the human to publish the result back over the channel.

For example, a tool that modifies data, performs financial transactions, or accesses sensitive resources might require explicit user approval before execution. The tool call information is surfaced to the user, who can then approve or reject the action.

<Aside data-type="further-reading">
  For detailed implementation patterns and best practices for human-in-the-loop workflows, including authorization and verification strategies, see the [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) documentation.
</Aside>
