One engine.
Three ways in.
HawkTalk is an OpenAI-compatible REST and Realtime voice AI API, served from our own accelerator fleet. Point an existing SDK at it and it works. This page goes in the order you should actually adopt it: REST, then WS, then HawkTalkLive.
What this is
Three rails, one gateway process, one model registry, one API-key system. The wire protocol is deliberately the one you already know — same request shapes, same response shapes, same event names — with extra fields that report what actually happened on the turn rather than what the brochure says.
Two rules run through everything below, and they will save you debugging time:
- Unknown is
null, never a fabricated zero. If the backend can't report a token count or a timing, you getnullor the string"unknown". Handle it. We would rather break your integer parse than lie to your dashboard. - Unwired seams fail loud. A node without speech wired answers
501 stt_not_wiredso your client can fall back to the browser's Web Speech API. You will never receive fake audio or an invented transcript.
First: know which node you're on
HawkTalk is not one server. It is a family of nodes that speak deliberately similar protocols on different hardware, and the differences will bite you if nobody tells you. Check GET /health (or /healthz) before you debug anything else.
| Node | Port | Auth | What it is |
|---|---|---|---|
| Product gateway | 8443 | HawkNest HNK1- | Cloud GPU node. Chat, TTS, STT. Crisis gate and billing live here. |
| Node REST | 8890 | sk- | On-device / NPU node. The full OpenAI-compatible surface below. |
| Node Realtime | 8891 | sk- | The WebSocket voice lane. A separate process from REST. |
| AIC runtime | 8900 | sk- | Qualcomm AI-100 fleet, with seat and drain control. |
:8443 uses HawkNest keys (HNK1-…, product-scoped, offline ECDSA-verified) — not the sk- keys the node surfaces use. The two are not interchangeable. And on that gateway the caller's model field is ignored: an intent router picks the model for you, so pinning a model there does nothing. Model selection works normally on the node surfaces described below.Pick your rail
If you are unsure, start at REST. Moving up a rail is additive — the model registry, the keys, and the tool definitions carry over unchanged.
What model:"auto" actually picks between
You do not have to care about this to use HawkTalk — auto works, and GET /v1/models lists whatever a given node actually has. But the ladder is worth understanding, because it explains why cost per turn is near zero and why pinning a model is sometimes the right call.
A small always-on router reads every turn and places it on the cheapest rung that can answer it correctly, escalating rather than degrading when it can't.
| Rung | Model | Size on disk | What it takes |
|---|---|---|---|
| router | ouromega — Gemma3-270M + LoRA | 253 MB Q4_K_M | Reads the turn, emits SELF · QUICK · DANK · CLOUD |
| SELF | ouromega answers directly | — | Acknowledgements, backchannel, trivia |
| QUICK | hawkalphaquick — Gemma-4 E2B | 2.29 GB Q4_K_M | The workhorse. Chat, quick reasoning, audio in |
| DANK | hawkalphadank — Gemma-4 E4B | 3.69 GB Q4_0 | Longer instructions, code, summaries |
| CLOUD | Frontier model | — | Only what genuinely needs it |
The router is the load-bearing piece, so its numbers are published: 99.1% routing accuracy (109/110) and 100% valid format (110/110) on the distillation gate, running at ~104 tok/s generation on ordinary CPU. Prompting a stock 270M for the same job scored 0/10 — the routing behaviour is distilled in, not prompted.
Function-calling models
Tool calling is a fine-tune, not a prompt trick, so different surfaces get different function-callers:
| Model | Runs on | Format | Measured |
|---|---|---|---|
fc-quick | Server / NPU | 2.22 GB Q4_0 | 71% raw, 79% grammar-constrained across 7 categories |
oak | NPU premium tier | 3.18 GB Q4_K_M | ~27 tok/s on Hexagon; teacher for feedseed |
feedseed | Browser, WebGPU | 310 MB ONNX fp16 | A/B 25/25; hiking suite 97.9% |
sakura-1.5b | CPU / Vulkan | 941 MB Q4_K_M | 70.8% exact under GBNF grammar; Apache-2.0 |
fc-quick scored 100% on negatives — it does not invent tool calls when no tool applies, which is the failure mode that actually costs you money in production. And the gap to a frontier model was diagnosed as format, not reasoning: constraining output with a grammar moved parallel-call accuracy from 50% to 83%. If your tool calls matter, run them grammar-constrained.feedseed at 270M scores 25/25 at fp16 and 4/25 at 4-bit — a 270M has no weight redundancy left for round-to-nearest quantisation. The browser build ships fp16 at 310 MB deliberately, and falls back to a deterministic rule parser rather than to a quantised model.Speech
STT is local whisper.cpp — base.en (148 MB) or tiny.en (78 MB); base.en is worth the extra memory, since tiny.en demonstrably mishears domain phrases. TTS is Kokoro-82M ONNX with 14 voice packs (af_heart is the default; af_bella, am_puck, bm_fable, bf_isabella and others ship alongside), with Piper as a fallback engine. Sherpa-onnx streaming zipformer is available for fully on-device STT on Android.
Hello world in sixty seconds
Default bind is loopback: 127.0.0.1:8890 for REST, :8891 for the WebSocket. Set SERVERPAL_HOST and the port env vars to expose a node beyond localhost.
curl -s http://127.0.0.1:8890/v1/chat/completions \
-H "Authorization: Bearer $HAWKTALK_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "auto",
"messages": [{"role": "user", "content": "Say hello in five words."}],
"max_tokens": 64
}'
You get the OpenAI response shape — choices[0].message.content, usage — plus x_timing and x_compute. model: "auto" lets the router place the turn on the cheapest tier that answers it correctly.
Keys and authentication
REST authenticates with a bearer key and nothing else:
Authorization: Bearer sk-...
Only a SHA-256 hash of your key is stored at rest; presented keys are hashed and matched, and revoked keys are skipped at load. On the WebSocket you have three options, tried in order — the same Authorization header, an ?api_key= query param on the upgrade URL, or the browser-friendly subprotocol openai-insecure-api-key.<key>. Browsers can't set headers on a WebSocket, which is why the third exists.
GET /health reports auth: "enabled" or auth: "OPEN (no keys configured)", unauthenticated, so you can always check.Unauthenticated routes: GET /health, the built-in browser chat client at GET /, /favicon.ico, and CORS preflight on any path.
1 · REST — the workhorse
Synchronous generation and function calling over the HTTP your stack already speaks. No sockets to hold open, no session to manage, no SDK you have to adopt. Scale it like any other HTTP service.
The upgrade over an ordinary completions endpoint is what sits behind it: the same tiered router that drives the live lane. A request arrives, the router places it at the lowest tier that gets it right, and the response tells you where it went.
POST /v1/chat/completions shipped
Standard OpenAI chat shape in, standard shape out. Two extras:
x_timing—ttft_ms,decode_tps,total_tps,total_ms,server_tps,prompt_tokens_source,backend,qnn_lineagex_compute— which compute path actually ran- Response headers
x-ttft-msandx-tpswhen known
usage.prompt_tokens and usage.total_tokens are null when the backend cannot report them, and timing fields can be the string "unknown". Clients expecting integers must handle both.Streaming (SSE)
curl -sN http://127.0.0.1:8890/v1/chat/completions \
-H "Authorization: Bearer $HAWKTALK_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"auto","stream":true,
"messages":[{"role":"user","content":"Two-line poem about hawks."}]}'
You get text/event-stream: chat.completion.chunk frames — a {role} delta first, then {content} deltas — then a final chunk carrying finish_reason, usage, x_timing and x_compute, then the literal data: [DONE].
Closing the connection cancels upstream generation — that is how a tab close or a voice barge-in stops work rather than orphaning it. A mid-stream backend fault arrives as an SSE {"error": {..., "type": "backend_error"}} frame and never as a silent truncation.
POST /v1/audio/transcriptions seam
curl -s http://127.0.0.1:8890/v1/audio/transcriptions \ -H "Authorization: Bearer $HAWKTALK_KEY" \ -F "file=@utterance.wav"
Accepts a multipart file, JSON base64 (audio / file / audio_b64 / data, data: URIs fine), or a raw audio/* body up to SERVERPAL_MAX_AUDIO_MB (default 32 MB). Returns {"text": ..., "x_stt": {...}}.
A 200 is always a real local whisper.cpp transcription. Unwired node → 501 stt_not_wired, and your client should fall back to browser SpeechRecognition.
POST /v1/audio/speech seam
curl -s http://127.0.0.1:8890/v1/audio/speech \
-H "Authorization: Bearer $HAWKTALK_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "HawkTalk is live.", "voice": "af_heart"}' \
-o reply.wav
A 200 is always playable audio/wav bytes from the local Kokoro ONNX worker, with x-voice, x-seconds, x-engine and x-synth-ms headers. Unwired → 501 tts_not_wired, fall back to browser speechSynthesis.
GET /v1/models · GET /health
# registry: id, backend, family, available, default, qnn_lineage curl -s http://127.0.0.1:8890/v1/models -H "Authorization: Bearer $HAWKTALK_KEY" # node health — no auth. auth state, models, voice seams, telemetry, compute, memory curl -s http://127.0.0.1:8890/health
Call /health first when something looks wrong. It tells you whether the node is in OPEN mode, which models are actually servable, and whether the voice seams are wired — which is usually the answer.
Casual vs pro compute
Nodes with the dual-compute model registered flip between casual (NPU only) and pro (NPU + CPU) at runtime. The flip applies to the next turn; in-flight turns keep their mode.
curl -s -X POST http://127.0.0.1:8890/v1/compute-mode \
-H "Authorization: Bearer $HAWKTALK_KEY" \
-H "Content-Type: application/json" \
-d '{"mode": "pro"}'
dual_model: false when the dual-compute model isn't registered on that node, and x_compute.engaged is false for any completion not actually routed through it. The mode value alone does not mean dual-compute ran. Legacy wiring answers 503 compute_not_wired.Errors and rate limits
Every REST error uses one shape: {"error": {"message", "type", "code", "param"}}.
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | Bad JSON, missing messages/text/mode, bad base64, invalid compute mode |
| 401 | invalid_api_key | Missing or unknown bearer key, when keys are configured |
| 404 | model_not_found | Requested model id not in the registry |
| 413 | — | Audio body over SERVERPAL_MAX_AUDIO_MB |
| 415 | — | Unsupported Content-Type on transcription |
| 429 | rate_limit_exceeded | Per-key RPM breached — carries Retry-After |
| 501 | stt_not_wired / tts_not_wired | Voice seam not wired — fall back to browser Web Speech |
| 502 | backend_failed / stt_failed / tts_failed | Backend dead or returned nothing. We fail loud, never fake output |
| 503 | model_unavailable / compute_not_wired | Registered but not currently servable |
Rate limiting is a per-key sliding-window RPM check (default 60). On the WS surface it is enforced at response.create, with rate_limits.updated frames after every completed turn. Every metered call appends one line to the node's usage ledger, labelled by key name and last-4 only — the raw key never touches a log.
2 · WS — when the user is listening
Move up when a human is waiting on the answer and can interrupt it. Full-duplex on one socket: mic audio in; transcript, streamed text, and sentence-chunked TTS audio out, with real barge-in.
The lane speaks the OpenAI-Realtime event protocol on purpose. /v1/realtime is an alias of the canonical /live/ouroboros, event names and ordering match, and the realtime subprotocol is echoed. An existing Realtime client should work against a changed URL.
Connect
const KEY = "sk-..."; // browser-safe subprotocol auth const ws = new WebSocket( "ws://127.0.0.1:8891/v1/realtime?model=auto", ["realtime", "openai-insecure-api-key." + KEY] );
x_sample_rate_hz — read it rather than assuming. Binary WebSocket frames are rejected by design, so one frame type carries everything and there is no ambiguity about what a message is.Text and voice turns
// text turn
ws.send(JSON.stringify({ type: "conversation.item.create", item: {
type: "message", role: "user",
content: [{ type: "input_text", text: "What can you do?" }] }}));
ws.send(JSON.stringify({ type: "response.create", response: {} }));
// voice turn: stream mic pcm16, then commit = end of utterance
function sendAudioChunk(b64) {
ws.send(JSON.stringify({ type: "input_audio_buffer.append", audio: b64 }));
}
function endOfUtterance() {
ws.send(JSON.stringify({ type: "input_audio_buffer.commit" }));
}
Handling the way back:
ws.onmessage = (e) => {
const ev = JSON.parse(e.data);
switch (ev.type) {
case "session.created": /* unprompted on connect */ break;
case "response.text.delta": appendText(ev.delta); break;
case "response.audio.delta": playPcm16(ev.delta, ev.x_sample_rate_hz); break;
case "response.viseme.delta": driveAvatarMouth(ev.visemes); break;
case "conversation.item.input_audio_transcription.completed":
showUserSaid(ev.transcript); break;
case "ouroboros.endpoint": if (ev.complete) commitAndRespond(); break;
case "ouroboros.telemetry": updateHud(ev.telemetry); break;
case "response.done": logTurn(ev.response.x_ouroboros); break;
case "error": console.error(ev.error); break;
}
};
Barge-in
ws.send(JSON.stringify({ type: "response.cancel" }));
This aborts generation and TTS mid-stream and rolls conversation history back to the turn boundary, so the model does not later believe it said something the user never heard. response.done then reports status: "cancelled".
This is the single most important behaviour on this rail. An assistant that cannot be interrupted cleanly is a walkie-talkie, and one that keeps a half-spoken reply in its history will contradict itself two turns later.
Endpointing — your VAD, our semantics
speech_started or speech_stopped. End of speech is your client's VAD, expressed by input_audio_buffer.commit. You own it because you are closest to the microphone.What the server adds is semantic endpointing: ouroboros.endpoint {complete, confidence, reason} on user turns and fresh transcripts, plus ouroboros.endpoint.check on demand over interim transcripts. That lets you respond when the utterance looks finished rather than waiting out a fixed silence timer — the difference between an assistant that feels attentive and one that feels asleep.
Tool calling
Client function tools use the arguments-delta round-trip, same as REST. Three builtins are woven in mid-turn without a round trip to you: memory.search, memory.save, and time.now.
Telemetry on the wire
ouroboros.telemetry is interleaved every 8 tokens by default, plus a 5-second idle heartbeat. At the end, response.done carries x_ouroboros with the turn's real measurements: stt_ms, ttft_ms, first_audio_out_ms, end_to_end_ms, end_to_end_audio_ms, plus backend, model, prompt-cache reuse and grounding info.
These are runtime measurements of the turn that just happened, not brochure constants. Put them in your HUD; that is what they are for.
3 · HawkTalkLive — the conductor preview
A single-socket multi-lane mux that sits in front of the gateway and composes deliberative replies, felt affect and social presence onto one wire — with strict lane priority, so audio never waits behind telemetry.
ws://127.0.0.1:18898/live/brain binds localhost and has no auth wired on the sidecar. Customer-facing exposure lands when the existing key system is put in front of it. Do not expose this port.Lanes and priority
Priority order, highest first:
AUDIO > VISEME > PRESENCE > TEXT > USER_AFFECT > CONDUCT > AGENT > TELEMETRY
Under backpressure telemetry drops first and audio sorts to the front. That ordering is the whole point of the rail: when something has to give, the thing the human is listening to must not be it.
Discovery is exact by design — GET /status and the first-frame hawk.brain.hello announce live-versus-stub per lane, so your UI can render accurate badges instead of implying a capability that isn't running.
Affect, presence, and the baton
- Felt affect — per committed utterance,
ouroboros.user_affect {emotion, arousal, valence, confidence}from the SER sidecar. Markedx_stubwhen SER is down rather than guessing. - Presence —
ouroboros.presencebackchannels ("mm-hm") from a reactive model when reachable, otherwise an affect-keyed heuristic pick, always labelled which. - Conduct baton —
hawk.conduct {route | cue | release | cancel}shows which mind holds the turn, and is generation-superseded on barge-in. This is the visible hierarchy: the user can see which tier answered. - Agent lane —
hawk.agentholds a reserved priority slot. Nothing emits it yet. Stated so you don't go looking.
What actually ships today
| Surface | Where | State |
|---|---|---|
| REST — chat, models, health | :8890 | shipped |
| REST — STT / TTS | :8890 | seam 501 when unwired |
| WS — Realtime session | :8891 | shipped |
| HawkTalkLive lane mux | 127.0.0.1:18898 | preview loopback, no auth |
| Per-key RPM limit + usage ledger | gateway | shipped |
| Monthly token quota enforcement | key system | roadmap defined, not in request path |
Public api.hawktalk.ai | — | coming soon |
Quotas and plan tiers are defined by the key system and summed from the ledger, but quota enforcement is not yet wired into the REST request path. RPM is enforced; monthly tokens are accounted, not blocked. Said plainly here so nobody designs around a limit that doesn't bite yet.
Endpoint index
| Method & path | What |
|---|---|
POST /v1/chat/completions | Chat, non-stream or SSE stream |
POST /v1/audio/transcriptions | Local whisper.cpp STT |
POST /v1/audio/speech | Local Kokoro TTS, real WAV bytes |
GET /v1/models | Registry with availability and routing |
GET·POST /v1/compute-mode | Read or flip casual|pro |
GET /health | No auth — auth state, models, seams, telemetry |
GET / | No auth — built-in browser chat client |
WS /live/ouroboros · /v1/realtime | Realtime session socket, :8891 |
WS /live/brain | HawkTalkLive lane mux, :18898, preview |
For AI agents
If you are a model or an agent reading this to write an integration, here is the compressed contract. Everything above is the human explanation of these same facts.
BASE_REST http://HOST:8890
BASE_WS ws://HOST:8891
AUTH Authorization: Bearer sk-...
WS also: ?api_key=... | Sec-WebSocket-Protocol: openai-insecure-api-key.KEY
SHAPE OpenAI-compatible. model:"auto" routes automatically.
REST
POST /v1/chat/completions {model, messages[], max_tokens?, temperature?, stream?, tools?}
-> {choices[], usage, x_timing, x_compute}
-> stream:true = SSE chat.completion.chunk ... data: [DONE]
POST /v1/audio/transcriptions multipart file | JSON base64 | raw audio/*
-> {text, x_stt} | 501 stt_not_wired
POST /v1/audio/speech {text, voice}
-> audio/wav bytes | 501 tts_not_wired
GET /v1/models registry
GET /health no auth; reports auth:"enabled"|"OPEN (no keys configured)"
WS /v1/realtime (alias /live/ouroboros) JSON text frames only; audio = base64 pcm16 @24kHz
send conversation.item.create | response.create | response.cancel
input_audio_buffer.append | input_audio_buffer.commit | session.update
recv session.created | session.updated | conversation.created
conversation.item.created | conversation.item.input_audio_transcription.completed
response.created | response.output_item.added | response.content_part.added
response.text.delta | response.audio.delta | response.viseme.delta
response.text.done | response.audio.done | response.content_part.done
response.output_item.done | response.done | rate_limits.updated | error
ouroboros.telemetry | ouroboros.endpoint | ouroboros.endpoint.check
INVARIANTS
unknown values are null or "unknown" — never a fabricated 0
unwired seams return 501 — never synthetic audio or invented transcripts
backend faults surface as errors — never a silent truncation
end-of-speech is client VAD via input_audio_buffer.commit; server adds semantic endpointing only
binary WS frames are rejected by design
response.cancel rolls history back to the turn boundary
Machine-readable index also lives at /llms.txt.