A minimal local webhook server for a Kakao i OpenBuilder chatbot "skill", exposed to the internet through a Cloudflare named tunnel.
Kakao ──HTTPS──▶ https://kakao-agent-local.vhh.sh/webhook/kakao
│ (Cloudflare named tunnel: kakao-agent-local)
▼
http://localhost:8788/webhook/kakao (this server)
POST /webhook/kakao reads the user's message from userRequest.utterance, asks an
LLM, and replies in Kakao's skill-response format:
{ "version": "2.0", "template": { "outputs": [ { "simpleText": { "text": "<reply>" } } ] } }Kakao requires version: "2.0", Content-Type: application/json, and HTTP 200. The
synchronous SLA is 5 seconds, which an LLM call easily blows past — so the server
uses Kakao's Callback:
- When Callback is enabled on the block, Kakao includes
userRequest.callbackUrl. The server immediately acks with{ "version": "2.0", "useCallback": true }(inside the 5s SLA; notemplate— the waiting message is configured in OpenBuilder). - It then runs the LLM and
POSTs the final skill response tocallbackUrl. That URL is valid up to 5 minutes and is single-use. - With no
callbackUrl(Callback off, or bot test) it answers synchronously, best-effort within 5s, falling back to a "couldn't answer in time" message.
Kakao renders up to 3 outputs (말풍선) per response. The LLM is told to separate bubbles with a blank line; the server splits the reply on blank lines, keeps fenced code blocks intact, merges >3 chunks into 3 (length-balanced, order preserved), and overflows an over-long (>1000-char) bubble into spare slots before truncating.
- Phase 1 (done): echo
userRequest.utteranceback. - Phase 2 (done): LLM reply via the Callback flow above, split into up to 3
bubbles. Falls back to
echo: …if noAI_API_KEYis configured.
Reads AI_API_KEY / AI_BASE_URL / AI_MODEL (falling back to OPENAI_*, then
opengateway defaults). KAKAO_SYSTEM_PROMPT overrides only the persona — the
bubble-splitting rules are always appended. See .env.example. An
OpenAI-compatible /chat/completions endpoint is expected.
pnpm install
# both server + tunnel together
pnpm dev
# or individually
pnpm dev:server # tsx watch, localhost:8788
pnpm dev:tunnel # cloudflared named tunnelLocal smoke test:
# Sync (no callbackUrl) — best-effort within 5s
curl -s localhost:8788/webhook/kakao \
-H 'content-type: application/json' \
-d '{"userRequest":{"utterance":"1+1은?"}}'
# {"version":"2.0","template":{"outputs":[{"simpleText":{"text":"1+1은 2입니다."}}]}}
# Callback — point callbackUrl at any local sink; the server acks immediately,
# then POSTs the LLM answer to that URL within ~5 min.
curl -s localhost:8788/webhook/kakao \
-H 'content-type: application/json' \
-d '{"userRequest":{"utterance":"길게 설명해줘","callbackUrl":"http://127.0.0.1:9911/cb"}}'
# {"version":"2.0","useCallback":true}- Tunnel name:
kakao-agent-local - Hostname:
kakao-agent-local.vhh.sh - Cloudflare account: the one that owns the
vhh.shzone - Config:
cloudflared/config.yml(ingress →http://localhost:8788)
Created with:
cloudflared tunnel create kakao-agent-local
cloudflared tunnel route dns kakao-agent-local kakao-agent-local.vhh.shThe credentials JSON lives at ~/.cloudflared/<tunnel-uuid>.json (git-ignored).