Skip to navigation

@jambonz/sdk/harness reference

Build a custom harness in TypeScript or JavaScript without handling the protocol yourself.

createHarness() from @jambonz/sdk/harness is a complete harness server. Out of the box it passes each turn to an OpenAI-compatible model and streams the reply to jambonz, keeping the conversation history, trimming it to what the caller heard when they interrupt, and handling silence. Hooks let you change any step.

npm install @jambonz/sdk pino
import { createHarness } from '@jambonz/sdk/harness';
await createHarness({
model: 'gpt-4.1-mini',
instructions: 'You answer calls for Acme. Keep replies to one or two short sentences.',
greeting: 'Thanks for calling Acme. How can I help?',
apiKey: process.env.HARNESS_SECRET, // optional: the agent's Secret in Agent Studio
}).listen(8080);

The quickest start is npm create @jambonz/agent-harness@latest my-agent, which generates a project around it. See the quickstart.

Options

OptionDefaultMeaning
modelgpt-4.1-minithe model for every turn, unless beforeModel says otherwise
instructionsthe system prompt
greetingspoken when the call connects, without a model call. Without it the model greets
clientfrom OPENAI_API_KEY and OPENAI_BASE_URL{ apiKey, baseURL, headers } for any OpenAI-compatible chat-completions endpoint
modelParamsextra request fields (temperature, max_tokens, …)
maxToolRounds5most model calls in one turn while the model keeps calling tools
errorReplyspoken when a turn fails (a model or hook error) before anything was said
apiKeywhen set, a connection without Authorization: Bearer <apiKey> is refused with 401
loggerpino at LOG_LEVELa pino logger; every call’s lines carry its call_sid
toolstools of the harness’s own (below)

Hooks

Every hook is optional and may be async. Each gets ctx, the per-call context.

HookRunsUse it for
onSessionStart(session, ctx)once, when the call connectsper-call state; session has from, to, direction, metadata, sip_headers and the agent’s tools
beforeModel(turn, ctx)each turn, before the modelrouting, retrieval, gating, prompt assembly. Return a BeforeModelResult or nothing
checkSentence(sentence, ctx)before each sentence is spokentrue speaks it, false stops the reply, a string is spoken instead
afterTurn(turn, ctx)after each reply, before reply:doneadding to the turn record with ctx.record(); turn.reply is what was spoken
onEvent.<name>(event, ctx)when jambonz sends that eventdtmf, silence, user_interruption, transfer, recording, application_update, …
onSessionEnd(end, ctx)when the call endsyour own records; end.reason is as in session:end

turn is { turnId, input, text }, where text is the caller’s words (empty for the greeting and for no input).

beforeModel

FieldMeaning
instructionsthe system prompt for this turn
messagesthe full message list to send instead of [system, ...ctx.history] (include your own system message)
toolsnames of the tools to offer this turn, from yours and the agent’s; default all
modelthe model for this turn
replyspeak this and skip the model
routerecorded as the turn record’s route
interruptiblefalse holds barge-in until this turn’s reply has played
beforeModel(turn, ctx) {
if (/refund/i.test(turn.text)) {
return { route: 'refunds', instructions: REFUNDS_PROMPT, tools: ['lookup_order', 'issue_refund'] };
}
},

Tools

The harness’s own tools run in-process. The agent’s tools from Agent Studio (session.tools) are offered to the model as well and run through jambonz; you do not declare those.

tools: {
lookup_outage: {
description: 'Look up a power outage by street.',
parameters: { type: 'object', properties: { street: { type: 'string' } }, required: ['street'] },
run: async ({ street }, ctx) => outages.find(String(street)),
},
},

Built-in behaviour

Without hooks, the harness:

  • keeps the conversation in ctx.history and sends it with the system prompt on each turn
  • streams the model’s reply to jambonz as it arrives
  • trims the last reply in the history to what the caller heard when they interrupt
  • on silence, asks “Are you still there?” once and says goodbye and hangs up on the next. Set onEvent.silence to replace this
  • sends a turn record for each turn: its route, steps with timings, model calls with tokens, and tools it ran

The per-call context

MemberMeaning
callSidthe call’s sid
sessionthe session:start message
statea Map for your per-call state
historythe conversation so far (user, assistant and tool messages); you may edit it
loga pino logger bound to the call
say(text, { interrupt?, interruptible? })speak on your own initiative; returns the reply id. See replies
command(name, args)send a command and wait for its result { ok, error? }
callTool(name, args)run one of the agent’s Studio tools; resolves with its result
record(field, value)add a field to the current turn’s record
onEvent: {
async dtmf(event, ctx) {
if (event.digits !== '0') return;
ctx.say('Connecting you to a person now.');
const result = await ctx.command('transfer', { to: '+15085550100' });
if (!result.ok) ctx.say('Sorry, I could not connect you.');
},
},

The server

createHarness() returns { listen, close }:

  • listen(port) or listen({ port, host }) starts listening and resolves with the bound address; use port 0 for an ephemeral port in tests.
  • close() ends every call’s socket and stops listening.

GET /health answers {"ok":true,"calls":N}. If jambonz reconnects a dropped socket within its 2-second window, the call continues with its history and state.

Lower-level pieces

@jambonz/sdk/harness also exports the protocol’s message types, createModelClient() (the streaming chat-completions client the harness uses) and SentenceSplitter, for building your own harness loop.