Skip to navigation

Harness protocol reference

Every message between jambonz and a custom harness (protocol version 1).

Most harnesses use @jambonz/sdk/harness, which speaks this protocol for you. This page is for writing a harness without it, in any language.

Connection

  • jambonz opens a WebSocket to the harness URL for each call, with subprotocol harness.jambonz.org. Use wss://; ws:// is accepted only for a private address on the same network as jambonz.
  • When the agent has a secret, the upgrade request carries Authorization: Bearer <secret>. With none, there is no Authorization header. A harness that requires one should refuse the upgrade with HTTP 401.
  • Every message is a JSON text frame with a type. Messages from jambonz carry call_sid. There are no binary frames: audio never goes to the harness.
  • Ignore message types and fields you do not recognize. New ones are added without changing the protocol version.
  • session:start carries protocol: 1. A harness that cannot speak it closes the socket with code 4000 and a reason.

A whole call

jambonz → harness session:start {call_sid, protocol: 1, from, to, direction, language, metadata, tools}
jambonz → harness turn:start {turn_id: 1, input: {type: "greeting"}}
harness → jambonz reply:chunk {reply_id: "r1", turn_id: 1, chunk: {choices: [{delta: {content: "Thanks for calling Acme."}}]}}
harness → jambonz reply:done {reply_id: "r1", turn_id: 1, turn_record: {route: "chat"}}
jambonz → harness speech:start {turn_id: 2}
jambonz → harness transcript {turn_id: 2, text: "When are you", final: false}
jambonz → harness transcript {turn_id: 2, text: "When are you open", final: true}
jambonz → harness turn:start {turn_id: 2, input: {type: "speech", text: "When are you open", language: "en-US"}}
harness → jambonz tool:call {call_id: "t1", turn_id: 2, name: "get_hours", args: {}}
jambonz → harness tool:result {call_id: "t1", result: {weekdays: "8am to 6pm"}}
harness → jambonz reply:chunk {reply_id: "r2", turn_id: 2, chunk: {…"We're open eight to six on weekdays."}}
jambonz → harness event {name: "user_interruption", turn_id: 2, reply_id: "r2", spoken: "We're open eight", unspoken: " to six on weekdays."}
jambonz → harness turn:cancel {turn_id: 2, reason: "barge_in"}
jambonz → harness turn:start {turn_id: 3, input: {type: "speech", text: "Thanks, bye"}}
harness → jambonz command {command_id: "c1", name: "hangup", args: {reason: "resolved"}}
jambonz → harness command:result {command_id: "c1", ok: true}
jambonz → harness session:end {reason: "harness_hangup", duration_ms: 41250}

jambonz → harness

typefieldsnotes
session:startprotocol, call_sid, account_sid, application_sid, direction, from, to, language, sip_headers, metadata, tools, resumed?, last_turn_id?metadata is the free-form JSON from the agent (Agent Studio adds the agent id, version and the call’s variables). tools lists the agent’s tools as OpenAI function definitions, ready to pass to a model. sip_headers holds the caller’s X- headers. resumed and last_turn_id are set on a reconnect (below)
speech:startturn_idthe caller started speaking; turn_id is the turn this speech will become. It can arrive while a reply is playing
transcriptturn_id, text, final, confidence?recognition so far. text is the whole utterance so far, not a delta. final: true means the recognizer will not revise that text, not that the caller has finished: only turn:start says that
turn:startturn_id, inputthe end of the caller’s turn. input is {type: "speech", text, confidence?, language}, {type: "greeting"} when the call connects, or {type: "no_input"} when the caller interrupted and then said nothing. turn_id increases by one per turn
turn:cancelturn_id, reasonbarge_in or call_ended. Anything you send for that turn afterwards is dropped
eventname, …see Events
command:resultcommand_id, ok, error?one per command
tool:resultcall_id, result or errorone per tool:call
session:endreason, duration_msthe socket closes after it. reason is caller_hangup, harness_hangup, application_hangup, application_redirect (the application moved the call to other verbs), transferred or error

harness → jambonz

typefieldsnotes
reply:chunkreply_id, turn_id?, interruptible?, interrupt?, chunkchunk is an OpenAI ChatCompletionChunk; only choices[0].delta.content is spoken, streamed to the voice as it arrives. interruptible: false on the first chunk holds barge-in until the whole reply has played. interrupt: true on the first chunk of an unprompted reply cuts off whatever is playing
reply:donereply_id, turn_id?, turn_record?ends the reply. turn_record (up to 16 KB of JSON) is stored on the turn
tool:callcall_id, turn_id?, name, argsruns one of the agent’s tools (a name from session:start.tools) through jambonz, exactly as for a pipeline agent. The result comes back as tool:result. Calls may overlap
commandcommand_id, name, argssee Commands

Ids (reply_id, call_id, command_id) are yours to choose and must be unique within the call.

Replies

A reply with a turn_id answers that turn, and is accepted only after that turn’s turn:start; one sent earlier is refused with an error event. A reply without a turn_id is the harness speaking on its own initiative, for example after a silence event. jambonz speaks one reply at a time; a reply that starts while another is playing is queued behind it.

Replies the harness starts on its own:

  • The floor is free (nothing playing, no turn open, the caller not speaking): spoken at once.
  • The floor is busy: held, and spoken as soon as the floor is free, so it never plays over the caller or over the turn’s reply. One unprompted reply is held at a time; a newer one replaces it (reply_dropped, reason replaced).
  • interrupt: true on its first chunk: whatever is playing is cut off and this reply is spoken now. A caller in mid-utterance is still never talked over.

Tool calls inside chunks (delta.tool_calls) are ignored. To run one of the agent’s tools, send tool:call. Tools of the harness’s own stay between it and its model.

Events

Sent as {type: "event", name, …}.

namefieldswhen
user_interruptionturn_id?, reply_id, spoken, unspokenthe caller interrupted a reply. spoken is the text actually played: keep only that in your history. It is exact for voices that report word timing and estimated for others
reply_spokenreply_ida reply finished playing
reply_droppedreply_id, reasonan unprompted reply was never spoken: replaced by a newer one, or call_ended
silenceseconds, streakthe caller said nothing for the agent’s no-response timeout; streak counts consecutive ones. jambonz takes no action of its own: reprompt, wait or hang up as you see fit
dtmfdigits, duration_ms?the caller pressed a key (one event per key). Not sent while a transfer or secure detour is in progress
transfercommand_id, outcome, reason?a transfer command finished: bridged, failed or returned
recordingcommand_id, state, bya record command took effect: started, stopped, paused or resumed
application_updateupdate_type, datathe application sent agent:update (for example inject_context or generate_reply). jambonz runs no model for a harness, so it passes the update on for you to act on
errorcode, message, ref?a message you sent was refused (an invalid or duplicate id, a reply sent before its turn’s turn:start, an oversized turn record). ref names the id

Commands

Sent as {type: "command", command_id, name, args}; each gets a command:result.

nameargsresult
hangupreason? (sent as X-Reason)session:end follows
transferto? (E.164 number, sip: URI or registered user), trunk?, headers?, announce?announce is spoken first. Without to, the call goes to the agent’s own transfer destination; with neither, the command fails. The outcome arrives as a transfer event; on failed or returned the call stays with the harness. Transfers use the agent’s transfer settings in Agent Studio
recordaction: start, stop, pause, resume; type? (cloud, the default, or siprec)recording event
barge_inenabledapplies from the next reply until changed
listenlanguagechanges the recognition language for the following turns, where the speech vendor can change mid-call

play, and listen with hints, are not supported yet: they get command:result with ok: false.

Timeouts and failures

FailureWhat jambonz does
The socket cannot be openedruns the agent’s On failure (say a line, then hang up or transfer)
No first chunk within the wait (default 4 s)speaks the holding phrase once; after a second wait with no reply, runs On failure. While a tool:call of yours is outstanding, the second wait waits for it
The socket drops mid-callreconnects once within 2 seconds, sending session:start again with resumed: true and last_turn_id; otherwise runs On failure
A message is invalidsends an error event; the message is dropped

Each failure is in the feature-server log, the turn’s record and the verb’s actionHook (completion_reason: "harness_failure" with a harness_error).

Starting early (optional)

speech:start and transcript let a harness start work before the turn ends: retrieval, routing, or a draft reply from a final segment. Send nothing for the turn until its turn:start, then compare the turn’s text with what you started from: if it matches, stream the draft at once; if the caller said more, redo it. jambonz does no speculation of its own for a harness. A harness that does not want to start early ignores both messages.

The turn record

turn_record on reply:done is your account of how the harness handled the turn. Every field is optional, and anything else you add is kept as given. Up to 16 KB; do not put caller secrets in it, since it is stored.

{
"route": "procedure",
"procedures": ["outage_report"],
"steps": [
{ "name": "retrieve", "ms": 120, "detail": { "documents": 4 } },
{ "name": "router", "ms": 310 }
],
"model_calls": [
{ "model": "gpt-4.1-mini", "purpose": "router", "input_tokens": 812, "output_tokens": 14, "ms": 310 },
{ "model": "gpt-4.1", "purpose": "reply", "input_tokens": 2240, "output_tokens": 61, "first_token_ms": 420, "ms": 900 }
],
"own_tools": [{ "name": "lookup_account", "ok": true, "ms": 220 }],
"flags": ["identity_verified"],
"note": "free text"
}
FieldMeaning
routewhich path the turn took
proceduresprocedures in force
stepsthe stages of the turn, in order, with time spent
model_callseach model call: model, purpose, tokens, time
own_toolstools the harness ran itself (jambonz already records the ones run with tool:call)
flagsfacts established during the turn
noteanything a reader should see

A minimal harness without the SDK

import { WebSocketServer } from 'ws';
import OpenAI from 'openai';
const model = new OpenAI();
const wss = new WebSocketServer({ port: 8080, handleProtocols: () => 'harness.jambonz.org' });
wss.on('connection', (ws) => {
const history = [];
const replies = new Map(); // reply_id → its assistant message
const send = (type, data) => ws.send(JSON.stringify({ type, ...data }));
let current; // the turn being worked on
ws.on('message', async (raw) => {
const msg = JSON.parse(raw);
switch (msg.type) {
case 'session:start':
history.push({ role: 'system', content: `You answer calls for Acme. Caller: ${msg.from}.` });
break;
case 'turn:start': {
current = msg.turn_id;
const text = msg.input.type === 'speech' ? msg.input.text : '(call connected)';
history.push({ role: 'user', content: text });
const reply_id = `r${msg.turn_id}`;
const said = { role: 'assistant', content: '' };
const stream = await model.chat.completions.create({ model: 'gpt-4.1-mini', messages: history, stream: true });
history.push(said); // filled in as it streams
replies.set(reply_id, said);
for await (const chunk of stream) {
if (current !== msg.turn_id) return; // cancelled: stop
said.content += chunk.choices[0]?.delta?.content ?? '';
send('reply:chunk', { reply_id, turn_id: msg.turn_id, chunk });
}
send('reply:done', { reply_id, turn_id: msg.turn_id, turn_record: { model_calls: 1 } });
break;
}
case 'turn:cancel':
if (current === msg.turn_id) current = undefined;
break;
case 'event':
if (msg.name === 'user_interruption') { // keep only what the caller heard
const said = replies.get(msg.reply_id);
if (said) said.content = msg.spoken;
}
break;
}
});
});

Everything a real harness adds (retrieval, routing, checks, its own tools) happens inside the turn:start case; the protocol stays the same.