> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.jambonz.org/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.jambonz.org/_mcp/server.

# @jambonz/sdk/harness reference

`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.

```bash
npm install @jambonz/sdk pino
```

```ts
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](/agent-studio/custom-harness/quickstart).

## Options

| Option          | Default                                     | Meaning                                                                             |
| --------------- | ------------------------------------------- | ----------------------------------------------------------------------------------- |
| `model`         | `gpt-4.1-mini`                              | the model for every turn, unless `beforeModel` says otherwise                       |
| `instructions`  |                                             | the system prompt                                                                   |
| `greeting`      |                                             | spoken when the call connects, without a model call. Without it the model greets    |
| `client`        | from `OPENAI_API_KEY` and `OPENAI_BASE_URL` | `{ apiKey, baseURL, headers }` for any OpenAI-compatible chat-completions endpoint  |
| `modelParams`   |                                             | extra request fields (`temperature`, `max_tokens`, ...)                             |
| `maxToolRounds` | 5                                           | most model calls in one turn while the model keeps calling tools                    |
| `errorReply`    |                                             | spoken when a turn fails (a model or hook error) before anything was said           |
| `apiKey`        |                                             | when set, a connection without `Authorization: Bearer <apiKey>` is refused with 401 |
| `logger`        | pino at `LOG_LEVEL`                         | a pino logger; every call's lines carry its `call_sid`                              |
| `tools`         |                                             | tools of the harness's own (below)                                                  |

## Hooks

Every hook is optional and may be async. Each gets `ctx`, the [per-call context](#the-per-call-context).

| Hook                           | Runs                                  | Use it for                                                                                                      |
| ------------------------------ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `onSessionStart(session, ctx)` | once, when the call connects          | per-call state; `session` has `from`, `to`, `direction`, `metadata`, `sip_headers` and the agent's `tools`      |
| `beforeModel(turn, ctx)`       | each turn, before the model           | routing, retrieval, gating, prompt assembly. Return a [`BeforeModelResult`](#beforemodel) or nothing            |
| `checkSentence(sentence, ctx)` | before each sentence is spoken        | `true` speaks it, `false` stops the reply, a string is spoken instead                                           |
| `afterTurn(turn, ctx)`         | after each reply, before `reply:done` | adding to the turn record with `ctx.record()`; `turn.reply` is what was spoken                                  |
| `onEvent.<name>(event, ctx)`   | when jambonz sends that event         | `dtmf`, `silence`, `user_interruption`, `transfer`, `recording`, `application_update`, ...                      |
| `onSessionEnd(end, ctx)`       | when the call ends                    | your own records; `end.reason` is as in [`session:end`](/agent-studio/custom-harness/protocol#jambonz--harness) |

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

### beforeModel

| Field           | Meaning                                                                                               |
| --------------- | ----------------------------------------------------------------------------------------------------- |
| `instructions`  | the system prompt for this turn                                                                       |
| `messages`      | the full message list to send instead of `[system, ...ctx.history]` (include your own system message) |
| `tools`         | names of the tools to offer this turn, from yours and the agent's; default all                        |
| `model`         | the model for this turn                                                                               |
| `reply`         | speak this and skip the model                                                                         |
| `route`         | recorded as the turn record's `route`                                                                 |
| `interruptible` | `false` holds barge-in until this turn's reply has played                                             |

```ts
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.

```ts
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

| Member                                      | Meaning                                                                                                          |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `callSid`                                   | the call's sid                                                                                                   |
| `session`                                   | the `session:start` message                                                                                      |
| `state`                                     | a `Map` for your per-call state                                                                                  |
| `history`                                   | the conversation so far (user, assistant and tool messages); you may edit it                                     |
| `log`                                       | a pino logger bound to the call                                                                                  |
| `say(text, { interrupt?, interruptible? })` | speak on your own initiative; returns the reply id. See [replies](/agent-studio/custom-harness/protocol#replies) |
| `command(name, args)`                       | send a [command](/agent-studio/custom-harness/protocol#commands) 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                                                                         |

```ts
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.