자체 Agent 연동
내 챗봇을 번호에 바인딩해 전화로 말하게 하는 chat wire 규격.
번호에 자체 Agent를 바인딩하면 gateway는 전화 회선 · 음성 인식 · 음성 합성만 맡고, 무슨 말을 할지는 여러분의 엔드포인트가 정합니다. 수신과 발신을 각각 다른 유형으로 붙일 수 있습니다.
Agent 유형 고르기
번호 관리 › 번호 › 인바운드 또는 아웃바운드 탭의 "Agent 연결"에서 유형과 chat URL을 저장합니다.
| agent_type | 무엇 | 수신에 저장되는 값 | 발신에 저장되는 값 |
|---|---|---|---|
webhook | 일반 webhook | chat URL | chat URL |
openai_compatible | OpenAI 호환 | chat URL | chat URL |
n8n | n8n | chat URL | chat URL |
langgraph | LangGraph | chat URL · Assistant ID | chat URL · Assistant ID |
mastra | Mastra | chat URL | chat URL · Agent ID |
chat URL은 https만 허용합니다.
수신에서는 mastra의 Agent ID가 저장되지 않습니다. 콘솔에 입력 칸이 보여도 저장 시 비워지고,
수신 통화는 항상 기본 agent(voiceAssistant)로 연결됩니다. 특정 agent를 지정하려면 chat URL을
그 agent로 향하게 하거나 발신 바인딩을 쓰세요. (Assistant ID를 저장하는 유형은 수신에서
langgraph 하나뿐입니다.)
유형을 고르지 않거나 chat URL이 비어 있으면 발신은 아예 전화를 걸지 않고 422
no_agent_endpoint로 거절합니다. 수신은 통화를 거절합니다.
연결 규격
아래는 여러분이 호출하는 API가 아닙니다. 통화 중에 gateway가 여러분의 chat URL로 보내는 요청의 모양입니다 — 여러분 서버는 이걸 받아서 파싱하고 답을 돌려주면 됩니다.
수신·발신이 같은 어댑터 wire를 씁니다 — 한 번 구현하면 양방향에 그대로 씁니다.
일반 webhook
gateway가 chat URL로 POST {call_id, session_id, message, locale}를 보내고 {reply}를 받습니다.
POST {chat_url}
Content-Type: application/json
{"call_id":"…","session_id":"…","message":"…","locale":"ko","vox":{…}}
→ {"reply":"…"}통화 컨텍스트는 요청 본문 최상위 vox에서 읽습니다 — const { call_id, from, language } = req.body.vox;
OpenAI 호환
gateway가 {chat_url}/chat/completions로 messages 배열을 누적 전송합니다.
POST {chat_url}/chat/completions
Authorization: Bearer <session_token>
X-Vox-Call-Id: …
{"messages":[…],"stream":true}통화 컨텍스트는 X-Vox-* 요청 헤더 (본문은 그대로)에서 읽습니다 — const callId = req.headers['x-vox-call-id'];
n8n
n8n Chat Trigger의 webhook URL을 chat URL로 등록합니다. 응답은 output · text · message 필드에서 추출됩니다.
POST {chat_url}
{"action":"sendMessage","sessionId":"<call_id>","chatInput":"…","metadata":{"vox":{…}}}
→ {"output":"…"}통화 컨텍스트는 metadata.vox에서 읽습니다 — {{ $json.metadata.vox.call_id }}
LangGraph
chat URL에 LangGraph 서버 베이스(http://host:2024)를, Agent ID에 그래프 이름을 입력하세요. 통화 시작에 thread(= call_id)를 만들고 SSE로 스트리밍됩니다.
POST {chat_url}/threads
{"thread_id":"<call_id>","if_exists":"do_nothing"}
POST {chat_url}/threads/{call_id}/runs/stream
{"assistant_id":"<그래프명>","input":{"messages":[…]},"stream_mode":"messages","config":{"configurable":{"vox":{…}}}}통화 컨텍스트는 config.configurable.vox에서 읽습니다 — def node(state, config): vox = config['configurable']['vox']
Mastra
chat URL에 Mastra 서버 베이스(http://host:4111)를, Agent ID에 등록한 agent 이름을 입력하세요. SSE로 스트리밍됩니다.
POST {chat_url}/api/agents/{agentId}/stream
Authorization: Bearer <session_token>
{"messages":[…],"requestContext":{"vox":{…}}}통화 컨텍스트는 requestContext.vox에서 읽습니다 — instructions: async ({ requestContext }) => requestContext.get('vox')
아웃바운드 kickoff
발신 통화에서는 상대가 받는 즉시 gateway가 아래 형식의 첫 user 턴(kickoff)을 보냅니다. 상대는 아직 아무 말도 하지 않았으므로, 여러분의 agent가 이 턴을 받아 먼저 말해야 합니다.
[통화 시작 — 발신] 당신이 먼저 말합니다. 상대는 아직 아무 말도 하지 않았습니다.
[임무] {instruction}- 첫 user 턴이 kickoff입니다 — 상대는 아직 아무 말도 하지 않았고, 당신의 agent가 이 턴을 받아 먼저 말해야 합니다.
- 임무(instruction)는 kickoff 턴으로만 전달됩니다 — 자연어 지시가 오는 채널은 이 턴 하나뿐입니다.
- 통화 식별 정보(call_id·번호·언어)는 매 요청에 함께 옵니다 — 아래 '통화 컨텍스트' 참고. kickoff 턴에도 이미 실려 있으므로 첫 마디를 만들 때부터 쓸 수 있습니다.
- 발신은 REST API(POST /api/v1/calls)를 사용하세요 — 착신번호(to)만 필수이고 instruction은 선택입니다 (agent가 이미 임무를 알고 있는 경우 생략).
- 무음·최대 통화시간은 플랫폼 정책이지만, 통화 종료는 agent가 할 수 있습니다 — 아래 '통화 종료 (end_call)' 참고.
- 인증: gateway가 모든 요청에 Authorization: Bearer <에이전트 인증 토큰>을 부착합니다. 토큰은 번호 설정에서 저장·발급합니다.
자연어 지시가 오는 채널은 kickoff 턴 하나뿐입니다 — 통화별로 달라지는 지시는 발신 요청의
instruction에 담으세요. 통화 식별 정보는 아래 "통화 컨텍스트"가 따로 나릅니다.
통화 컨텍스트
여러분의 agent가 지금 어느 통화에 붙어 있는지 알려주는 값입니다. 이 값의 call_id로 통화
조회·전사본·녹음·종료 API를 부를 수 있습니다.
| 키 | 수신(inbound) | 발신(outbound) |
|---|---|---|
call_id | 통화 id | 통화 id |
direction | "inbound" | "outbound" |
from | 발신자 번호 | 내 번호 |
to | 내 번호 | 착신자 번호 |
language | 통화 언어 | 통화 언어 |
읽는 자리는 유형마다 다릅니다 — 위 "연결 규격"의 각 유형 설명을 보세요. 네 유형은 요청 본문의
vox 객체로 오고, openai_compatible만 요청 헤더로 옵니다(그 형식은 본문에 임의 키를
넣으면 거절하는 서버가 있어 본문을 건드리지 않습니다).
X-Vox-Call-Id: …
X-Vox-Direction: …
X-Vox-From: …
X-Vox-To: …
X-Vox-Language: …- 수신·발신 양방향, 유형 5종 모두에 전달됩니다. 읽지 않아도 됩니다 — 기존 연결은 그대로 동작합니다.
- 매 요청에 다시 실립니다. 세션 시작 한 번이 아니므로, 엔드포인트가 상태를 저장해 둘 필요가 없습니다.
- 통화가 끝날 때까지 값이 바뀌지 않습니다 — 통화 시작 시점에 확정됩니다.
- from·to의 의미는 방향에 따라 뒤집힙니다. 상대방 번호는 수신이면 from, 발신이면 to입니다.
- 번호는 국내형 표기입니다(선행 0 유지, 하이픈 없음).
- 자연어(임무·지침)는 여기 실리지 않습니다 — 식별 정보만 담는 자리입니다.
요청 인증
gateway가 여러분의 엔드포인트로 보내는 요청에 Authorization 헤더를 붙일 수 있습니다. 다만
그 값의 출처가 방향마다 다릅니다 — 저장해 두는 토큰은 아웃바운드에만 있습니다.
POST {chat_url}
Authorization: Bearer {agent_token}
Content-Type: application/json| 아웃바운드 (발신) | 인바운드 (수신) | |
|---|---|---|
| 값의 출처 | 번호에 저장한 에이전트 인증 토큰 | 발신자 인증(handshake)이 돌려준 세션 토큰 |
| 설정하는 곳 | 번호 관리 › 번호 › 아웃바운드 › 아웃바운드 Agent | 번호 관리 › 번호 › 인바운드 › 인바운드 인증 |
| 헤더가 안 붙는 경우 | 토큰을 비워 뒀을 때 | 인증 방식이 handshake가 아닐 때 |
아웃바운드 토큰은 암호화 저장되며 다시 조회되지 않습니다(존재 여부만 표시). 엔드포인트 쪽에서는 이 값이 저장한 토큰과 같은지 확인하면 됩니다.
인바운드에는 토큰을 저장하는 칸이 없습니다. 수신 통화에 인증 헤더를 받고 싶다면 인증 방식을 handshake로 두고 그 인증 API가 세션 토큰을 내려주게 하세요 — 발신자마다 다른 값을 줄 수 있다는 것이 이 구조의 이유입니다.
통화 종료 (hangup)
대화가 끝났다고 판단하면 agent가 직접 통화를 끊을 수 있습니다. 표면은 REST 하나입니다.
curl -X POST https://vox-gateway-dev.fly.dev/api/v1/calls/{call_id}/hangup \
-H "Authorization: Bearer $VOX_API_KEY"
# → 202 {"call_id": "...", "status": "hangup_requested"}- 이 호출은 종료 “요청”입니다(응답 202) — 실제 종료 확정은 이벤트 Webhook의 call.ended로 관찰하세요. 사유는 end_reason=agent_hangup(이력엔 “AI 종료”).
- 즉시 끊기지 않습니다 — 작별 인사가 끝나면 끊깁니다.
- 인사 도중 상대가 다시 말하면 종료가 자동 취소되고 통화가 이어집니다 — 아직 할 말이 남은 상대를 끊지 않으려는 안전장치입니다.
- 작별 인사를 말한 뒤(또는 말하면서) 호출하세요 — 먼저 호출하고 침묵하면 인사가 안 들립니다.
- 거절돼도 통화를 끊지 마세요 — 사유를 받고 대화를 이어갑니다.
| HTTP | error | 뜻 |
|---|---|---|
| 409 | call_not_active | 통화가 진행 중이 아님 — 재시도 무의미. |
| 409 | room_unknown | 종료 신호를 보낼 방(room)을 못 찾음 — 재시도 무의미. |
| 404 | call_not_found | 내 통화가 아님(존재 여부도 감춤). |
| 502 | hangup_failed | 신호 전송 일시 실패 — 한 번 재시도 가능. |
| 503 | hangup_unavailable | gateway에 LiveKit 미설정 — 기능 off. |
거절돼도 통화는 살아 있습니다 — 사유를 받고 대화를 이어가세요.
전체 요청·응답 규격은 종료 레퍼런스를 보세요.