Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Tests

on:
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-node@v5
with:
node-version: 22

- name: Install dependencies
working-directory: typescript
run: npm ci

- name: Typecheck
working-directory: typescript
run: npm run typecheck

- name: Run tests
working-directory: typescript
run: npm test
106 changes: 106 additions & 0 deletions docs/verbs/pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,40 @@ There are two ways early generation is triggered:

For other STT vendors with native turn-taking (assemblyai, speechmatics), early generation is not available — they don't emit a preflight signal.

## Noise isolation

The `noiseIsolation` property enables server-side noise cancellation on the call audio. By default it filters the inbound (caller) audio, improving STT accuracy in noisy environments. It can also be configured to filter outbound audio via the `direction` option. Two vendors are available:

- **`"krisp"`** — Krisp's proprietary noise cancellation. Requires a Krisp API key on self-hosted systems.
- **`"rnnoise"`** — Open-source RNNoise-based noise cancellation. No API key required.

Shorthand (default settings):

```json
{
"noiseIsolation": "krisp"
}
```

Detailed configuration:

```json
{
"noiseIsolation": {
"mode": "krisp",
"level": 80,
"direction": "read"
}
}
```

- `mode` — Vendor: `"krisp"` or `"rnnoise"`.
- `level` — Suppression level 0–100. Higher values are more aggressive. Default: 100.
- `direction` — `"read"` filters caller audio (default), `"write"` filters outbound audio.
- `model` — Optional model name override.

Noise isolation can also be enabled/disabled mid-call via the `config` verb, the REST LCC API, or a WebSocket inject command (`noiseIsolation:status`).

## Barge-in

By default, users can interrupt the assistant while it's speaking. The `bargeIn` object controls this:
Expand Down Expand Up @@ -95,6 +129,78 @@ The `arguments` field is already parsed (an object, not a JSON string).

**WebSocket**: The tool call arrives as an event on the hook path. Respond by calling `session.toolOutput(tool_call_id, result).reply()`.

## MCP servers (external tools)

Instead of (or in addition to) defining tools inline via `llmOptions.tools` and handling them with `toolHook`, you can connect to external MCP servers. The pipeline connects to each server at startup via SSE transport, discovers available tools, and makes them available to the LLM alongside any inline tools.

```json
{
"verb": "pipeline",
"mcpServers": [
{
"url": "https://livescoremcp.com/sse"
}
],
"llm": {
"vendor": "openai",
"model": "gpt-4.1",
"llmOptions": {
"messages": [
{ "role": "system", "content": "You are a sports assistant. Use available tools to look up live scores and fixtures when asked." }
]
}
},
"stt": { "vendor": "deepgram", "language": "en-US" },
"tts": { "vendor": "cartesia", "voice": "sonic-english" }
}
```

The [LiveScore MCP server](https://livescoremcp.com/) is a free, public MCP server that exposes tools for live football scores, fixtures, team stats, and player data. The pipeline discovers these tools automatically at startup — no need to define tool schemas in `llmOptions.tools`. A caller can simply ask "what football matches are on right now?" and the LLM will use the `get_live_scores` tool to fetch real-time data.

If an MCP server requires authentication, pass credentials in the `auth` property:

```json
{
"mcpServers": [
{
"url": "https://mcp.example.com/sse",
"auth": {
"apiKey": "your-api-key-here"
}
}
]
}
```

**How tool dispatch works**: When the LLM requests a tool call, the pipeline checks MCP servers first. If the tool name matches one discovered from an MCP server, the call is dispatched there directly and the result is fed back to the LLM. If no MCP server provides the tool, it falls through to the `toolHook` webhook. You can use both MCP servers and `toolHook` together — MCP handles the tools it knows about, and `toolHook` handles the rest.

**TypeScript example** — a pipeline agent with the LiveScore MCP server:

```typescript
session
.pipeline({
stt: { vendor: 'deepgram', language: 'en-US' },
tts: { vendor: 'cartesia', voice: 'sonic-english' },
llm: {
vendor: 'openai',
model: 'gpt-4.1',
llmOptions: {
messages: [
{ role: 'system', content: 'You are a sports assistant. Use available tools to answer questions about football scores, fixtures, and teams.' },
],
},
},
mcpServers: [
{ url: 'https://livescoremcp.com/sse' },
// To use a server that requires auth:
// { url: 'https://mcp.example.com/sse', auth: { apiKey: 'your-key' } },
],
turnDetection: 'krisp',
actionHook: '/pipeline-complete',
})
.send();
```

## LLM configuration

The `llm` property is the only required field. It configures the text-to-text LLM:
Expand Down
2 changes: 1 addition & 1 deletion schema/verbs/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@
},
"noiseIsolation": {
"type": "object",
"description": "Noise isolation configuration to reduce background noise on the caller's audio.",
"description": "Noise isolation configuration to reduce background noise on call audio. Defaults to filtering inbound (caller) audio; can also filter outbound audio via the direction option.",
"properties": {
"enable": {
"type": "boolean"
Expand Down
62 changes: 62 additions & 0 deletions schema/verbs/pipeline.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,68 @@
"type": "boolean",
"description": "Enable speculative LLM prompting before end-of-turn is confirmed. When using Krisp turn detection, set this to true to speculatively prompt the LLM before Krisp confirms the turn has ended. If the transcript matches when turn ends, buffered tokens are released immediately — reducing response latency. Note: Deepgram Flux performs early generation automatically via its native EagerEndOfTurn signal regardless of this setting. Default: false.",
"default": false
},
"noiseIsolation": {
"oneOf": [
{
"type": "string",
"enum": ["krisp", "rnnoise"],
"description": "Shorthand — enable noise isolation with the specified vendor using default settings."
},
{
"type": "object",
"description": "Detailed noise isolation configuration.",
"properties": {
"mode": {
"type": "string",
"description": "Noise isolation vendor/mode (e.g. 'krisp')."
},
"level": {
"type": "number",
"minimum": 0,
"maximum": 100,
"description": "Suppression level 0–100. Default: 100."
},
"direction": {
"type": "string",
"enum": ["read", "write"],
"description": "Audio direction to apply noise isolation. 'read' filters caller audio, 'write' filters outbound audio. Default: 'read'."
},
Comment on lines +129 to +133
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The direction option supports both read (caller audio) and write (outbound audio), but the noiseIsolation description frames this as only applying to the caller's audio. Please update the description to reflect both directions (or clarify defaults/limitations) to avoid misleading schema consumers.

Copilot uses AI. Check for mistakes.
"model": {
"type": "string",
"description": "Optional model name override."
}
},
"required": ["mode"],
"additionalProperties": false
}
],
"description": "Enable server-side noise isolation to reduce background noise on call audio. Defaults to filtering inbound (caller) audio; set direction to 'write' for outbound. Useful for improving STT accuracy in noisy environments."
},
"mcpServers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "uri",
"description": "The URL of the MCP server."
},
"auth": {
"type": "object",
"description": "Authentication for the MCP server.",
"additionalProperties": true
},
"roots": {
"type": "array",
"items": { "type": "object" },
"description": "MCP root definitions."
}
},
"required": ["url"]
},
"description": "External MCP servers that provide tools to the LLM. The pipeline connects at startup via SSE, discovers available tools, and makes them callable by the LLM."
}
},
"required": [
Expand Down
14 changes: 14 additions & 0 deletions typescript/src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,20 @@ export class CallsResource {
async mute(callSid: string, status: 'mute' | 'unmute'): Promise<void> {
return this.update(callSid, { mute_status: status });
}

/** Enable or disable server-side noise isolation. */
async noiseIsolation(
callSid: string,
status: 'enable' | 'disable',
opts?: { vendor?: string; level?: number; model?: string }
): Promise<void> {
return this.update(callSid, {
noise_isolation_status: status,
...(opts?.vendor ? { noise_isolation_vendor: opts.vendor } : {}),
...(opts?.level !== undefined ? { noise_isolation_level: opts.level } : {}),
...(opts?.model ? { noise_isolation_model: opts.model } : {}),
});
}
Comment on lines +150 to +162
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing tests for CallsResource convenience methods (redirect/whisper/mute), but the new noiseIsolation convenience method is untested. Add a unit test to verify it sends noise_isolation_status and correctly maps opts.vendor/level/model to noise_isolation_vendor/noise_isolation_level/noise_isolation_model in the request body.

Copilot uses AI. Check for mistakes.
}

export class ConferencesResource {
Expand Down
8 changes: 8 additions & 0 deletions typescript/src/types/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ export interface UpdateCallRequest {
dub?: Verb;
/** Mute/unmute the call. */
mute_status?: 'mute' | 'unmute';
/** Enable/disable server-side noise isolation. */
noise_isolation_status?: 'enable' | 'disable';
/** Noise isolation vendor (default: krisp). */
noise_isolation_vendor?: string;
/** Noise isolation level (0-100). */
noise_isolation_level?: number;
/** Noise isolation model. */
noise_isolation_model?: string;
/** Mute/unmute in conference. */
conf_mute_status?: 'mute' | 'unmute';
/** Hold/unhold in conference. */
Expand Down
9 changes: 9 additions & 0 deletions typescript/src/types/verbs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,15 @@ export interface PipelineVerb {
greeting?: boolean;
/** Speculatively prompt the LLM on final transcript before Krisp end-of-turn. Default: false. */
earlyGeneration?: boolean;
/** Enable server-side noise isolation to reduce background noise on call audio. Defaults to inbound (caller) audio; set direction to 'write' for outbound. */
noiseIsolation?: 'krisp' | 'rnnoise' | {
mode: string;
level?: number;
direction?: 'read' | 'write';
model?: string;
};
/** External MCP servers that provide tools to the LLM. */
mcpServers?: McpServerConfig[];
}

export interface ListenVerb {
Expand Down
12 changes: 12 additions & 0 deletions typescript/src/websocket/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,18 @@ export class Session extends EventEmitter {
this.injectCommand('mute:status', { mute_status: status }, callSid);
}

/** Enable or disable server-side noise isolation. */
injectNoiseIsolation(
status: 'enable' | 'disable',
opts?: { vendor?: string; level?: number; model?: string },
callSid?: string
): void {
this.injectCommand('noiseIsolation:status', {
noise_isolation_status: status,
...opts,
}, callSid);
Comment on lines +286 to +289
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

injectNoiseIsolation spreads opts directly, which sends keys like vendor/level/model. Elsewhere (REST UpdateCallRequest and CallsResource.noiseIsolation) the API uses noise_isolation_vendor, noise_isolation_level, and noise_isolation_model. To avoid the server ignoring these fields, map the option keys to the expected noise_isolation_* names (and omit undefined values) before calling injectCommand.

Suggested change
this.injectCommand('noiseIsolation:status', {
noise_isolation_status: status,
...opts,
}, callSid);
const payload: Record<string, unknown> = {
noise_isolation_status: status,
};
if (opts?.vendor !== undefined) {
payload.noise_isolation_vendor = opts.vendor;
}
if (opts?.level !== undefined) {
payload.noise_isolation_level = opts.level;
}
if (opts?.model !== undefined) {
payload.noise_isolation_model = opts.model;
}
this.injectCommand('noiseIsolation:status', payload, callSid);

Copilot uses AI. Check for mistakes.
}

/** Pause or resume audio streaming (listen/stream). */
injectListenStatus(status: 'pause' | 'resume', callSid?: string): void {
this.injectCommand('listen:status', { listen_status: status }, callSid);
Expand Down
40 changes: 40 additions & 0 deletions typescript/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,46 @@ describe('CallsResource', () => {
const body = JSON.parse(mock.mock.calls[0][1].body);
expect(body.mute_status).toBe('unmute');
});

it('noiseIsolation sends noise_isolation_status in update', async () => {
const mock = mockFetch(200, {});
globalThis.fetch = mock as unknown as typeof fetch;
const client = createClient();

await client.calls.noiseIsolation('call-1', 'enable');
const body = JSON.parse(mock.mock.calls[0][1].body);
expect(body.noise_isolation_status).toBe('enable');
});

it('noiseIsolation maps opts to flat noise_isolation_ fields', async () => {
const mock = mockFetch(200, {});
globalThis.fetch = mock as unknown as typeof fetch;
const client = createClient();

await client.calls.noiseIsolation('call-1', 'enable', {
vendor: 'krisp',
level: 80,
model: 'custom-model',
});
const body = JSON.parse(mock.mock.calls[0][1].body);
expect(body.noise_isolation_status).toBe('enable');
expect(body.noise_isolation_vendor).toBe('krisp');
expect(body.noise_isolation_level).toBe(80);
expect(body.noise_isolation_model).toBe('custom-model');
});

it('noiseIsolation omits undefined opts fields', async () => {
const mock = mockFetch(200, {});
globalThis.fetch = mock as unknown as typeof fetch;
const client = createClient();

await client.calls.noiseIsolation('call-1', 'disable');
const body = JSON.parse(mock.mock.calls[0][1].body);
expect(body.noise_isolation_status).toBe('disable');
expect(body).not.toHaveProperty('noise_isolation_vendor');
expect(body).not.toHaveProperty('noise_isolation_level');
expect(body).not.toHaveProperty('noise_isolation_model');
});
});
});

Expand Down