Companion host-control verbs (universal app actions)
A small set of generic verbs that let the companion act in ANY surface — web,
game, app, hardware — through one onToolCall handler. They're intentionally
generic: the specific intent rides in the string params, which your surface
interprets. That keeps the schema fixed (so it can be pre-registered on the
otherwise-static ElevenLabs Convai agent) while still covering arbitrary actions.
Exported from the SDK as HOST_CONTROL_TOOLS (the JSON-Schema definitions) and
HOST_CONTROL_TOOL_NAMES.
Where they're offered — voice calls only, unless you declare them. The SDK
adds these verbs automatically to connectCall() voice sessions when your
embed declares tools or handles. Text turns are different: the server
offers the model exactly the tools[] your session declared — nothing is
auto-added — so a text embed that wants the verbs on text turns must declare
them itself (createCompanion({ tools: [...HOST_CONTROL_TOOLS] })). A declared
verb then flows like any other tool: the model calls it, companion.tool_call
is emitted, your onToolCall fires. (handles[] is stored but never consumed
server-side today — treat it as a client-side capability hint; its one SDK
effect is opting a tool-less embed into the auto-offered verbs on voice calls.
A tool the app wants called on text turns MUST be declared in tools[].)
The verbs
| Verb | Params | Examples across surfaces |
|---|---|---|
invoke_action |
{ action: string, params?: object } |
pause / resume / start / stop / restart / next / previous / submit / confirm / cancel / refresh. Catch-all trigger. The SDK unwraps it to action, so it reaches onToolCall exactly like a declared tool. |
set_feature |
{ feature: string, on: boolean } |
hints, captions, dark mode, notifications, a hardware light/mic, a mode |
set_value |
{ name: string, value: number|string|boolean } |
volume, brightness, difficulty, playback speed, zoom, temperature, fan speed |
navigate |
{ to: string, params?: object } |
web route, app screen, game menu/level, hardware mode/channel |
highlight |
{ target: string, note?: string } |
onboarding / tutorial / point attention at an element |
Handle them (one onToolCall handler)
companion.onToolCall(async ({ id, name, args }) => {
const a = JSON.parse(args);
switch (name) {
case 'pause': // invoke_action({action:'pause'}) → name:'pause'
case 'pause_game': game.pause(); break;
case 'resume': game.resume(); break;
case 'set_feature': ui.toggle(a.feature, a.on); break;
case 'set_value': ui.set(a.name, a.value); break;
case 'navigate': router.go(a.to, a.params); break;
case 'highlight': ui.highlight(a.target); break;
}
await companion.sendToolResult(id, { ok: true });
});
invoke_action arrives unwrapped: invoke_action({action:'pause_game', params})
fires onToolCall with name:'pause_game', args: params — so it hits the same
handler a declared pause_game tool would. The other verbs pass through by name.
The same handler serves both planes: voice-call verbs arrive here via the SDK's
call bridge, and text-turn tool calls arrive here via companion.tool_call —
but remember the verbs only reach a text turn if you declared them in tools[]
(see the offering rules above).
Benign actions (above) run immediately. Only sensitive ops (wallet.spend /
social.message / skills.execute) go through the Pouchy confirm boundary.
Provider note
- OpenAI Realtime: the SDK registers these (and any declared tools) dynamically
via
session.update— works out of the box. - ElevenLabs Convai: a shared Convai agent only calls tools it's been configured with, so these verbs must be registered once on the agent (below). Until then, voice tool-calling on the EL path won't fire (the OpenAI voice path is unaffected; text turns only ever see the tools your session declared, per the offering rules above).
One-time ElevenLabs Convai agent registration
Add these as client tools on the companion Convai agent (Dashboard → the agent
→ Tools → Add client tool, or via the EL API). Name + description + parameters
must match HOST_CONTROL_TOOLS:
[
{ "name": "invoke_action",
"description": "Trigger a named action the current surface supports — e.g. pause, resume, start, stop, restart, next, previous, submit, confirm, cancel, refresh. Put the action name in \"action\" and any extra args in \"params\".",
"parameters": { "type": "object", "properties": {
"action": { "type": "string" }, "params": { "type": "object" } }, "required": ["action"] } },
{ "name": "set_feature",
"description": "Turn a named feature/capability on or off (hints, captions, dark mode, a light, notifications, a mode).",
"parameters": { "type": "object", "properties": {
"feature": { "type": "string" }, "on": { "type": "string" } }, "required": ["feature", "on"] } },
{ "name": "set_value",
"description": "Set a named control to a value — volume, brightness, difficulty, speed, zoom, temperature, etc.",
"parameters": { "type": "object", "properties": {
"name": { "type": "string" }, "value": { "type": "string" } }, "required": ["name", "value"] } },
{ "name": "navigate",
"description": "Go to a named view / screen / page / section / mode / level in the surface.",
"parameters": { "type": "object", "properties": {
"to": { "type": "string" }, "params": { "type": "object" } }, "required": ["to"] } },
{ "name": "highlight",
"description": "Draw the user's attention to a named element (guidance / onboarding / a tutorial step).",
"parameters": { "type": "object", "properties": {
"target": { "type": "string" }, "note": { "type": "string" } }, "required": ["target"] } }
]
The SDK provides the handlers for these at call time (clientTools); the agent
config above provides the schemas so the model knows it may call them. The
result the app reports via sendToolResult is fed back to the voice model.
ElevenLabs client-tool params are always strings, so
set_feature.onis declared as a string ("true"/"false"). The SDK normalizes it to a real boolean before calling youronToolCall, so your handler readsa.onas a boolean on every provider — no per-provider branching.
Avatar emote tools (play_gesture / play_expression)
The embodied ("形象") companion can also call two avatar visual tools to emote
— play_gesture (full-body animation) and play_expression (facial expression).
Exported from the SDK as AVATAR_VISUAL_TOOLS / AVATAR_VISUAL_TOOL_NAMES. Like
the host verbs their schemas are registered on the shared Convai agent (a manual
EL-console step — see the registration section below), so the companion can call
them on every voice call.
On voice calls you do not need to declare or handle them. The SDK offers them
on every connectCall() session — even a pure-chat embed that declared no tools —
and answers with a silent no-op success ({ ok: true }) for any embed that
didn't opt in, so a pure audio or 2D integrator can ignore avatars entirely
and the companion's gesture/expression calls simply do nothing instead of
erroring. (On the text plane the question doesn't arise: the server offers only
the session's declared tools[], so an undeclared avatar tool is never called on
a text turn.)
To actually render the avatar, opt in by declaring the same tool name in
createCompanion({ tools }) (your own schema wins) and handling it in onToolCall
just like any other tool — the SDK then routes the call to you instead of no-oping,
and the declaration also puts the tool on your text turns:
createCompanion({
// ...
tools: [
{ name: 'play_gesture', description: 'play an avatar gesture',
parameters: { type: 'object', properties: { category: { type: 'string' } }, required: ['category'] } },
{ name: 'play_expression', description: 'set the avatar expression',
parameters: { type: 'object', properties: { intent: { type: 'string' } }, required: ['intent'] } }
]
});
companion.onToolCall(async ({ id, name, args }) => {
const a = JSON.parse(args);
if (name === 'play_gesture') avatar.playGesture(a.category);
if (name === 'play_expression') avatar.setExpression(a.intent);
await companion.sendToolResult(id, { ok: true });
});
How registration actually works today
Two halves, per call vs. per agent:
- Per call (automatic): at
connectCall()the SDK passes a handler function for every offered tool into the EL session'sclientToolsmap (itsVoiceToolBridge, seesrc/lib/companion-sdk/call.ts). The first-party app does the same insrc/lib/services/convai/connection.ts, whose runners come fromsrc/lib/engine/call-tools.ts. On OpenAI Realtime the schemas ALSO ride per call (session.update), so that path needs nothing else. - Per agent (manual, EL only): ElevenLabs only lets the model call a client
tool the agent's own config already declares, and the shared Convai agent's
config is static — so the schema pre-registration in the section above is a
manual, one-time ElevenLabs-console step (Dashboard → the agent → Tools →
Add client tool, or the EL API). The old Ops-dashboard "ElevenLabs Convai tool
sync" panel and its
DESIRED_CONVAI_TOOLSconstant have been removed — there is no in-app sync tool anymore. After adding a NEW verb toHOST_CONTROL_TOOLS/AVATAR_VISUAL_TOOLS, register its schema on the agent by hand.