hawktalk@ai — docs · tty4 ◉ rest → ws → live
developer documentation

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 get null or 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_wired so 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.

NodePortAuthWhat it is
Product gateway8443HawkNest HNK1-Cloud GPU node. Chat, TTS, STT. Crisis gate and billing live here.
Node REST8890sk-On-device / NPU node. The full OpenAI-compatible surface below.
Node Realtime8891sk-The WebSocket voice lane. A separate process from REST.
AIC runtime8900sk-Qualcomm AI-100 fleet, with seat and drain control.
Two things that trip people up. The product gateway on :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

REST shippedRequest in, response out. Stateless, drop-in, boring on purpose.
Batch work, classification, extraction, tool-calling agents, anything already built around HTTP.
WS shippedFull-duplex voice on one socket, Realtime-compatible.
A human is listening and can interrupt. Live assistants, phone-style agents, avatars.
HawkTalkLive previewLane-muxed reactive conductor in front of the gateway.
Affect, presence, and a visible baton showing which mind holds the turn.

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.

RungModelSize on diskWhat it takes
routerouromega — Gemma3-270M + LoRA253 MB Q4_K_MReads the turn, emits SELF · QUICK · DANK · CLOUD
SELFouromega answers directlyAcknowledgements, backchannel, trivia
QUICKhawkalphaquick — Gemma-4 E2B2.29 GB Q4_K_MThe workhorse. Chat, quick reasoning, audio in
DANKhawkalphadank — Gemma-4 E4B3.69 GB Q4_0Longer instructions, code, summaries
CLOUDFrontier modelOnly 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:

ModelRuns onFormatMeasured
fc-quickServer / NPU2.22 GB Q4_071% raw, 79% grammar-constrained across 7 categories
oakNPU premium tier3.18 GB Q4_K_M~27 tok/s on Hexagon; teacher for feedseed
feedseedBrowser, WebGPU310 MB ONNX fp16A/B 25/25; hiking suite 97.9%
sakura-1.5bCPU / Vulkan941 MB Q4_K_M70.8% exact under GBNF grammar; Apache-2.0
Two findings worth building around. On the 42-case function-calling eval, 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.
Do not 4-bit the small models. 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.

Know which node you're talking to. A node with no keys configured runs OPEN — every request passes with an anonymous key. That is proof-of-concept mode, not a security model. 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_timingttft_ms, decode_tps, total_tps, total_ms, server_tps, prompt_tokens_source, backend, qnn_lineage
  • x_compute — which compute path actually ran
  • Response headers x-ttft-ms and x-tps when known
Parsing warning. 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"}'
Read the reply. The response carries 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"}}.

StatusCodeWhen
400invalid_requestBad JSON, missing messages/text/mode, bad base64, invalid compute mode
401invalid_api_keyMissing or unknown bearer key, when keys are configured
404model_not_foundRequested model id not in the registry
413Audio body over SERVERPAL_MAX_AUDIO_MB
415Unsupported Content-Type on transcription
429rate_limit_exceededPer-key RPM breached — carries Retry-After
501stt_not_wired / tts_not_wiredVoice seam not wired — fall back to browser Web Speech
502backend_failed / stt_failed / tts_failedBackend dead or returned nothing. We fail loud, never fake output
503model_unavailable / compute_not_wiredRegistered 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]
);
Audio format. Base64 pcm16 inside JSON text frames. Input defaults to 24 000 Hz; output frames carry 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

The server never emits 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.

Latency masking is opt-in and off. Tic and breath filler audio, instant tic cue, reactive opener pre-roll, rolling partial STT, speculative generation and semantic autocommit are all env-flagged, default OFF, and fail open. A stock gateway emits none of those events — if you are not seeing them, nothing is broken.

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.

Preview, and loopback only. 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. Marked x_stub when SER is down rather than guessing.
  • Presenceouroboros.presence backchannels ("mm-hm") from a reactive model when reachable, otherwise an affect-keyed heuristic pick, always labelled which.
  • Conduct batonhawk.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 lanehawk.agent holds a reserved priority slot. Nothing emits it yet. Stated so you don't go looking.

What actually ships today

SurfaceWhereState
REST — chat, models, health:8890shipped
REST — STT / TTS:8890seam 501 when unwired
WS — Realtime session:8891shipped
HawkTalkLive lane mux127.0.0.1:18898preview loopback, no auth
Per-key RPM limit + usage ledgergatewayshipped
Monthly token quota enforcementkey systemroadmap defined, not in request path
Public api.hawktalk.aicoming 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 & pathWhat
POST /v1/chat/completionsChat, non-stream or SSE stream
POST /v1/audio/transcriptionsLocal whisper.cpp STT
POST /v1/audio/speechLocal Kokoro TTS, real WAV bytes
GET /v1/modelsRegistry with availability and routing
GET·POST /v1/compute-modeRead or flip casual|pro
GET /healthNo auth — auth state, models, seams, telemetry
GET /No auth — built-in browser chat client
WS /live/ouroboros · /v1/realtimeRealtime session socket, :8891
WS /live/brainHawkTalkLive 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.