---
name: bridge-by-wekoya
description: Add AI features (a chatbot, assistant, voice assistant, summariser, extraction, classification or an agent) to an app using Bridge by Wekoya, an OpenAI-compatible API for GPT, Claude, Gemini and open-source models, paid in naira. Use when the user asks to add AI with Bridge or Wekoya, mentions WEKOYA_API_KEY or wekoya-api.wekoya.tech, or wants an AI model API they can pay for in naira.
---

# Bridge by Wekoya

Bridge gives an app one connection to many AI models (GPT, Claude, Gemini and open-source models) through the OpenAI Chat Completions protocol. The user pays from a balance in naira. Follow these steps exactly; the details below are the ones that break Bridge apps when they are wrong.

## When to use this

- The user wants an AI feature in their own app and chose Bridge, or has not chosen a provider and is happy to use Bridge.
- Do not replace a working integration with another provider (OpenAI, Anthropic, Gemini and so on) unless the user asks.
- If a feature needs something Bridge does not offer (see "What Bridge does not have"), say so and ask which provider to use for that part.

## The facts

- Base URL: `https://wekoya-api.wekoya.tech/v1`
- Auth: header `Authorization: Bearer <key>`. Keys start with `wk_live_`.
- `POST /chat/completions`: OpenAI Chat Completions, with streaming, tool calling, image input, `response_format` JSON and `reasoning_effort`.
- `POST /responses`: OpenAI's Responses API, stateless, on every chat model: text, streaming, function and custom tools, images, files, JSON output, reasoning. Nothing is stored, so send the whole conversation in `input` each time; `previous_response_id`, `conversation`, `background` and built-in tools other than web search are refused (OpenAI's built-in `web_search` is left out and named in the `X-Bridge-Ignored-Tools` header; use `POST /search` from a function instead). Prefer Chat Completions in new code; use this for tools that only speak Responses (Codex, an SDK's default).
- `POST /audio/transcriptions` (model `nova-3`) and `POST /audio/speech` (model `aura-2`): speech to text and text to speech, OpenAI's audio API. See "Voice" below.
- `wss /audio/live` (with `POST /audio/live/tickets`): live transcription over a WebSocket, for captions and voice agents.
- `wss /realtime` (with `POST /realtime/tickets`): live voice conversations with Gemini Live, where the model hears the caller and answers by voice in one connection. See "Live voice conversations" below.
- `POST /embeddings` (gemini-embedding-2), `POST /images/generations` and `/images/edits` (gemini-3.1-flash-image; base64 only), `POST /audio/music` (lyria-3-clip-preview): OpenAI's shapes where OpenAI has one.
- `POST /search`: search the live web. `{ "query": "...", "max_results": 5, "recency": "day", "site": "punchng.com", "country": "NG", "answer": true }` (all but `query` optional; `max_results` 1 to 10, `recency` day, week, month, year or any). Returns `results` (`title`, `url`, `snippet`, `published_at` or null) and, with `answer: true`, a short `answer` citing the results as [1], [2] with `citations` (`answer` is null when the results do not answer the question). One flat price per search, a little more with an answer; shown in `wekoya.cost`. A failed search (503 `search_unavailable`) costs nothing: retry after a pause. Use it for anything current (news, prices, rates, what people are saying) instead of letting a chat model guess; pass the results to a chat model when the app needs more than the short answer, and show the links. A key with a model allow-list needs `web-search` on it; a guest key needs the `search` use.
- `POST /videos/generations`: video with sound from a prompt (and optionally a first frame, `image`), by Veo (`veo-3.1-fast` default, `veo-3.1-lite` cheapest, `veo-3.1` best). `duration_seconds` 4, 6 or 8; `aspect_ratio` 16:9 or 9:16 (no square); `resolution` 720p, or 1080p/4k at 8 seconds. Answers 202 with an `id`; poll `GET /videos/generations/{id}` every 10 s until `status` is `succeeded` (then `video.url`, which plays without a key for two days: download it to keep it) or `failed` (`error`, not charged). Charged per second only when made. Webhooks: `video.succeeded`, `video.failed`. Guest keys need the `videos` use.
- Prices before making something: `GET /models?type=image|music|video|search` gives `wekoya.unit` and `wekoya.cost_per_unit` (and `cost_per_unit_by_size`). Every model and price, no key needed: https://bridge.wekoya.tech/models (JSON: `GET /pricing`).
- `GET /apps/{app_id}/usage?from=&to=&group_by=day|model|end_user`: one app's spend from Bridge's ledger (usage scope).
- `POST /caches` and `POST /batches`: context caching and half-price batches, on Gemini models. See "Long documents and bulk work" below.
- Prompt caching is automatic on Claude, Gemini, GPT and DeepSeek models: keep what stays the same (instructions, tools, documents) at the start of `messages` and what changes at the end, and send one `prompt_cache_key` (or `session_id`, or the `X-Session-Id` header) per conversation. `usage.prompt_tokens_details.cached_tokens` shows what was read from the cache. No `cache_control` is needed.
- `POST /decisions`: typed decisions (choose, score, yes/no) from Jev. Not chat; see "Decisions, not text" below.
- `GET /models`: the list of models with their capabilities and prices in naira. It needs no key.
- Default model: `gpt-6-luna`. Any id from `GET /models` works, and `auto` picks a model for each request.
- MCP server: `https://wekoya-api.wekoya.tech/mcp` (Streamable HTTP, a Bridge key as the bearer token). It lets an agent search models and prices, compare models, estimate a feature's cost, find a cheaper model that passes the user's examples, and explain spending. Docs: https://bridge.wekoya.tech/docs/mcp
- Docs: https://bridge.wekoya.tech/docs

### What Bridge does not have

No stored responses or conversations (`previous_response_id`), no audio translation, no fine-tuning, no Assistants or files API. Only the endpoints above.

## Step 1: configuration

Read three environment variables, so the model can change without a code change:

- `WEKOYA_API_KEY`: required. If it is missing, fail with a message that names `WEKOYA_API_KEY`.
- `WEKOYA_BASE_URL`: optional, default `https://wekoya-api.wekoya.tech/v1`
- `WEKOYA_MODEL`: optional, default `gpt-6-luna`

Add them to `.env.example` (create it if it does not exist, and keep its existing lines):

```
# Bridge by Wekoya: https://bridge.wekoya.tech
# 1. Create an account: https://bridge.wekoya.tech/sign-up
# 2. Add money under Billing: https://bridge.wekoya.tech/console/billing
# 3. Create a key under API keys: https://bridge.wekoya.tech/console/keys (it is shown only once)
# Models and prices: https://bridge.wekoya.tech/console/models
WEKOYA_API_KEY=wk_live_replace_me
WEKOYA_BASE_URL=https://wekoya-api.wekoya.tech/v1
WEKOYA_MODEL=gpt-6-luna
```

- Make sure the real `.env` is in `.gitignore`.
- Make sure the app actually loads `.env`: use the framework's own loader, or `node --env-file=.env` (Node 20.6 or later), or `python-dotenv`. Tell the user to restart the app after editing it.

## Step 2: keep the key on the server

The key spends the user's money. Call Bridge only from server code: an API route, a serverless function or a backend.

- Never put the API key in browser or mobile app code. When the app must call Bridge directly (chat or voice without a round trip through your server), the server makes a short-lived **guest key** for the signed-in user with `POST /v1/guest-keys` (`{ "max_spend": 50, "expires_in": 900, "uses": ["chat"], "end_user": "<user id>", "origins": ["https://app.example.com"] }`; `uses` from chat, speech, transcription, live, realtime, images, embeddings, decisions, music, search, videos) and hands the returned `wk_guest_…` key to the app, which uses it like an API key. It expires, stops at `max_spend` (`key_limit_exceeded`), and is refused for anything outside `uses` (`guest_key_not_allowed`). Revoke with `POST /v1/guest-keys/{id}/revoke`. Make guest keys with a server key that has no spending limit of its own. Docs: https://bridge.wekoya.tech/docs/guest-keys
- Never put the key in a public variable such as `NEXT_PUBLIC_`, `VITE_`, `EXPO_PUBLIC_` or `REACT_APP_`.
- Never hardcode, print, log or commit the key. If the user pastes a real key, put it only in `.env`.
- If the app has no backend, add a small one (for example a single API route) and explain why.

## Step 3: make the call

Use the official OpenAI SDK, pointed at Bridge.

Node.js or TypeScript (`npm install openai`):

```ts
import OpenAI from "openai"

const apiKey = process.env.WEKOYA_API_KEY
if (!apiKey) throw new Error("WEKOYA_API_KEY is not set. Add it to .env (see .env.example).")

const bridge = new OpenAI({
  apiKey,
  baseURL: process.env.WEKOYA_BASE_URL ?? "https://wekoya-api.wekoya.tech/v1",
})

const reply = await bridge.chat.completions.create({
  model: process.env.WEKOYA_MODEL ?? "gpt-6-luna",
  messages: [{ role: "user", content: "Say hello in one sentence." }],
})
console.log(reply.choices[0]?.message?.content)
```

Python (`pip install openai`):

```python
import os
from openai import OpenAI

api_key = os.environ.get("WEKOYA_API_KEY")
if not api_key:
    raise RuntimeError("WEKOYA_API_KEY is not set. Add it to .env (see .env.example).")

bridge = OpenAI(
    api_key=api_key,
    base_url=os.environ.get("WEKOYA_BASE_URL", "https://wekoya-api.wekoya.tech/v1"),
)

reply = bridge.chat.completions.create(
    model=os.environ.get("WEKOYA_MODEL", "gpt-6-luna"),
    messages=[{"role": "user", "content": "Say hello in one sentence."}],
)
print(reply.choices[0].message.content)
```

Vercel AI SDK (`npm install ai @ai-sdk/openai`). Use `.chat(model)`, which calls Chat Completions. The default `bridge(model)` calls the Responses API, which also works (statelessly), but Chat Completions carries every Bridge option:

```ts
import { createOpenAI } from "@ai-sdk/openai"
import { generateText } from "ai"

const bridge = createOpenAI({
  apiKey: process.env.WEKOYA_API_KEY,
  baseURL: process.env.WEKOYA_BASE_URL ?? "https://wekoya-api.wekoya.tech/v1",
})

const { text } = await generateText({
  model: bridge.chat(process.env.WEKOYA_MODEL ?? "gpt-6-luna"),
  prompt: "Say hello in one sentence.",
})
```

Plain `fetch` or any other HTTP client: join the URL as a string.

```ts
const base = (process.env.WEKOYA_BASE_URL ?? "https://wekoya-api.wekoya.tech/v1").replace(/\/+$/, "")
const response = await fetch(`${base}/chat/completions`, {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.WEKOYA_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ model: process.env.WEKOYA_MODEL ?? "gpt-6-luna", messages: [{ role: "user", content: "Hello" }] }),
})
```

Never build the URL with `new URL("/chat/completions", base)` or `path.join`. The leading slash drops `/v1` and every call returns 404. With an SDK, pass the base URL as it is, ending in `/v1`; the SDK adds `/chat/completions` itself.

## Step 4: the details that matter

- **Streaming**: with an SDK, pass `stream: true` and read the chunks. With raw HTTP, read Server-Sent Events: split on newlines, keep a partial last line for the next chunk, parse each `data: {...}` line, and stop at `data: [DONE]`. The text is in `choices[0].delta.content`.
- **Images**: put them in a user message as `{ "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,..." } }`. Allow a request body large enough for photos. Check that the chosen model has `"vision": true` under `wekoya` in `GET /models`.
- **JSON output**: with `response_format: { "type": "json_object" }`, also say "JSON" in the prompt, and still parse defensively: strip code fences and take the outermost `{...}` before parsing.
- **Tools**: tool calling works as in the OpenAI API. Check the model has `"tools": true` under `wekoya` in `GET /models`.
- **Sampling**: `temperature` and similar settings are optional. Some models ignore them, which is fine.
- **Files in chat**: Gemini models (for example `gemini-3.5-flash-lite`) read audio (`input_audio`), PDFs (`{"type":"file","file":{"file_data":"data:application/pdf;base64,…"}}`) and video (`video_url` as a data URL or YouTube link), up to 20 MB per request. Other models refuse them with `media_not_supported`.
- **Slow replies**: images, music and long transcriptions may answer 200 with leading spaces and put an error in the body; always check for an `error` field.
- **Voice**: use the OpenAI SDK's audio methods on the same client. Transcribe with `audio.transcriptions.create({ model: "nova-3", file })` (25 MB and 60 minutes per file; `language` defaults to "en", use "multi" for mixed languages; Yoruba, Hausa and Igbo are not supported). Speak with `audio.speech.create({ model: "aura-2", voice: "thalia", input })` (2,000 characters per request, so split longer text; OpenAI voice names such as "alloy" do not exist here; list voices with `GET /v1/audio/voices`). Keep both calls on the server, like every other call. Docs: https://bridge.wekoya.tech/docs/voice
- **Live audio and voice agents**: for speech as it happens, use the WebSocket `wss://wekoya-api.wekoya.tech/v1/audio/live`. Browsers: the server gets a one-minute ticket with `POST /v1/audio/live/tickets` (options such as `{ "model": "nova-3", "encoding": "container" }` for MediaRecorder webm), and the browser connects to the returned `url`; never put the key in the page. Send audio as binary messages; receive JSON `transcript` events (or `turn` events with `"model": "flux-general-en"`, which marks when the speaker has finished: use it for voice agents). Send `{"type":"close"}` to finish. For a spoken conversation, prefer "Live voice conversations" below: one connection, and it answers sooner. Build the three-call agent only when it needs something Gemini Live lacks (an aura-2 voice, or a chat model of its own choosing): listen (Flux, `turn` end) → stream a chat completion from a fast model → speak each sentence with `audio.speech.create` as it is written; cancel the reply when a new turn starts. Guide: https://bridge.wekoya.tech/docs/voice-agents
- **Live voice conversations (Gemini Live)**: any voice conversation is built on `wss://wekoya-api.wekoya.tech/v1/realtime`, fully duplex. Follow "Live voice conversations" below exactly.
- **Speed**: when a person is waiting for the answer (chat, voice), pick a model that starts answering fast: `gemini-3.5-flash-lite`, `gemini-2.5-flash-lite`, `ministral-3-8b`, `nova-lite` or `gpt-oss-20b` (first word in about half a second), and stream the reply. Some cheap "flash" models take several seconds to start; check before using one where someone waits. Speech with `aura-2` starts in about half a second; Gemini's speech models take 2 to 3 seconds, too slow for a live call.
- **Decisions, not text**: to route, classify, flag or score something (a support ticket's team, whether a payment needs review, how good an answer is), use Jev on `POST /v1/decisions` instead of a chat model: faster, cheaper, and the answer is already structured. Send `{ "model": "jev-latest", "state": <text or JSON>, "questions": { "<name>": { "type": "choice" | "score" | "noul", "instructions": "...", "criteria": ... } } }`. `choice` takes `criteria` as an object of labels to meanings and returns `choice` with `probabilities`; `score` takes 2 to 10 levels, lowest first, and returns `score` (0 = first level) with a `legend`; `noul` is a yes/no question and returns `noul`, the probability of yes. Never send Jev to `/chat/completions`. Docs: https://bridge.wekoya.tech/docs/decisions
- **Long documents and bulk work**: to ask many questions about one long document, file or video, store it once with `POST /v1/caches` (`{ "model": "gemini-3.5-flash-lite", "messages": [...], "ttl_seconds": 3600 }`, at least 1,024 tokens) and pass the returned id as `"cache": "cache_..."` in chat requests (with the OpenAI SDK, `extra_body`); a request using a cache must use the cache's model (or `auto`) and cannot add a system message or tools. For many requests nobody is waiting on (labelling, summarising, evaluating), use `POST /v1/batches` (`{ "model", "requests": [{ "custom_id", "body": { "messages", "max_tokens" } }] }`, up to 2,000): half price, done within a day (usually minutes); poll `GET /v1/batches/{id}` until `status` is not `in_progress`, then read JSON lines from `GET /v1/batches/{id}/results`. Docs: https://bridge.wekoya.tech/docs/caches and /docs/batches
- **Cheaper models, live names, pipelines and designs**: see "AI tasks, live names, pipelines and designs" below.
- **One key, many users**: if the app serves many users from one key, send `X-Bridge-End-User: <your user id>` on each request, and Bridge tracks and limits each user's spend separately. Create each user first (`POST /v1/end-users`, see the docs) or turn on auto-create in the console, or the request is refused with 404 `end_user_not_found`.

## Live voice conversations

A voice conversation (a voice assistant or agent, voice chat, people talking to or speaking with the AI, a phone or call assistant, spoken tutoring, live interpreting) is a live, two-way conversation: build it on Gemini Live (`wss://wekoya-api.wekoya.tech/v1/realtime`), fully duplex. Never as "record, stop, upload, transcribe, reply, play": that is a voice-note feature, slower, and it cannot be interrupted. Voice notes, uploaded recordings and dictation are not conversations: use `POST /audio/transcriptions` for those.

Models: `gemini-3.1-flash-live-preview` (default, fastest: first sound about 1.3 s after the person stops), `gemini-3.8-live` (fuller answers, about 1.7 s) and `gemini-3.8-live-extended-thinking`. They understand and answer in Yoruba, Hausa and Pidgin as well as English. Cost: about ₦7.43 a minute heard and ₦26.73 a minute spoken. Messages are Gemini Live's own, so Google's Live API docs apply; Bridge adds `ready` (send your setup now), `usage` (the cost so far after each turn, `final: true` at the end) and `error`, and sets the model from the ticket. Docs: https://bridge.wekoya.tech/docs/realtime

Do all of these; each one missed is a bug users hit:

- The key stays on your server. Your server makes a one-minute, single-use ticket (`POST /v1/realtime/tickets`) for each call, and returns the ticket's `url` and the Live `setup` (system instruction, tools, voice) to the app, so the instructions are set on the server.
- Ask for the microphone before the ticket, so a refused microphone costs nothing. Use `echoCancellation`, `noiseSuppression` and `autoGainControl`, and start audio from a click or tap (browsers block sound until then). On laptop speakers, check the assistant does not hear itself and stop mid-sentence; headphones avoid it.
- Stream the microphone continuously for the whole call, silence included, as 16-bit mono PCM in 20 to 100 ms pieces, sent as binary messages at the ticket's `sample_rate`. Gemini detects when the person has finished. Do not stop sending when they pause: `gemini-3.8-live` never answers an app that does.
- Play the reply as it arrives: 24 kHz 16-bit PCM in `serverContent.modelTurn.parts[].inlineData`, each piece scheduled right after the last. Keep the microphone open while it plays so the person can talk over it; on `serverContent.interrupted`, stop and drop everything queued at once.
- Captions and records come from the session itself: put `inputAudioTranscription: {}` and `outputAudioTranscription: {}` in the setup and read `serverContent.inputTranscription.text` and `outputTranscription.text`. Never send the call's audio to `/audio/transcriptions` as well.
- Lookups and actions are the session's own tools: declare them in `setup.tools` as `functionDeclarations`. On `toolCall`, run each call on your server (with the signed-in person's permissions, never more; treat the arguments as the caller's words, not as trusted input) and send `toolResponse` with each call's `id`, `name` and `response`, including an error as `{ "error": "..." }`. Skip calls listed in `toolCallCancellation`. Do not run a separate chat model for them.
- `turnComplete` ends one turn, not the call: a tool call is a turn of its own before the spoken answer. The call ends only when the person hangs up.
- Keep the latest `sessionResumptionUpdate.newHandle` (when `resumable` is true). Bridge carries the conversation over when Gemini rotates its connection (about every ten minutes), so the app sees nothing. If the app's own connection closes with 1001, 1006, 1013 or 4413, get a new ticket and send the same setup with `sessionResumption: { handle }` to carry on; give up after a few tries.
- Do not retry 4400 (bad setup), 4401 (a message Gemini refused), 4402 (balance or spending limit), 4403 (key revoked) or 4408 (idle): show the `error` message Bridge sent just before closing.
- Hang up with `{"type":"close"}`, then stop the microphone tracks and close the audio context. The last message is `usage` with `final: true` and the call's cost. Hang up on `pagehide` too, and when the microphone track ends (a headset unplugged).
- Push-to-talk only when the user asks for it: send `realtimeInputConfig: { automaticActivityDetection: { disabled: true } }` in the setup, `{ realtimeInput: { activityStart: {} } }` on press and `{ realtimeInput: { activityEnd: {} } }` on release. While muted or between presses, send `{"type":"keep_alive"}` at least every 4 minutes, or Bridge closes the session as idle.
- Phone lines (Twilio, Africa's Talking and others) run on your server: connect with `?model=...&sample_rate=8000` and the `Authorization` header, decode the line's 8 kHz mu-law to 16-bit PCM before sending, turn the 24 kHz reply into 8 kHz mu-law for the line, and clear the line's playback on `interrupted`.
- A guest key for live voice needs `uses: ["realtime"]` and a `max_spend` of at least 150: starting a session holds about ₦120. Up to 10 sessions at once per account; a session lasts at most 2 hours.

On the server, the ticket and the tools:

```javascript
// On your server: a ticket for one call, and the call's setup. The key never leaves the server.
app.post("/api/talk", async (req, res) => {
  const user = await signedInUser(req) // your own sign-in check
  if (!user) return res.status(401).json({ error: "Sign in first." })
  const sampleRate = Math.min(48000, Math.max(8000, Math.round(Number(req.body?.sample_rate) || 16000)))
  const reply = await fetch("https://wekoya-api.wekoya.tech/v1/realtime/tickets", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WEKOYA_API_KEY}`,
      "Content-Type": "application/json",
      "X-Bridge-End-User": user.id, // optional: per-user spend and limits
    },
    body: JSON.stringify({ model: "gemini-3.1-flash-live-preview", sample_rate: sampleRate }),
  })
  const ticket = await reply.json()
  // 402: out of balance or over a limit. 429: too many calls at once. Say so; do not retry at once.
  if (!reply.ok) return res.status(reply.status).json({ error: ticket.error?.message ?? "Could not start the call." })
  res.json({
    url: ticket.url,
    setup: {
      systemInstruction: { parts: [{ text: "You are the phone assistant for Ada's Bakery in Lagos. You are speaking, not writing: one or two short sentences, no lists. Answer in the caller's language." }] },
      generationConfig: { speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: "Kore" } } } },
      inputAudioTranscription: {}, // what the caller said, as text
      outputAudioTranscription: {}, // what the assistant said, as text
      tools: [{ functionDeclarations: [{
        name: "order_status",
        description: "Where a customer's order is",
        parameters: { type: "object", properties: { order: { type: "string" } }, required: ["order"] },
      }] }],
    },
  })
})

// Tools run here, as the signed-in person, from an allow-list. The arguments are the caller's words: check them.
app.post("/api/tool", async (req, res) => {
  const user = await signedInUser(req)
  if (!user) return res.status(401).json({ error: "Sign in first." })
  const tools = {
    order_status: async ({ order }) => ({ status: await findOrderStatus(user, String(order ?? "")) }),
  }
  const run = tools[req.body?.name]
  if (!run) return res.json({ error: "Unknown tool." })
  try {
    res.json(await run(req.body.args ?? {}))
  } catch {
    res.json({ error: "That lookup failed." })
  }
})
```

In the browser, the whole call (tested end to end):

```javascript
// In the browser: a live, two-way voice call. Call startCall() from a click or tap.
// The microphone streams all the time, the reply plays as it arrives, the person can
// talk over it, tools run on your server, and a dropped connection carries on.
export async function startCall({ onStatus = () => {}, onCaption = () => {}, onEnd = () => {} } = {}) {
  const mic = await navigator.mediaDevices
    .getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true } })
    .catch((error) => {
      throw new Error(error?.name === "NotAllowedError" ? "Allow the microphone to talk." : "No microphone was found.")
    })
  const audio = new AudioContext()
  await audio.resume()
  const rate = Math.min(16000, audio.sampleRate) // the rate we send; the ticket is made for the same

  // Microphone to 16-bit PCM at `rate`, in 40 ms pieces, averaged down from the device's rate
  // (inside the worklet, `sampleRate` is the device's).
  const worklet = `registerProcessor("mic", class extends AudioWorkletProcessor {
    constructor() { super(); this.step = sampleRate / ${rate}; this.pos = 0; this.sum = 0; this.count = 0; this.size = ${Math.round(rate * 0.04)}; this.out = new Int16Array(this.size); this.n = 0 }
    process([input]) {
      const samples = input[0]
      if (!samples) return true
      for (let i = 0; i < samples.length; i++) {
        this.sum += samples[i]; this.count++; this.pos += 1
        if (this.pos < this.step) continue
        this.pos -= this.step
        this.out[this.n++] = Math.max(-1, Math.min(1, this.sum / this.count)) * 0x7fff
        this.sum = 0; this.count = 0
        // Handing the buffer over empties it, so the next one is made at the size kept aside.
        if (this.n === this.size) { this.port.postMessage(this.out.buffer, [this.out.buffer]); this.out = new Int16Array(this.size); this.n = 0 }
      }
      return true
    }
  })`
  await audio.audioWorklet.addModule(URL.createObjectURL(new Blob([worklet], { type: "text/javascript" })))
  const source = audio.createMediaStreamSource(mic)
  const capture = new AudioWorkletNode(audio, "mic")
  source.connect(capture)

  let socket
  let setup
  let lastError
  let handle // Gemini's latest resume handle
  let live = false // the session is accepting audio
  let ended = false
  let retries = 0
  const cancelled = new Set()
  const playing = new Set()
  let playAt = 0

  capture.port.onmessage = (event) => {
    if (live && socket?.readyState === WebSocket.OPEN) socket.send(event.data)
  }

  async function connect() {
    onStatus(handle ? "reconnecting" : "connecting")
    const reply = await fetch("/api/talk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sample_rate: rate }) })
    const body = await reply.json().catch(() => ({}))
    if (!reply.ok) return end(body.error ?? "Could not start the call.")
    setup = body.setup
    socket = new WebSocket(body.url)
    socket.onmessage = (event) => receive(JSON.parse(event.data))
    socket.onclose = (event) => {
      live = false
      if (ended) return
      // Going away, network, try again, or the 2-hour limit: carry on from the handle.
      if ([1001, 1006, 1013, 4413].includes(event.code) && handle && retries < 3) {
        retries++
        stopPlayback()
        setTimeout(() => connect().catch((error) => end(error.message)), 500 * retries)
        return
      }
      end(lastError ?? "The call ended.")
    }
  }

  function receive(message) {
    if (message.type === "ready") {
      socket.send(JSON.stringify({ setup: handle ? { ...setup, sessionResumption: { handle } } : setup }))
      return
    }
    if (message.type === "error") lastError = message.message // the close that follows says why it ended
    if (message.type === "usage" && message.final) onStatus(`cost ${message.cost} ${message.currency}`)
    if (message.setupComplete) {
      live = true
      retries = 0
      onStatus("listening")
    }
    const update = message.sessionResumptionUpdate
    if (update?.resumable && update.newHandle) handle = update.newHandle
    const content = message.serverContent
    if (content?.interrupted) stopPlayback() // they talked over it
    for (const part of content?.modelTurn?.parts ?? []) if (part.inlineData?.data) play(part.inlineData.data)
    if (content?.inputTranscription?.text) onCaption("caller", content.inputTranscription.text)
    if (content?.outputTranscription?.text) onCaption("assistant", content.outputTranscription.text)
    for (const id of message.toolCallCancellation?.ids ?? []) cancelled.add(id)
    if (message.toolCall) runTools(message.toolCall.functionCalls ?? [])
    // turnComplete ends a turn, not the call: keep listening.
  }

  async function runTools(calls) {
    const functionResponses = await Promise.all(
      calls.map(async (call) => {
        const reply = await fetch("/api/tool", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: call.name, args: call.args }) }).catch(() => undefined)
        const response = reply?.ok ? await reply.json().catch(() => ({ error: "The tool answered badly." })) : { error: "The tool is unavailable." }
        return { id: call.id, name: call.name, response }
      }),
    )
    const wanted = functionResponses.filter((item) => !cancelled.has(item.id))
    if (wanted.length && live) socket.send(JSON.stringify({ toolResponse: { functionResponses: wanted } }))
  }

  function play(base64) {
    const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
    const samples = new Int16Array(bytes.buffer, 0, bytes.length >> 1)
    if (!samples.length) return
    const buffer = audio.createBuffer(1, samples.length, 24000) // replies are 24 kHz
    buffer.getChannelData(0).set(Float32Array.from(samples, (x) => x / 32768))
    const node = audio.createBufferSource()
    node.buffer = buffer
    node.connect(audio.destination)
    playAt = Math.max(playAt, audio.currentTime + 0.02)
    node.start(playAt)
    playAt += buffer.duration
    playing.add(node)
    node.onended = () => playing.delete(node)
  }

  function stopPlayback() {
    for (const node of playing) node.stop()
    playing.clear()
    playAt = 0
  }

  function end(reason) {
    if (ended) return
    ended = true
    live = false
    if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "close" })) // Bridge sends the final cost, then closes
    stopPlayback()
    capture.port.onmessage = null
    source.disconnect()
    for (const track of mic.getTracks()) track.stop()
    setTimeout(() => audio.close(), 300)
    removeEventListener("pagehide", hangUp)
    document.removeEventListener("visibilitychange", wake)
    onEnd(reason)
  }

  function hangUp() {
    end("You hung up.")
  }

  for (const track of mic.getTracks()) track.onended = () => end("The microphone was disconnected.")
  addEventListener("pagehide", hangUp)
  // Phones suspend sound in the background; pick it up again on return.
  const wake = () => document.visibilityState === "visible" && audio.resume()
  document.addEventListener("visibilitychange", wake)
  await connect()
  return { hangUp }
}
```

## AI tasks, live names, pipelines and designs

Use these when the user wants to cut cost, switch models without losing quality, chain several Bridge calls, or plan an AI product. In the console they are called AI tasks, Live names, Pipelines and Design a solution; the API calls AI tasks "workloads" and live names "deployments". Every call they make is billed to the account like a normal request.

### Switch a feature to a cheaper model (AI tasks)

Follow these steps in order. Do not skip a step or claim a result the report does not give.

1. **Create the task with what a good answer looks like.** `POST /v1/workloads`:
   ```json
   { "name": "WhatsApp orders", "monthly_volume": 200000,
     "contract": { "output": "json",
       "schema": { "type": "object", "required": ["phone"], "properties": { "phone": { "type": "string" } } },
       "fields": [{ "path": "phone", "match": "exact", "critical": true }] } }
   ```
   `output` is `json`, `text` or `tool_call`. A field marked `critical` fails the whole answer when wrong. `match` is one of `exact`, `normalized`, `number` (with `tolerance`), `set`, `contains`, `present`, `judge` (needs a `rubric`). Schema keywords allowed: type, properties, required, items, enum, const, minimum, maximum, minLength, maxLength, pattern, anyOf, oneOf, nullable, additionalProperties.
2. **Add real examples** from the app, not invented ones: at least 20, ideally 100 or more. `POST /v1/workloads/{id}/cases` with `Content-Type: application/x-ndjson`, one per line: `{"id": "order-1", "messages": [{"role": "user", "content": "..."}], "expected": {...}, "slice": "pidgin"}`. `expected` is optional: Bridge fills it from the current model. For `tool_call` tasks each example needs `tools`, and `expected` is `{"name": "...", "arguments": {...}}`.
3. **Save the current setup.** `POST /v1/workloads/{id}/configs` with `{ "label": "Current", "spec": { "model": "<model the app uses now>", "instructions": "<its system prompt>" } }`. Options: `tools`, `tool_choice`, `fallback_config_id` (backup model), `similar_examples: { "count": 3 }`, `repair` (default true), `params` (temperature, max_tokens, reasoning_effort).
4. **Check the cost, then run.** `POST /v1/workloads/{id}/runs/estimate` with the run body below; tell the user the `estimate` (likely) and `estimate_max`. Then `POST /v1/workloads/{id}/runs`:
   ```json
   { "kind": "adapt", "source": { "config_id": "<current setup>" }, "target_models": ["gemini-2.5-flash-lite"], "budget": 2000 }
   ```
   `budget` is the spending limit in the account's currency; a run never goes over it. At most 2 runs at once per account.
5. **Wait for it.** Poll `GET /v1/workloads/{id}/runs/{run}` every 30–60 seconds until `status` is `completed`, `failed` or `cancelled`. Runs take minutes.
6. **Act on `report.recommendations[].action` exactly:**
   - `switch`: the cheaper setup is as good. Its saved setup id is in `report.saved_configs`.
   - `switch_with_fallback`: as good only with the current model as backup. The saved setup already names it.
   - `keep_current`: do not switch. Tell the user why (`reason`).
   - `need_more_cases`: not sure yet. Tell the user how many more examples `reason` asks for. Do not switch.
7. **Go live.** `POST /v1/workloads/{id}/deployments` with `{ "name": "orders", "config_id": "<saved setup>" }`. This needs an admin key. The app then calls `"model": "cfg:orders"` like any model: same SDK, same messages, tools and streaming work. For JSON tasks read `wekoya.configuration.valid` in the reply. To try it on part of the traffic first, use `PATCH /v1/deployments/{id}` with `{ "canary_config_id": "...", "canary_percent": 10 }`; undo with `POST /v1/deployments/{id}/rollback`.

Rules: never send customer data you were not given as examples; never set `learn_from_backup` or `auto_check` without the user's say-so (the first stores requests, the second spends money every month).

### Pipelines

Several steps behind one endpoint. `POST /v1/workflows` (admin key):
```json
{ "name": "voice-orders", "steps": [
  { "id": "hear", "type": "transcribe", "audio": "{{input.audio}}", "model": "nova-3" },
  { "id": "order", "type": "chat", "model": "cfg:orders", "prompt": "{{steps.hear}}", "json": true },
  { "id": "complaint", "type": "decide", "state": "{{steps.hear}}", "questions": { "complaint": { "type": "noul", "instructions": "Is the customer complaining?" } } },
  { "id": "reply", "type": "speak", "text": "Thanks {{steps.order.customer_name}}, your order is on its way." } ] }
```
- Step types: `transcribe` (`audio`), `chat` (`model` or `cfg:<name>`, `prompt`, optional `instructions`, `attach` for a file, `json`, `schema`), `decide` (`state`, Jev `questions`), `speak` (`text`, optional `voice`), `image` (`prompt`, `n` 1–4, optional `images`: up to 4 pictures to edit or combine, e.g. `["{{input.photo}}", "{{steps.draw.image}}"]`; output `{ image, images }` as data URLs), `music` (`prompt`; output `{ audio, duration, lyrics }`). Up to 8 steps and 8 pictures per pipeline. A step may read `{{input.<field>}}` or an earlier step: `{{steps.<id>}}` or `{{steps.<id>.<field>}}`.
- Run: `POST /v1/workflows/{name}/run` with `{ "input": { "audio": "data:audio/ogg;base64,..." } }` (files as data URLs, 25 MB in all). The reply may start with spaces while it works. Check `status`: `completed`, or `failed` with `error.step` and `error.message`. Each step's output, time and cost are in `steps`; a speak step's `output.audio` is a data URL.
- Every run costs money, and pictures are the dearest step (about ₦57 or more each): tell the user before running, and make no more pictures than they asked for. Embeddings are not a pipeline step: call `/embeddings` directly and keep vectors in the app's own database.

### Design a solution

To plan an AI product from a description, `POST /v1/solutions` with `{ "problem": "<at least a sentence>", "constraints": { "languages": [...], "latency": "realtime" | "interactive" | "background", "monthly_volume": 50000 } }`. It takes up to a minute and may reply 200 with leading spaces; check for `error`. The reply's `blueprint` has `steps` (each with `capability`, `model`, `cost_per_unit`, `alternatives`, `instructions`), `cost_per_unit`, `monthly_cost`, `risks`, `questions` and a suggested `contract`.
- Show the user the steps, the cost and the questions before building anything.
- To ask about a design or change it, `POST /v1/solutions/{id}/messages` with `{ "message": "..." }` (up to 2,000 characters; replies like a design, so check for `error`). The reply has `messages` (the question and the answer, whose `content` is Markdown and whose `suggestions` are follow-ups to offer) and `revision`: a new version of the design when the answer changed it, otherwise `null`. Ask only on the newest version: an older one answers 409 `solution_superseded` with `latest_id`. `GET /v1/solutions/{id}` returns `versions`, `latest_id` and the whole `thread`.
- `POST /v1/solutions/{id}/workload` turns its chat step into an AI task; `POST /v1/solutions/{id}/workflow` turns it into a pipeline (steps it cannot run are listed in `left_out`). A `realtime` step (a live voice conversation) is never part of a pipeline: build it on `/v1/realtime` as above, and its cost is per minute of call.
- Its model choices come from prices and capabilities, not from tests on the user's data. Test the important steps with an AI task before promising quality.

## Step 5: handle errors

Every error body is `{ "error": { "type": "...", "message": "..." } }`. Read the body on any non-2xx reply, log the status and `error.type` on the server (never the key), and show the user a friendly message.

| Status | Types | What to do |
| --- | --- | --- |
| 400 | `invalid_request`, `end_user_required` | A problem with the request. Fix it; do not retry. |
| 401 | `unauthorized`, `guest_key_revoked` | Key missing, wrong, revoked or expired. Do not retry. Tell the developer to check `WEKOYA_API_KEY`; an app holding a guest key asks its server for a new one. |
| 402 | `insufficient_funds`, `key_limit_exceeded`, `end_user_budget_exhausted` | Out of money or over a spending limit. Do not retry. Show a clear message such as "AI is unavailable right now". Treat any 402 this way. |
| 403 | `insufficient_scope`, `model_not_allowed`, `guest_key_not_allowed`, `origin_not_allowed` | The key cannot do this, use this model, or be used from this website. Do not retry. |
| 404 | `model_not_found`, `end_user_not_found` | Unknown model id (pick one from `GET /models`), or an end user that has not been created. |
| 429 | `rate_limited`, `end_user_rate_limited`, `guest_key_used_up` | Retry after a short wait, with backoff (for example 1s, 2s, 4s, at most 3 tries). A used-up guest key needs a new one instead. |
| 503 | `model_unavailable` | Retry with backoff, or fall back to another model. |
| 5xx | any | Retry with backoff a few times, then show a friendly error. |

## Step 6: check it works

- If `WEKOYA_API_KEY` is set, make one small request and confirm a 200 before you finish.
- If it is not set, tell the user exactly how to get a key (the three steps in `.env.example`), where to put it, and the command to start the app.

## Before you finish

- [ ] The key is read from `WEKOYA_API_KEY` on the server only, and is not in any public variable or client code.
- [ ] `.env.example` has the three variables and the steps; `.env` is git-ignored and loaded.
- [ ] The base URL ends in `/v1` and the request path is `/chat/completions`.
- [ ] The Vercel AI SDK, if used, calls `.chat(model)` (or, on the Responses API, sends the whole conversation each time).
- [ ] Errors are handled as in the table, and 402 shows a friendly message.
- [ ] One test request returned 200, or the user knows how to add their key.
