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

# Client tools and confirmations

> Run your own tools inside a turn, and approve or deny actions before they run.

A **client tool** is a tool your application executes. You declare it in the instance config; the model decides when to call it; the platform hands the call to your server, pauses the turn, and continues when you post the result. A **confirmation** is a pause before a call with side effects runs: your user approves or denies it, and the platform acts on the decision.

The turn states these pauses create are defined once on [Turns and streaming](/conversations/turns-and-streaming#turn-states).

## Which calls pause, and how

| Call                                                                | Pause                                          | You post                             |
| ------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------ |
| Client tool with `effect: "read"`                                   | Handoff (`stop_reason: "tool_use"`)            | `tool_results`                       |
| Client tool with `effect: "read"` and `requires_confirmation: true` | Confirm, then handoff once approved            | `confirmations`, then `tool_results` |
| Client tool with `effect` `write`, `outbound`, or `destructive`     | Confirm, then handoff once approved            | `confirmations`, then `tool_results` |
| Platform tool from `platform.read`                                  | None. Runs on the server.                      | Nothing                              |
| Platform tool from `platform.write`                                 | Confirm, then runs on the server once approved | `confirmations`                      |

A client tool call whose input fails the tool's `input_schema` is never handed to you, even after an approval. The platform answers the model with an error naming the schema failure, and the model can try again.

When a model round contains any call that needs confirmation, the whole round pauses. Nothing in that round runs until you post `confirmations`.

## Declare a client tool

Client tools live in `tools.client_tools` of the instance config. See [Instance configuration](/conversations/configuration#tools) for every rule.

The examples on this page come from Harbor Group, a fictional company that reviews its suppliers. Its application keeps a record for each supplier. One tool reads a supplier's review status; the other emails the supplier's contact a reminder.

```json theme={null}
{
  "tools": {
    "platform_packs": ["platform.read"],
    "client_tools": [
      {
        "name": "lookup_supplier_status",
        "description": "Read the current review status of a supplier by supplier id.",
        "input_schema": {
          "type": "object",
          "properties": { "supplier_id": { "type": "string" } },
          "required": ["supplier_id"]
        },
        "effect": "read"
      },
      {
        "name": "send_document_reminder",
        "description": "Email the supplier's contact a reminder that review documents are due.",
        "input_schema": {
          "type": "object",
          "properties": {
            "supplier_id": { "type": "string" },
            "note": { "type": "string" }
          },
          "required": ["supplier_id"]
        },
        "effect": "outbound"
      }
    ]
  }
}
```

## Handle a handoff

<Steps>
  <Step title="Read the tool_use frames">
    The segment ends with one or more `tool_use` frames, then `turn_end` with `stop_reason: "tool_use"`.

    ```text theme={null}
    event: tool_activity
    data: {"type":"tool_activity","tool_call_id":"call_9pLw2cXv7NqB","name":"lookup_supplier_status","execution":"client","phase":"started"}

    event: tool_use
    data: {"type":"tool_use","tool_call_id":"call_9pLw2cXv7NqB","name":"lookup_supplier_status","input":{"supplier_id":"SUP-2026-004417"}}

    event: turn_end
    data: {"type":"turn_end","stop_reason":"tool_use","usage":{"input_tokens":2210,"output_tokens":24,"total_tokens":2234,"cached_input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}
    ```
  </Step>

  <Step title="Run the tool in your application">
    Execute each call with its `input`, on behalf of the same end user.
  </Step>

  <Step title="Post tool_results">
    Post one result per handed-off call. The ids must cover exactly the pending calls: no missing ids, no extras, no duplicates.

    <CodeGroup>
      ```bash curl theme={null}
      curl -N -X POST https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93/turns \
        -H "Authorization: Bearer $NEXIO_API_KEY" \
        -H "Content-Type: application/json" \
        -H "Accept: text/event-stream" \
        -d '{
          "end_user": "u_dana_ortiz",
          "tool_results": [
            {
              "tool_call_id": "call_9pLw2cXv7NqB",
              "content": {"supplier_id": "SUP-2026-004417", "status": "approved", "review_due_on": "2026-11-01"}
            }
          ]
        }'
      ```

      ```python Python theme={null}
      import os
      import requests

      resp = requests.post(
          "https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93/turns",
          headers={"Authorization": "Bearer " + os.environ["NEXIO_API_KEY"], "Accept": "text/event-stream"},
          json={
              "end_user": "u_dana_ortiz",
              "tool_results": [
                  {
                      "tool_call_id": "call_9pLw2cXv7NqB",
                      "content": {"supplier_id": "SUP-2026-004417", "status": "approved", "review_due_on": "2026-11-01"},
                  }
              ],
          },
          stream=True,
          timeout=(10, 660),
      )
      for line in resp.iter_lines(decode_unicode=True):
          print(line)
      ```

      ```typescript TypeScript theme={null}
      const resp = await fetch(
        "https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93/turns",
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${process.env.NEXIO_API_KEY}`,
            "Content-Type": "application/json",
            Accept: "text/event-stream",
          },
          body: JSON.stringify({
            end_user: "u_dana_ortiz",
            tool_results: [
              {
                tool_call_id: "call_9pLw2cXv7NqB",
                content: { supplier_id: "SUP-2026-004417", status: "approved", review_due_on: "2026-11-01" },
              },
            ],
          }),
        },
      )
      console.log(await resp.text())
      ```
    </CodeGroup>

    The next segment starts with a `conversation` frame (no `user_message_id`), then a `tool_activity` frame per result with `phase: "completed"`, or `"failed"` when you set `is_error: true`. The model continues from there.
  </Step>
</Steps>

`tool_results` fields:

| Field          | Type     | Required | Meaning                                                                                                                |
| -------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `tool_call_id` | string   | Yes      | The `tool_call_id` from the `tool_use` frame.                                                                          |
| `content`      | any JSON | No       | Passed to the model as the tool's output. Treated as untrusted data. When omitted, the model receives an empty string. |
| `is_error`     | boolean  | No       | `true` tells the model the tool failed.                                                                                |

## Handle a confirmation

<Steps>
  <Step title="Read the pending_confirmation frames">
    The segment ends with one `pending_confirmation` frame per call that needs approval, then `turn_end` with `stop_reason: "pending_confirmation"`.

    ```text theme={null}
    event: pending_confirmation
    data: {"type":"pending_confirmation","tool_call_id":"call_4Tq8mZb1RvKs","name":"send_document_reminder","input":{"supplier_id":"SUP-2026-004417","note":"Your review documents are due on November 1."},"reason":"This tool has effect \"outbound\" and requires confirmation before it runs.","execution":"client"}

    event: turn_end
    data: {"type":"turn_end","stop_reason":"pending_confirmation","usage":{"input_tokens":2480,"output_tokens":41,"total_tokens":2521,"cached_input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}
    ```

    `execution` tells you where the call runs once approved: `client` means it comes back to you as a handoff, `platform` means the platform runs it.
  </Step>

  <Step title="Show the action and collect a decision">
    Show your user the tool name and `input`. The approval is for exactly what you displayed.
  </Step>

  <Step title="Post confirmations">
    Post one decision per paused call, covering exactly the paused ids.

    <CodeGroup>
      ```bash curl theme={null}
      curl -N -X POST https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93/turns \
        -H "Authorization: Bearer $NEXIO_API_KEY" \
        -H "Content-Type: application/json" \
        -H "Accept: text/event-stream" \
        -d '{
          "end_user": "u_dana_ortiz",
          "confirmations": [
            {"tool_call_id": "call_4Tq8mZb1RvKs", "approved": true}
          ]
        }'
      ```

      ```python Python theme={null}
      import os
      import requests

      resp = requests.post(
          "https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93/turns",
          headers={"Authorization": "Bearer " + os.environ["NEXIO_API_KEY"], "Accept": "text/event-stream"},
          json={
              "end_user": "u_dana_ortiz",
              "confirmations": [{"tool_call_id": "call_4Tq8mZb1RvKs", "approved": True}],
          },
          stream=True,
          timeout=(10, 660),
      )
      for line in resp.iter_lines(decode_unicode=True):
          print(line)
      ```

      ```typescript TypeScript theme={null}
      const resp = await fetch(
        "https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93/turns",
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${process.env.NEXIO_API_KEY}`,
            "Content-Type": "application/json",
            Accept: "text/event-stream",
          },
          body: JSON.stringify({
            end_user: "u_dana_ortiz",
            confirmations: [{ tool_call_id: "call_4Tq8mZb1RvKs", approved: true }],
          }),
        },
      )
      console.log(await resp.text())
      ```
    </CodeGroup>
  </Step>
</Steps>

`confirmations` fields:

| Field          | Type    | Required | Meaning                                                                                     |
| -------------- | ------- | -------- | ------------------------------------------------------------------------------------------- |
| `tool_call_id` | string  | Yes      | The id from the `pending_confirmation` frame.                                               |
| `approved`     | boolean | Yes      | Must be present. `false` denies.                                                            |
| `reason`       | string  | No       | For a denial, sent to the model as the reason. Defaults to "The user declined this action." |

What happens on resume:

* A denied call is answered to the model as an error carrying your `reason`, and a `tool_activity` frame with `phase: "failed"` is sent.
* An approved platform call runs on the server.
* An approved client call is handed to you: a `tool_activity` frame with `phase: "started"`, a `tool_use` frame, then `stop_reason: "tool_use"`. Post `tool_results` for it as in the handoff flow.
* Calls in the paused round that needed no approval run (platform) or are handed off (client) in the same segment.
* When no client call is handed off, the model continues in the same segment.

## The environment of an approved action

An approval covers the action as it was displayed, including the environment it would act on. The platform records the environment when the turn pauses, and the approved calls run against that environment, never the one on the resuming request. If a paused action has no recorded environment, `confirmations` returns `409 confirmation_environment_unpinned`, and the pause stays open: a new message in the same conversation gets `409 pending_turn`. Start a new conversation and ask the question there.

## Rules that return errors

| Condition                                                                                                           | Status and code                                                                                       |
| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `tool_results` or `confirmations` do not cover exactly the pending ids, repeat one, or have an empty `tool_call_id` | `400 invalid_tool_result` or `400 invalid_confirmation`                                               |
| `approved` missing                                                                                                  | `400 invalid_confirmation`                                                                            |
| `tool_results` sent while the turn waits for confirmations, or the reverse, or nothing is paused                    | `409 turn_state_conflict`                                                                             |
| A new `message` while a turn is paused                                                                              | `409 pending_turn`, with `details.pause_kind` (`confirm` or `handoff`) and `details.pending_call_ids` |
| The paused action has no recorded environment                                                                       | `409 confirmation_environment_unpinned`                                                               |

A client that lost its local state learns from `pending_turn` which resume is owed (`pause_kind`) and for which calls (`pending_call_ids`). The error does not carry tool names or inputs. Read them from the `tool_use` blocks in the transcript with [`GET /conversations/{conversation_id}`](/api-reference/conversations/conversations/get-conversation), matching each block's `id` to a pending call ID. The platform does not store a client tool's result until you post it, so keep results your application computed until `tool_results` succeeds.

## What is recorded

Each call to a declared tool is recorded as a tool event with the tool name, where it ran (`platform` or `client`), its outcome (`ok`, `error`, `invalid_input`, `pending_confirmation`, `denied`, `approved`), and its duration in milliseconds. For a client tool the duration runs from the handoff to your `tool_results` request. A call to a tool name the instance does not declare gets an error result and no tool event. Tool events appear in the [conversation export](/conversations/export-and-retention).

In [evaluation](/conversations/evaluation) runs, no application is attached, so scenario scripts supply client tool results and confirmation decisions by tool name. An unscripted confirmation is denied.

<CardGroup cols={2}>
  <Card title="Platform tools" href="/conversations/platform-tools">
    Tools the platform runs on the server.
  </Card>

  <Card title="Turns and streaming" href="/conversations/turns-and-streaming">
    Frames, stop reasons, and errors.
  </Card>
</CardGroup>
