# Build a Voice Agent in TypeScript (no framework)

> Write your own phone voice agent as a plain Node process on the TeleQuick SDK: register a worker over QUIC, receive caller audio, run your own STT/LLM/TTS, and speak back. No LiveKit, no WebRTC, no inbound ports.

You want a voice agent on the phone, written in TypeScript, with **your own**
STT/LLM/TTS — and you do not want to adopt a framework to get there. This recipe
builds exactly that: a plain Node process that dials one outbound QUIC session to
TeleQuick, receives each routed call, and streams audio in and out.

Your process holds no inbound ports. TeleQuick owns the hard edge — the SIP
trunk or browser leg, turn-taking, recording, analytics — and hands your worker
8 kHz PCM in, takes 8 kHz PCM back.

> **NOTE:**
> Already on **LiveKit Agents**? Keep your `AgentSession` verbatim and only swap
>   the transport — see [Run a LiveKit Agent on TeleQuick Transport](/modalities/voice/recipes/livekit-transport).
>   This recipe is the framework-free path for everyone else.

## What you'll build

```
 PSTN / SIP trunk ─▶ ┌──────────────────┐                   ┌─────────────────────────────┐
                     │  voice gateway   │  ONE QUIC/WT      │ your Node worker (the SDK)   │
 browser (QUIC/MoQT) │  (owns the       │◀── session ──────▶│  register-and-wait          │
 on :443 ──────────▶ │  caller leg)     │  (worker dials    │  + your STT / LLM / TTS      │
                     └──────────────────┘   out to :443)    └─────────────────────────────┘
     caller audio ──▶ 8 kHz pcm16 ──▶ QUIC datagrams ──▶  for await (const pcm of call.audio())
     agent audio  ◀── back onto the call ◀── QUIC datagrams ◀── call.sendAudio(pcm)
```

## 1. Install

The SDK is served from our package origin, not the public registries — deps
still resolve from npmjs/PyPI/crates.io as normal.

```bash
npm install https://artifacts.clutchcall.dev/npm/tarballs/telequick-agents-0.3.0.tgz \
            @fails-components/webtransport
```

`@fails-components/webtransport` is the native QUIC/WebTransport client for Node
(Node has no built-in one). It is an optional peer — install it wherever the
worker runs. Node 20+.

## 2. Get media credentials

In the console, create an **External agent** (Agents → New → External). That
flow, in one step:

- registers a `VENDOR_BRIDGE` agent whose `vendor_room` is your **agent name**, and
- mints a **media key/secret** (`ck_…`) scoped to your org.

Your worker authenticates with that key/secret and registers under the agent
name; the platform routes every call for that agent to your connected worker.

## 3. Echo agent — prove the media path

Start with an agent that echoes the caller. It exercises the whole loop —
caller → gateway → QUIC → your process → back onto the call — with no AI:

```ts
import { AgentConfig, serve, type Call } from "@telequick/agents";

async function handler(call: Call): Promise<void> {
  console.log(`call ${call.callId} from ${call.callerNumber} → ${call.calledNumber}`);
  for await (const pcm of call.audio()) {   // 20 ms pcm16 @ 8 kHz
    await call.sendAudio(pcm);              // echo caller audio back
  }
}

// Reads TELEQUICK_HOST / TELEQUICK_MEDIA_KEY / TELEQUICK_MEDIA_SECRET / TELEQUICK_AGENT
await serve(handler, AgentConfig.fromEnv({ agentName: "salesbot" }), {
  onReady: () => console.log("connected + registered — awaiting calls"),
  onDisconnected: (err) => console.warn("dropped, reconnecting:", err.message),
});
```

```bash
export TELEQUICK_HOST=engine.telequick.dev
export TELEQUICK_MEDIA_KEY=ck_...
export TELEQUICK_MEDIA_SECRET=...
export TELEQUICK_AGENT=salesbot
node --import tsx echo-agent.ts
# → "worker ready (agent=salesbot) — awaiting calls"
```

`serve()` connects, authenticates (HMAC over a server nonce — your secret never
leaves your process), registers, and runs `handler` once per call. It reconnects
with backoff on any drop, so presence recovers on its own across an engine
restart.

### Know when it's connected

`serve()` runs forever, so it never "returns connected". Read its lifecycle
through the callbacks instead — `onReady` fires the moment the worker is
authenticated and registered (present to the platform, awaiting calls), and again
after every automatic reconnect; `onDisconnected` fires on a drop, before the
retry. Drive a health check or a readiness gate off them:

```ts
let connected = false;
await serve(handler, AgentConfig.fromEnv({ agentName: "salesbot" }), {
  onReady: () => { connected = true; },        // e.g. flip your /healthz to 200
  onDisconnected: () => { connected = false; },
});
```

On the platform side, a connected worker shows as **present** for its agent in
the console (Agents → your external agent → status), and the worker logs
`worker ready (agent=…) — awaiting calls` on each successful (re)connect.

## 4. Your real agent

Swap the echo body for your pipeline. Audio in and out is 16-bit PCM at
`call.sampleRate` (8 kHz for telephony) — resample your provider's audio to that
rate before `sendAudio`. Forward each turn with `sendTranscript` so it lands in
TeleQuick's transcript store, analytics timeline, and
`voice.transcript.ready` webhooks — the same history a native agent produces.

```ts
import { AgentConfig, serve, type Call } from "@telequick/agents";

async function handler(call: Call): Promise<void> {
  // Greet first — most callers expect the agent to speak on answer.
  for await (const pcm of yourTTS("Hi, thanks for calling. How can I help?", call.sampleRate)) {
    await call.sendAudio(pcm);
  }
  await call.flush();

  for await (const pcm of call.audio()) {
    const text = await yourSTT(pcm, call.sampleRate);   // your streaming STT
    if (!text) continue;
    call.sendTranscript("user", text);

    // Caller started talking over the agent → stop playback immediately.
    await call.clear();                                 // barge-in

    const reply = await yourLLM(text);
    call.sendTranscript("assistant", reply);
    for await (const chunk of yourTTS(reply, call.sampleRate)) {
      await call.sendAudio(chunk);
    }
    await call.flush();
  }
}

await serve(handler, AgentConfig.fromEnv({ agentName: "salesbot" }));
```

What the `Call` gives you:

| Member | What it is |
|---|---|
| `call.callerNumber` / `call.calledNumber` | ANI / DNIS for this call |
| `call.metadata` | operator-defined context KVs from the agent config |
| `call.tags` | the agent's effective **resource tags** (`{key: value}`, inherited) — the same cost-allocation/ownership/environment labels you set in the console; branch or bill per tenant without a lookup |
| `call.trunkId` | the inbound trunk the call arrived on |
| `call.audio()` | async iterator of 20 ms pcm16 caller frames until hangup |
| `call.sendAudio(pcm)` | queue agent audio; paced onto the wire as 20 ms frames |
| `call.clear()` | barge-in — drop buffered agent audio so playout stops now |
| `call.flush()` | mark the outbound segment complete |
| `call.sendTranscript(role, text)` | forward a turn to platform history + webhooks |
| `call.sendUsage(stage, fields, provider, model)` | per-stage token analytics — `"stt"`/`"llm"`/`"tts"`. You run the providers on your own keys, so **only what you forward is counted** |
| `call.sendToolCall(tool, args, result)` | log a tool you invoked → analytics timeline + `voice.agent.tool_called`. **Redact sensitive values yourself** — args/result are forwarded verbatim |

## 5. Route a number and place a call

Point an inbound number at the External agent you created (Numbers → assign →
your agent), then call it. With the worker running you'll see
`call_id=… from=… → handler`, and the caller hears your agent.

To dial **out** to your agent, use the admin API (`@telequick/admin-sdk`) or the
console's outbound flow — the answered call is dispatched to your worker exactly
like an inbound one.

## Notes

- **No inbound ports.** The worker only dials out on :443 — deploy it anywhere,
  including behind NAT.
- **8 kHz pcm16 both ways.** Telephony legs are 8 kHz; resample 24/48 kHz TTS
  down before `sendAudio`, or you'll hear chipmunk audio.
- **Concurrency is free.** Each call is its own `Call`; `serve` runs your handler
  per call on the one shared session.
- **Other languages.** The identical SDK — same wire protocol, same
  `serve(handler)` / `Call` shape, same `onReady`/`onDisconnected` lifecycle —
  ships for four languages, one directory per language in the same package:

  | Language | Package | Loop |
  |---|---|---|
  | TypeScript | `@telequick/agents` (npm) | `for await (const pcm of call.audio())` |
  | Python | `telequick-agents` (pip) | `async for pcm in call.audio()` |
  | Go | `github.com/telequick/agents` | `for pcm := range call.Audio()` |
  | Rust | `telequick-agents` (crate) | `while let Some(pcm) = call.recv_audio().await` |

  Each carries an `examples/echo` you can run against your agent with the same
  four `TELEQUICK_HOST` / `TELEQUICK_MEDIA_KEY` / `TELEQUICK_MEDIA_SECRET` /
  `TELEQUICK_AGENT` variables. All four ship at the **same version** and
  install from the same origin:

  
  **Python:**
```bash
  pip install 'telequick-agents[quic]==0.3.0' \
    --extra-index-url https://artifacts.clutchcall.dev/pip/simple/
  ```

  **Go:**
```bash
  export GOPROXY=https://artifacts.clutchcall.dev/go GOSUMDB=off
  go get github.com/telequick/agents@v0.3.0
  ```

  **Rust:**
```toml
  # .cargo/config.toml
  [registries.telequick]
  index = "sparse+https://artifacts.clutchcall.dev/cargo/"

  # Cargo.toml
  [dependencies]
  telequick-agents = { version = "0.3.0", registry = "telequick" }
  ```
  

  > **NOTE:**
> `GOSUMDB=off` is required for the Go module: the public checksum database
>     has no entry for a module served from a private proxy. Scope it to this
>     module (`GONOSUMDB`/`GOPRIVATE`) if you would rather not disable it globally.

## Related

- [Run a LiveKit Agent on TeleQuick Transport](/modalities/voice/recipes/livekit-transport)
- [Pipecat on TeleQuick Transport](/modalities/voice/recipes/pipecat-transport)
- [Voice SDK reference (JavaScript)](/modalities/voice/sdk/javascript)
