Client tools
Declare tools the character can call, run them in your app, and answer back.
A client tool is something your app does that the character can decide to call — put a question on screen, move the camera, swap a weapon. The server describes it to the model and forwards each call back to you over the same connection.
This is not a module: a module's tools run inside the server. A client tool runs where your app runs, which is the only place it can.
Declaring
Tools are declared in startChat / resumeChat, not afterwards — a tool announced later would be offered to the second reply rather than the first.
{
"$type": "startChat",
"characterId": "7c9f1d0a-2f4b-4b0e-9a11-6b3d5c8e2f10",
"tools": [
{
"name": "offer_user_choices",
"description": "Offer the user a short list of options and wait for the one they pick.",
"arguments": [
{ "name": "question", "type": "String", "required": true },
{ "name": "choices", "type": "Array", "itemsType": "String", "required": true }
],
"permission": "InChat",
"display": "Hidden",
"timeoutSeconds": 300
}
]
}| Field | What it does |
|---|---|
name | What the model calls it by. Lowercase, alphanumerics and underscores. |
description | What the model reads to decide whether to call it. |
arguments | Name, type (String, Integer, Double, Boolean, Array), optional itemsType, choices, required. |
chatStyles | Which styles offer it. Omitted means all. |
permission | InChat, Read, Act or Destructive. Omitted means Destructive, so it is asked about before it runs. |
display | Card (default), Pill, Minimal or Hidden. Use Hidden when your app draws the call itself. |
syncWithSpeech | Hold the call until the voice reaches the point it was called from. See below. |
timeoutSeconds | How long the server waits. Thirty seconds by default — far too short for anything a person answers. |
Flat arguments on purpose: a model given a scalar per field writes them far more reliably than one given a nested object, so anything structured travels as a JSON string in a String argument and says so in its description.
Answering a call
The server sends toolCallRequest and waits:
{ "$type": "toolCallRequest", "sessionId": "…", "requestId": "…", "name": "offer_user_choices", "arguments": "{\"question\":\"Which one?\"}" }arguments is the raw JSON the model wrote, as a string, or absent. Parse defensively — on backends without native tool calling it comes through a text protocol, so an array can arrive as a JSON string.
Answer with the same requestId:
{ "$type": "toolCallResult", "sessionId": "…", "requestId": "…", "result": "The user picked B.", "isError": false }result is written for a model to read: what happened, or what went wrong and what to do instead.
Answer exactly once, and always. The model is mid-reply waiting on it. An error or a late answer costs one failed call; a call dropped on the floor costs the reply its turn until the timeout expires. Use isError: true for anything you cannot serve.
toolCallAbandoned says the server stopped waiting — take down whatever you put on screen for that call. reason is Timeout or Cancelled (the reply was interrupted or the chat closed). Don't run your own timer against timeoutSeconds; the server owns it.
Timing a call to the speech
The model calls a tool while writing, seconds before that reply is spoken. For a tool whose point is what the person sees or hears, that is too early — the room changes while the character is still building up to it.
syncWithSpeech: true holds the call itself until the speech reaches the words it was called from — a client tool runs in your app, so there is nothing on the server to hold back. The server releases it when the chunk starting there begins playing, at the end of the speech for a call made after the last word, and on an interruption for anything still waiting, so a call is never silently lost.
Two consequences:
- The model is answered immediately, with an acknowledgement rather than your result. Nothing reads what you send back, so a tool that syncs is one whose answer was never the point.
- The call arrives late, up to the length of the reply. Your timeout starts when the call is sent.
Off by default. Scenario actions default the other way, and hold only their effects — they run server-side, so they can answer the model and still land on cue.
Permissions
permission says what running the tool does to the world, and the chat's permission mode gates on it. InChat — something that only touches the app the user is already looking at — never stops to ask. Anything else may prompt, and the user's answer can be remembered: the server sends toolPermissionRemembered, and your client writes it down and sends alwaysAllowed: true for that tool next time.