ページの一覧

ドキュメント

SDK

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

Markdown で見る

SDK は公開の準備中です(0.1.0)。npm に出るまでは、API を直接呼んでください。

  • 依存するパッケージはありません(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 など)に話させるときに使います。

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 度入らないよう、これで重複を見分けます(通話中の道具)。

メソッド#

メソッド 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 を直接呼び、署名の検証には Standard Webhooks の公式ライブラリを使ってください(結果の受け取り)。