ClawOps Docs

VoiceML

XML 기반 통화 제어 마크업 언어. 요청/응답 형식과 Say, Play, Gather, Record, Dial, Connect, Hangup, Redirect 태그 레퍼런스.

개요

ClawOps 번호로 수신 전화가 들어오면, 플랫폼은 설정된 웹훅 URL로 HTTP 요청을 전송합니다. 서버는 VoiceML(XML)로 응답하여 통화 흐름을 제어합니다.

VoiceML은 TwiML 호환 형식으로, 기존 TwiML 기반 코드를 최소한의 수정으로 사용할 수 있습니다.

동사별 검증 상태

각 동사의 운영 환경 실통화 검증 상태입니다. 미검증 항목은 자체 테스트 후 사용 권장.

동사상태검증된 속성 / 동작
<Say>✅ 검증 완료language="ko-KR", 본문 텍스트 TTS 재생
<Play>⚠️ 미검증
<Gather>✅ 검증 완료numDigits (1/4/6), timeout (inter-digit reset, 부분 입력 전달), action (POST redirect), nested <Say>, 기본 finishOnKey="#", Digits 응답 파라미터
<Record>✅ 검증 완료maxLength, finishOnKey="#", playBeep="true", action, 응답 파라미터 RecordingUrl (24h GCS 서명 URL) / RecordingDuration / Digits
<Dial>✅ 부분 검증<Number> noun, timeout, action, DialCallStatus 분기 중 no-answer / failed → action redirect 검증. completed / busy / callerId 표시는 미검증
<Connect><Stream>✅ 검증 완료url (wss://), 기본 inbound track, WebSocket 프로토콜 (connected/start/media/stop), μ-law 8k 양방향 오디오
<Hangup>✅ 검증 완료통화 즉시 종료, 후속 동사 실행 중단
<Reject>✅ 검증 완료reason="busy|rejected". 응답의 첫 verb 일 때 answer 전 거절 → SIP 486/603 + 통화료·AI 비용 0
<Redirect>✅ 검증 완료 (간접)Gather/Dial action 경로를 통한 동기적 next-URL fetch + 응답 VoiceML 실행
<Pause>⚠️ 미검증

요청 형식

POST 요청 (기본) — Content-Type: application/x-www-form-urlencoded 파라미터가 요청 본문(body)으로 전송됩니다.

GET 요청 — 파라미터가 쿼리 문자열(query string)으로 전송됩니다.

번호 설정에 서명 키가 등록되어 있으면 POST/GET 모두 X-Signature 헤더가 포함됩니다. 서명 검증을 통해 요청의 무결성을 확인할 수 있습니다.

요청 파라미터

파라미터타입필수설명
CallIdstring필수통화 고유 ID (예: CA1a2b3c...)
AccountIdstring필수계정 ID (예: AC...)
Fromstring필수발신 번호 (예: 01012345678)
Tostring필수수신 번호 — ClawOps 번호 (예: 07012340001)
CallStatusstring필수통화 상태 (예: in-progress)
Directionstring필수통화 방향 (예: inbound)

POST vs GET

POST https://your-server.com/voice HTTP/1.1
Content-Type: application/x-www-form-urlencoded
X-Signature: abc123...

CallId=CA...&AccountId=AC...&From=010...&To=070...&CallStatus=in-progress&Direction=inbound
GET https://your-server.com/voice?CallId=CA...&AccountId=AC...&From=010...&To=070...&CallStatus=in-progress&Direction=inbound HTTP/1.1
X-Signature: abc123...

응답 형식

웹훅 서버는 Content-Type: application/xml 헤더와 함께 VoiceML XML을 반환해야 합니다. 모든 응답은 <Response> 루트 엘리먼트로 시작합니다.

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">안녕하세요.</Say>
  <Gather timeout="5" action="https://your-server.com/gather">
    <Say language="ko">서비스를 선택하세요. 1번 상담, 2번 안내</Say>
  </Gather>
  <Say language="ko">응답이 없습니다.</Say>
  <Hangup/>
</Response>

동사 레퍼런스

<Say>

텍스트를 음성으로 읽어줍니다 (TTS).

파라미터타입필수설명
languagestring선택음성 언어 (ko, en, ja). 기본값: ko

language 속성은 ko(한국어), en(영어), ja(일본어)를 지원합니다. 생략 시 한국어가 기본 적용됩니다.

<Say language="ko">안녕하세요. 고객센터입니다.</Say>

<Play>

오디오 파일을 재생하거나 DTMF 톤을 송출합니다. URL 은 본문 텍스트로 지정 (속성이 아님).

파라미터타입필수설명
loopinteger선택반복 재생 횟수. 0 이면 무한 반복. 기본값: 1
digitsstring선택DTMF 톤 송출 모드. 본문 URL 대신 이 문자열의 각 키를 차례로 송출. 'w' = 500ms 대기, 'W' = 1000ms 대기
<Play>https://example.com/welcome.wav</Play>
<Play loop="2">https://example.com/beep.wav</Play>
<Play digits="1w2w3#"/>

<Gather>

DTMF 입력(키패드)을 수집합니다. <Say>, <Play>를 중첩할 수 있습니다.

파라미터타입필수설명
timeoutinteger선택inter-digit 대기 시간 (초). 매 입력마다 리셋. 기본값: 5
numDigitsinteger선택수집할 자릿수. 도달 시 즉시 종료. 미설정 시 finishOnKey 또는 timeout 까지 무제한 수집
finishOnKeystring선택입력 완료 키. 누르면 즉시 종료되며 Digits 에 포함되지 않음. 기본값: '#'. 빈 문자열이면 비활성화
actionstring선택입력 완료 후 요청할 콜백 URL (POST). Digits 가 비어있거나 timeout 시 호출되지 않음
<Gather timeout="5" numDigits="1" action="/handle-input">
  <Say language="ko">1번 상담, 2번 안내, 3번 기타</Say>
</Gather>

Gather 완료 시 action URL로 POST 요청이 전송되며, Digits 파라미터에 사용자 입력값이 포함됩니다.

<Record>

발신자 발화를 별도 파일로 녹음합니다. 통화 전체 녹음(MixMonitor)과 독립된 경로.

파라미터타입필수설명
maxLengthinteger선택최대 녹음 시간 (초). 기본값: 60
finishOnKeystring선택녹음 종료 키. 기본값: '#'
playBeepboolean선택녹음 시작 전 비프음 재생. 기본값: false
actionstring선택녹음 완료 후 요청할 URL

녹음 완료시 action URL 로 다음 파라미터가 추가 전송됩니다:

파라미터타입필수설명
RecordingUrlstring필수녹음 파일의 24시간 유효 서명 URL (GCS). 그대로 GET 으로 다운로드 가능
RecordingDurationstring필수녹음 길이 (초). 0 이면 미녹음
Digitsstring선택녹음을 종료시킨 키 (finishOnKey 와 일치). 시간 초과로 종료되면 빈 문자열
<Record maxLength="60" finishOnKey="#" playBeep="true" action="https://your-server.com/voicemail-done"/>

<Dial>

외부 번호로 전화를 연결합니다. 현재 <Number> noun 만 지원하며 본문은 한국 국내 전화번호 형식으로 자동 정규화됩니다. callerId 는 계정 소유 번호로만 설정 가능 (spoofing 방지).

파라미터타입필수설명
callerIdstring선택발신자 번호 표시 — 계정 소유 번호여야 함. 미소유 번호 지정시 dial 실패 (DialCallStatus=failed)
timeoutinteger선택연결 대기 시간 (초). 기본값: 30
actionstring선택Dial 종료 후 요청할 URL (POST)

action 이 설정된 경우 다음 파라미터가 추가 전송됩니다:

파라미터타입필수설명
DialCallStatusstring필수Dial 결과 — completed / busy / no-answer / failed / canceled
<Dial callerId="07012340001" timeout="30" action="https://your-server.com/after-dial">
  <Number>01012345678</Number>
</Dial>

<Sip> noun 은 현재 미지원입니다. SIP URI 발신이 필요하면 별도로 문의해 주세요.

<Connect>

WebSocket Stream을 연결하여 실시간 양방향 오디오를 처리합니다. AI Agent 연동에 주로 사용됩니다.

하위에 <Stream> 엘리먼트를 포함하며, <Stream><Parameter> 자식을 가질 수 있습니다.

Stream 속성

파라미터타입필수설명
urlstring필수WebSocket 서버 URL (wss://)
trackstring선택스트리밍할 오디오 트랙 (inbound, outbound, both)

Parameter 속성

파라미터타입필수설명
namestring필수파라미터 이름
valuestring필수파라미터 값
<Connect>
  <Stream url="wss://your-server.com/stream" track="inbound">
    <Parameter name="userId" value="123"/>
    <Parameter name="language" value="ko"/>
  </Stream>
</Connect>

Stream 프로토콜에 대한 자세한 내용은 Stream WebSocket 문서를 참고하세요.

<Hangup>

통화를 종료합니다. 속성이 없습니다.

<Hangup/>

<Reject>

수신 통화를 거절합니다.

  • 응답의 첫(유일) verb 가 <Reject> 일 때: 통화를 받지 않고(answer 전) 거절합니다. 발신자에게 실제 SIP 응답이 나가고, 통화료·AI 비용이 발생하지 않습니다. 발신번호 기반 스팸/무효 통화 필터링에 사용합니다.
  • 앞에 다른 verb(<Say> 등)가 있으면: 이미 통화를 받은 뒤라 <Hangup> 과 동일하게 단순 종료됩니다(과금됨).

reason 속성으로 발신자 단말에 보일 응답을 지정합니다:

reason발신자 SIP 응답의미
rejected (기본)603 Decline거절
busy486 Busy Here통화중
<Response>
  <Reject reason="busy"/>
</Response>

<Pause>

지정된 시간만큼 대기합니다.

파라미터타입필수설명
lengthinteger선택대기 시간 (초). 기본값: 1
<Pause length="2"/>

<Redirect>

다른 URL로 요청을 리다이렉트하여 새로운 VoiceML을 가져옵니다. 엘리먼트 텍스트에 URL을 포함합니다. 호출은 항상 POST 입니다.

<Redirect>https://your-server.com/next-step</Redirect>

중첩 규칙

부모 엘리먼트허용되는 자식 엘리먼트
<Gather><Say>, <Play>
<Dial><Number>
<Connect><Stream>
<Stream><Parameter>
<Say>, <Play>, <Record>, <Hangup>, <Reject>, <Pause>, <Redirect>중첩 불가

중첩 규칙을 따르지 않는 XML은 파싱 오류가 발생합니다. 각 동사에 허용된 자식 엘리먼트만 사용하세요.

서버 구현 예제

Webhook 엔드포인트를 구현하여 전화를 수신하고 VoiceML로 응답하는 예제입니다.

Python (Flask)

from flask import Flask, request

app = Flask(__name__)

@app.route("/voice", methods=["POST"])
def handle_call():
    call_id = request.form.get("CallId")
    from_number = request.form.get("From")
    return """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">안녕하세요. ClawOps에 연결되었습니다.</Say>
  <Hangup/>
</Response>""", 200, {"Content-Type": "application/xml"}

로컬 개발 시 ngrok 등의 터널링 도구를 사용하면 외부에서 로컬 서버로 Webhook을 전달받을 수 있습니다. Webhook 없이 AI 에이전트로 전화를 처리하려면 Voice Agent를 참고하세요.

전체 예제: 통신사 콜센터 IVR

본인확인 → 다단계 메뉴 → AI 상담사 스트림 / 상담원 Dial / 음성사서함 분기까지 포함하는 실제 동작하는 예제입니다. 운영 환경에서 E2E 검증된 흐름입니다.

통화 흐름

[발신]
  ↓ POST /ivr/enter
인사 + 녹음 안내 + 언어선택 (Gather numDigits=1)
  ↓ Digits=1
본인확인 1단계 (Gather numDigits=4)
  ↓ Digits=1234
본인확인 2단계 (Gather numDigits=6)
  ↓ Digits=900101
메인 메뉴 (Gather numDigits=1)
  ├─ 1: 요금조회       → <Connect><Stream wss://…/>
  ├─ 2: 데이터/부가서비스 → 서브메뉴
  ├─ 3: 분실/도난     → <Dial> 상담원
  ├─ 0: 상담원 연결    → <Dial> 상담원 → busy 시 음성사서함
  └─ *: 메뉴 재안내

음성사서함 → <Record finishOnKey="#"> → action callback (RecordingUrl)

1. 진입점 + 본인확인

import express from 'express';
const app = express();
app.use(express.urlencoded({ extended: false }));

const BASE = 'https://your-server.com/ivr';
const xml = (body) => `<?xml version="1.0" encoding="UTF-8"?>\n<Response>${body}</Response>`;
const sendXml = (res, body) => res.type('xml').send(xml(body));

// 진입점
app.post('/ivr/enter', (req, res) => {
  sendXml(res, `
    <Say language="ko-KR">안녕하세요. 상담 품질 향상을 위해 통화 내용이 녹음됩니다.</Say>
    <Gather numDigits="1" timeout="5" action="${BASE}/lang">
      <Say language="ko-KR">한국어는 1번, English press 2.</Say>
    </Gather>
    <Redirect>${BASE}/enter</Redirect>
  `);
});

// 언어 선택
app.post('/ivr/lang', (req, res) => {
  if (req.body.Digits === '2') {
    return sendXml(res, `<Say language="en-US">English service unavailable.</Say><Hangup/>`);
  }
  sendXml(res, `<Redirect>${BASE}/verify</Redirect>`);
});

// 본인확인 1단계: 휴대폰 뒷 4자리 (numDigits=4 라 # 안눌러도 4자리 채우면 즉시 진행)
app.post('/ivr/verify', (req, res) => {
  sendXml(res, `
    <Gather numDigits="4" timeout="8" action="${BASE}/verify-2">
      <Say language="ko-KR">가입하신 휴대폰 뒷 네 자리를 누르세요.</Say>
    </Gather>
    <Redirect>${BASE}/enter</Redirect>
  `);
});

// 본인확인 2단계: 생년월일 6자리
app.post('/ivr/verify-2', (req, res) => {
  // req.body.Digits = 휴대폰 뒷 4자리. 세션/Redis 에 보관 후 DB 조회.
  sendXml(res, `
    <Gather numDigits="6" timeout="10" action="${BASE}/menu">
      <Say language="ko-KR">생년월일 여섯 자리를 누르세요.</Say>
    </Gather>
    <Redirect>${BASE}/enter</Redirect>
  `);
});

2. 메인 메뉴 + 라우팅

app.post('/ivr/menu', (req, res) => {
  sendXml(res, `
    <Gather numDigits="1" timeout="6" action="${BASE}/route">
      <Say language="ko-KR">
        본인 확인이 완료되었습니다.
        요금 조회는 1번, 데이터 서비스는 2번, 분실 신고는 3번,
        상담원 연결은 0번, 메뉴를 다시 들으시려면 별표를 눌러주세요.
      </Say>
    </Gather>
    <Redirect>${BASE}/menu</Redirect>
  `);
});

app.post('/ivr/route', (req, res) => {
  const d = req.body.Digits;

  if (d === '1') {
    // 요금조회 AI 상담사 — WebSocket 스트림 연결
    return sendXml(res, `
      <Say language="ko-KR">AI 상담사에게 연결합니다.</Say>
      <Connect>
        <Stream url="wss://your-server.com/ai/billing">
          <Parameter name="topic" value="billing"/>
        </Stream>
      </Connect>
    `);
  }

  if (d === '3') {
    // 분실/도난 — 즉시 상담원 Dial
    return sendXml(res, `
      <Say language="ko-KR">분실 신고는 즉시 상담원에게 연결됩니다.</Say>
      <Dial timeout="30" action="${BASE}/after-dial" callerId="07012340001">
        <Number>01099991111</Number>
      </Dial>
    `);
  }

  if (d === '0') {
    return sendXml(res, `
      <Dial timeout="30" action="${BASE}/after-dial" callerId="07012340001">
        <Number>01099991111</Number>
      </Dial>
    `);
  }

  // '*' 또는 기타 → 메뉴 재안내
  return sendXml(res, `<Redirect>${BASE}/menu</Redirect>`);
});

3. Dial 결과 분기 + 음성사서함

app.post('/ivr/after-dial', (req, res) => {
  // DialCallStatus: completed / busy / no-answer / failed / canceled
  if (req.body.DialCallStatus === 'completed') {
    return sendXml(res, `<Hangup/>`);
  }
  // busy / no-answer / failed → 음성사서함으로
  sendXml(res, `
    <Say language="ko-KR">현재 상담원이 모두 통화 중입니다. 신호음 후 메시지를 남겨주세요.</Say>
    <Redirect>${BASE}/voicemail</Redirect>
  `);
});

app.post('/ivr/voicemail', (req, res) => {
  sendXml(res, `
    <Record maxLength="60" finishOnKey="#" playBeep="true" action="${BASE}/voicemail-done"/>
    <Say language="ko-KR">메시지가 저장되지 않았습니다.</Say>
    <Hangup/>
  `);
});

app.post('/ivr/voicemail-done', (req, res) => {
  // req.body.RecordingUrl     — 24시간 유효 GCS 서명 URL (그대로 GET 다운로드 가능)
  // req.body.RecordingDuration — 녹음 길이 (초)
  // req.body.Digits            — 녹음을 종료시킨 키 (#)
  console.log('voicemail saved:', {
    callId: req.body.CallId,
    url: req.body.RecordingUrl,
    duration: req.body.RecordingDuration,
  });
  // 실제 운영에서는 RecordingUrl 을 본인 스토리지로 24h 안에 복사해 영구 보관 권장
  sendXml(res, `
    <Say language="ko-KR">메시지가 접수되었습니다. 빠른 시일 내에 연락드리겠습니다.</Say>
    <Hangup/>
  `);
});

4. AI 상담사 — WebSocket Stream

<Connect><Stream> 으로 ClawOps 가 wss:// 서버에 연결합니다. μ-law 8kHz base64 프레임을 양방향 주고받으며, 통화 끊기까지 유지됩니다.

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 9000, path: '/ai/billing' });

wss.on('connection', (ws) => {
  let streamSid, callSid;

  ws.on('message', (raw) => {
    const msg = JSON.parse(raw.toString());

    if (msg.event === 'start') {
      streamSid = msg.start.streamId;
      callSid = msg.start.callId;
      // STT/LLM 세션 시작
      return;
    }

    if (msg.event === 'media') {
      const inboundAudio = Buffer.from(msg.media.payload, 'base64'); // G.711 μ-law
      // STT 로 전사 → LLM 응답 생성 → TTS μ-law 로 인코딩 → 아래처럼 전송
      // ws.send(JSON.stringify({
      //   event: 'media',
      //   streamSid,
      //   media: { payload: ttsOutputBase64 }
      // }));
    }

    if (msg.event === 'stop') {
      // 세션 정리
    }
  });
});

자세한 메시지 포맷은 Stream WebSocket 프로토콜 참고.

시스템 한계

  • 상담사 대기열 (<Queue> / <Enqueue>) 미지원 — busy 시 음성사서함으로 우회
  • 3자 통화 / Conference 미지원
  • <Sip> noun 미지원 — Dial 은 <Number>
  • Gather input="speech" 미지원 — DTMF only