# SDK

> Node.js / TypeScript の SDK（npm の voicast）の使い方。会話の設定・テナント・通話の操作、Webhook と通話中の道具の署名の検証、ブラウザからの Web の会話。

Source: https://voicast.jp/docs/sdk

> SDK は公開の準備中です（0.1.0）。npm に出るまでは、[API](https://voicast.jp/docs/api.md) を直接呼んでください。

- 依存するパッケージはありません（`fetch` と Web Crypto を使います）。
- Node.js 18 以上・Bun・Deno・Cloudflare Workers で動きます。`voicast/web` はブラウザで動きます。
- ESM と CommonJS、TypeScript の型付きです。

## インストール

```bash
npm install voicast
```

## はじめかた

```ts
import { Voicast } from 'voicast';

const voicast = new Voicast(process.env.VOICAST_API_KEY); // 省くと環境変数 VOICAST_API_KEY を使う

// 会話の設定
const agent = await voicast.agents.create({
  name: '受付',
  config: {
    greeting: 'お電話ありがとうございます。さくら歯科です。',
    persona: '丁寧で落ち着いた受付',
    fields: [
      { key: 'name', label: 'お名前', required: true },
      { key: 'phone', label: '電話番号', type: 'phone' },
    ],
    handoff_when: ['急患', '担当者と話したいと言われた'],
    closing: '承りました。担当者から折り返しご連絡します。',
    voice: 'Kore',
  },
});

// テナント（電話を受ける会社）
const tenant = await voicast.tenants.create({ external_id: 'clinic-123', name: 'さくら歯科', agent_id: agent.id });
// tenant.secret（vts_…）はこのときだけ返る。Twilio の <Stream> の secret に渡す
```

API キーはサーバーだけで使います。ブラウザやアプリには渡しません。

## ブラウザ・アプリから話す

サーバーでセッションを作り、`client_token`・`webrtc_url`・`ice_servers` だけをブラウザに渡します。

```ts
// サーバー
const session = await voicast.sessions.create({ tenant_id: tenant.id }); // output: 'audio'（既定）か 'text'
return { client_token: session.client_token, webrtc_url: session.webrtc_url, ice_servers: session.ice_servers };
```

```ts
// ブラウザ
import { VoiceSession } from 'voicast/web';

const session = new VoiceSession({ clientToken, webrtcUrl, iceServers }); // iceServers はセッションの ice_servers
session.on('user-transcript', ({ text, final }) => showUser(text, final));
session.on('bot-output', ({ text }) => showBot(text)); // AI が話した文（output: 'audio'）
session.on('closed', ({ reason }) => console.log('closed', reason));
await session.start(); // マイクの許可を求めてからつなぐ

hangupButton.onclick = () => session.stop();
```

`output: 'text'` では声を出さず、返事が 1 文ずつ `reply` で届きます。アバター（[avacast](https://avacast.jp/) など）に話させるときに使います。

```ts
session.on('reply', ({ text }) => avatar.speak(text));
session.on('interrupted', () => avatar.interrupt()); // 相手が話し始めた
session.on('end', ({ outcome }) => console.log('ended', outcome));
```

| イベント | 中身 | いつ |
| --- | --- | --- |
| `ready` | `{}` | AI の準備ができた |
| `reply` | `{ text }` | 返事の 1 文（`output: 'text'`） |
| `interrupted` | `{}` | 相手が話し始めた。話している途中の返事を止める |
| `end` | `{ outcome }` | AI が会話を終えた |
| `user-transcript` | `{ text, final }` | 相手の発言 |
| `bot-output` | `{ text }` | AI が話した文（`output: 'audio'`） |
| `bot-started-speaking`・`bot-stopped-speaking` | `{}` | AI が話し始めた・話し終えた |
| `track` | `{ stream }` | AI の音声トラック（`playAudio: false` でなければ自動で再生） |
| `state` | `{ state }` | `RTCPeerConnection.connectionState` が変わった |
| `message` | RTVI のメッセージ | 音声サーバーからのすべてのメッセージ |
| `error` | `{ error }` | |
| `closed` | `{ reason }` | `ended`・`hangup`・`failed`・`disconnected`・`error`。1 回だけ |

オプション: `mediaStream`（自分で取ったマイク）・`audioElement`・`playAudio`・`iceServers`・`iceGatheringTimeout`・`disconnectTimeout`・`fetch`。`setMuted(true)` でマイクを止めます。

通話のあとは `voicast.calls.getByProvider(session.id)` で結果を引けます。

## Webhook の検証

```ts
import { verifyWebhook } from 'voicast';

const event = await verifyWebhook(rawBody, headers, process.env.VOICAST_WEBHOOK_SECRET!); // whsec_…
switch (event.type) {
  case 'call.ended':
    console.log(event.data.outcome, event.data.fields, event.data.tenant.external_id);
    break;
  case 'call.summary_ready':
    console.log(event.data.summary, event.data.category);
    break;
  case 'billing.balance_low':
    console.log(event.data.balance_yen);
    break;
}
```

- 本文は JSON に直す前の文字列かバイト列を渡します。
- 署名が合わない・時刻が 5 分以上ずれているときは `WebhookVerificationError` を投げます（4 つ目の引数 `{ tolerance: 300 }` で変えられます）。
- 鍵を切り替えるあいだは、鍵の配列を渡せます。

Express:

```ts
app.post('/webhooks/voicast', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const event = await verifyWebhook(req.body, req.headers, secret);
    // event を処理する
    res.sendStatus(204);
  } catch {
    res.sendStatus(400);
  }
});
```

Hono・Next.js（App Router）:

```ts
const event = await verifyWebhook(await req.text(), req.headers, secret);
```

## 通話中の道具の検証

```ts
import { verifyToolCall } from 'voicast';

app.post('/voicast/tools', express.raw({ type: 'application/json' }), async (req, res) => {
  const call = await verifyToolCall<{ date: string }>(req.body, req.headers, process.env.VOICAST_TOOLS_SECRET!);
  if (call.tool === 'find_free_slots') {
    res.json({ slots: await findSlots(call.arguments.date, call.call.tenant.external_id) });
  }
});
```

`call.tool_call_id`（見出しの `idempotency-key` と同じ）は呼び出しごとに 1 つです。予約が 2 度入らないよう、これで重複を見分けます（[通話中の道具](https://voicast.jp/docs/tools.md)）。

## メソッド

| メソッド | API |
| --- | --- |
| `agents.create({ name, config })` | `POST /v1/agents` |
| `agents.list()` | `GET /v1/agents` |
| `agents.get(id, { version? })` | `GET /v1/agents/{id}` |
| `agents.update(id, { name?, config })` | `PUT /v1/agents/{id}`（新しい版） |
| `agents.versions(id)` | `GET /v1/agents/{id}/versions` |
| `agents.delete(id)` | `DELETE /v1/agents/{id}` |
| `tenants.create({ external_id, name?, agent_id? })` | `POST /v1/tenants` |
| `tenants.list()` | `GET /v1/tenants` |
| `tenants.get(id)` | `GET /v1/tenants/{id}` |
| `tenants.update(id, { name?, agent_id?, status? })` | `PATCH /v1/tenants/{id}` |
| `tenants.rotateSecret(id)` | `POST /v1/tenants/{id}/secret` |
| `tenants.delete(id)` | `DELETE /v1/tenants/{id}` |
| `tenants.listConnections(id)` | `GET /v1/tenants/{id}/connections` |
| `tenants.createConnectLink(id, { connectors?, frame_origin? })` | `POST /v1/tenants/{id}/connect-links` |
| `calls.list({ tenant_id?, cursor?, limit? })` | `GET /v1/calls`（`before` も最初のページだけ受ける） |
| `calls.get(id)` | `GET /v1/calls/{id}` |
| `calls.getByProvider(providerCallId)` | `GET /v1/calls/by-provider/{providerCallId}` |
| `calls.transcript(id)` | `GET /v1/calls/{id}/transcript` |
| `sessions.create({ tenant_id, agent_id?, output? })` | `POST /v1/sessions` |
| `usage.get({ from?, to?, group_by? })` | `GET /v1/usage` |
| `connectors.list()` | `GET /v1/connectors` |
| `connections.list({ tenant_id? })` | `GET /v1/connections` |
| `connections.create({ connector, secret, input?, tenant_id? })` | `POST /v1/connections` |
| `connections.get(id)` | `GET /v1/connections/{id}` |
| `connections.update(id, { config })` | `PATCH /v1/connections/{id}` |
| `connections.options(id, query?)` | `GET /v1/connections/{id}/options` |
| `connections.delete(id)` | `DELETE /v1/connections/{id}` |
| `webhooks.createPortalLink({ frame_origin?, locale? })` | `POST /v1/webhooks/portal` |
| `billing.get()` | `GET /v1/billing` |
| `billing.createTopup({ amount_yen })` | `POST /v1/billing/topups` |
| `tools.getSecret()` | `GET /v1/tools/secret` |
| `tools.rotateSecret()` | `POST /v1/tools/secret/rotate` |

どのメソッドも、最後の引数で `{ timeout?, maxRetries?, signal? }` を渡せます。`billing.get()` は組織の残高・オートチャージの設定・1 分の値段・支払い方法を返し、`billing.createTopup()` はチャージの URL（利用者がブラウザで開いて払う）を返します。オートチャージ・月の上限・カードの変更は管理画面で行います。

### 通話のページ送り

`await` すると 1 ページ（`{ data, has_more, next_cursor }`）です。続きは `next_cursor` を `cursor` に渡して読むか、`for await` ですべての通話を新しい順にたどります。

```ts
const { data, has_more } = await voicast.calls.list({ tenant_id: 'ten_…', limit: 100 });

for await (const call of voicast.calls.list({ tenant_id: 'ten_…' })) {
  console.log(call.id, call.outcome);
}
```

## エラー

どのエラーも `VoicastError` を継ぎ、`status`・`code`（`invalid_request`・`in_use`・`balance_depleted` など）・`message`・`details` を持ちます。

| クラス | いつ |
| --- | --- |
| `AuthenticationError` | 401: API キーが無い・違う・取り消し済み |
| `PaymentRequiredError` | 402: 新しい通話を断っている（`balance_depleted`・`payment_overdue`・`spending_cap_reached`） |
| `PermissionError` | 403 |
| `NotFoundError` | 404 |
| `ConflictError` | 409: `external_id` の重複・`in_use`・`tenant_disabled`・`needs_reauth` |
| `ValidationError` | 400・422。`details` に項目ごとの誤り |
| `RateLimitError` | 429（送り直したあと）。`retryAfter` は秒 |
| `ApiError` | ほかの応答（5xx を含む） |
| `ConnectionError`・`TimeoutError` | 応答が無い・`timeout` に達した |
| `WebhookVerificationError` | `verifyWebhook`・`verifyToolCall` の失敗 |

```ts
import { PaymentRequiredError } from 'voicast';

try {
  await voicast.sessions.create({ tenant_id });
} catch (e) {
  if (e instanceof PaymentRequiredError && e.code === 'balance_depleted') showTopUpNotice();
  else throw e;
}
```

## 送り直しと時間切れ

既定は `new Voicast(key, { timeout: 30_000, maxRetries: 2 })` です。429 はどの要求も `retry-after` だけ待って送り直します（1 分まで）。5xx・時間切れ・接続の失敗は、繰り返しても安全な要求（GET・PATCH・DELETE）だけを送り直します。会話の設定・テナント・セッション・連携の作成、会話の設定の変更、鍵の作り直しは送り直さないので、2 度実行されることはありません。

## ほかの言語

Python などの SDK は今後用意します。それまでは [API](https://voicast.jp/docs/api.md) を直接呼び、署名の検証には Standard Webhooks の公式ライブラリを使ってください（[結果の受け取り](https://voicast.jp/docs/results.md#署名の検証)）。
