# Codex Auth API

OpenAI-compatible API backed by the Codex account connected to each codexauthapi.dev API key.

- Base URL: `https://codexauthapi.dev/v1`
- Interactive documentation: `https://codexauthapi.dev/docs`
- OpenAPI 3.1 specification: `https://codexauthapi.dev/openapi.json`
- This Markdown document: `https://codexauthapi.dev/docs.md`

> ChatGPT-plan authentication outside the Codex application is unofficial and may change. Each user must connect their own Codex account.

## Authentication

Create an API key at `https://codexauthapi.dev/dashboard` after connecting Codex. Send the key as an HTTP bearer token on every `/v1/*` request.

```http
Authorization: Bearer $KEY
```

Keys are scoped to the account that created them. Requests use that account's connected Codex session.

## Quick start

```bash
curl https://codexauthapi.dev/v1/chat/completions \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.6-sol",
    "messages": [{"role": "user", "content": "Hello"}]
  }'
```

### OpenAI Python SDK

```python
from openai import OpenAI

client = OpenAI(
    base_url="https://codexauthapi.dev/v1",
    api_key=KEY,
)

response = client.chat.completions.create(
    model="gpt-5.6-sol",
    messages=[{"role": "user", "content": "Hello"}],
)
print(response.choices[0].message.content)
```

## Streaming

Set `stream: true` on either text endpoint.

- Chat Completions returns `text/event-stream` containing OpenAI-compatible `chat.completion.chunk` objects and ends with `data: [DONE]`. Function calls arrive as indexed `delta.tool_calls` fragments; assemble each function's `arguments` string by `index`. The final chunk has `finish_reason: "tool_calls"` when calls were produced.
- Responses preserves canonical typed SSE events, including `response.output_item.added/done`, `response.function_call_arguments.delta/done`, and `response.completed`.

```python
stream = client.chat.completions.create(
    model="gpt-5.6-sol",
    messages=[{"role": "user", "content": "Hello"}],
    stream=True,
)
for chunk in stream:
    print(chunk.choices[0].delta.content or "", end="")
```

## Endpoints

### GET `/v1/health`

Checks the API key and the Codex connection belonging to that key. Send the API key in the bearer authorization header, never in the URL.

```bash
curl https://codexauthapi.dev/v1/health \
  -H "Authorization: Bearer $CODEXAUTH_API_KEY"
```

- HTTP 200 with `status: "connected"`: the API key is valid and Codex authorization is working.
- HTTP 401: the API key is missing, invalid, or revoked.
- HTTP 503 with `status: "disconnected"`: the API key is valid, but Codex must be connected or reconnected from the dashboard.

This makes the endpoint user-specific without exposing credentials in browser history, access logs, referrer headers, or query strings.

### GET `/v1/models`

Lists models currently exposed by Codex in OpenAI-compatible model-list format. Use each returned object's `id` in generation requests.

```bash
curl https://codexauthapi.dev/v1/models \
  -H "Authorization: Bearer $KEY"
```

### POST `/v1/chat/completions`

Creates an OpenAI-compatible chat completion. Supports multi-turn messages, text and image content, reasoning effort, and streaming.

Request fields:

- `model` string, optional: model ID; defaults to `gpt-5.6-sol`
- `messages` array, required: system, developer, user, assistant, and tool result messages. Assistant messages may contain `tool_calls`; tool messages use `tool_call_id`.
- `tools` array, optional: up to 128 nested function definitions (`{type: "function", function: {name, description, parameters, strict}}`)
- `tool_choice` optional: `none`, `auto`, `required`, or a forced function object such as `{"type":"function","function":{"name":"get_weather"}}`
- `parallel_tool_calls` boolean, optional: allow multiple calls in one assistant turn
- `reasoning_effort` string, optional: reasoning level supported by the selected model
- `stream` boolean, optional: return SSE chunks when true

The gateway validates and relays tools but never executes them. A non-streaming tool turn has `message.content: null`, one or more `message.tool_calls`, stable call IDs, and `finish_reason: "tool_calls"`. Execute each function in your client, then send its result as a `role: "tool"` message with the matching `tool_call_id`.

```json
{
  "model": "gpt-5.6-sol",
  "messages": [{"role": "user", "content": "Weather in Paris?"}],
  "tools": [{
    "type": "function",
    "function": {
      "name": "get_weather",
      "description": "Get current weather",
      "parameters": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"]
      },
      "strict": true
    }
  }],
  "tool_choice": "auto",
  "parallel_tool_calls": true
}
```

Tool-result continuation:

```json
{
  "model": "gpt-5.6-sol",
  "messages": [
    {"role": "user", "content": "Weather in Paris?"},
    {"role": "assistant", "content": null, "tool_calls": [{
      "id": "call_abc", "type": "function",
      "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
    }]},
    {"role": "tool", "tool_call_id": "call_abc", "content": "{\"temperature_c\":21}"}
  ],
  "tools": [{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}]
}
```

### POST `/v1/responses`

Creates an OpenAI Responses-style response from a string or structured input. Supports non-streaming JSON and typed SSE streams.

Request fields:

- `model` string, optional: model ID; defaults to `gpt-5.6-sol`
- `input` string or array, required: input text, structured messages, `function_call`, and `function_call_output` items
- `instructions` string, optional: developer-level instructions
- `tools` array, optional: up to 128 flat function definitions (`{type: "function", name, description, parameters, strict}`)
- `tool_choice` optional: `none`, `auto`, `required`, or `{"type":"function","name":"get_weather"}`
- `parallel_tool_calls` boolean, optional: allow multiple calls in one response
- `reasoning.effort` string, optional: reasoning level
- `stream` boolean, optional: emit canonical typed Responses API events when true

Function calls are included as `type: "function_call"` output items. Return client-executed results in a subsequent request as `type: "function_call_output"` items with the same `call_id`.

```json
{
  "model": "gpt-5.6-sol",
  "input": [
    {"role": "user", "content": [{"type": "input_text", "text": "Weather in Oslo?"}]},
    {"type": "function_call", "call_id": "call_abc", "name": "get_weather", "arguments": "{\"city\":\"Oslo\"}"},
    {"type": "function_call_output", "call_id": "call_abc", "output": "{\"temperature_c\":9}"}
  ],
  "tools": [{
    "type": "function",
    "name": "get_weather",
    "description": "Get current weather",
    "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
  }],
  "tool_choice": "auto",
  "parallel_tool_calls": true,
  "stream": true
}
```

### POST `/v1/images/generations`

Generates an image and returns OpenAI-compatible base64 image data. Every image response includes `X-Request-Id`. You may send a canonical UUID in `X-Request-Id`; invalid or unsafe values are replaced with a generated UUID. Image work has a bounded server timeout and is cancelled if the client disconnects.

Request fields:

- `prompt` string, required: image description
- `size` string, optional: `1024x1024`, `1536x1024`, or `1024x1536`
- `quality` string, optional: `low`, `medium`, or `high`
- `output_format` string, optional: defaults to `png`

```json
{
  "model": "gpt-image-1",
  "prompt": "A technical blueprint of a lunar rover",
  "size": "1536x1024",
  "quality": "medium"
}
```

### POST `/v1/images/edits`

Edits one or more supplied images. Accepts multipart form data for files or JSON with image data URLs.

Request fields:

- `image` file, file array, data URL, or data URL array, required
- `prompt` string, required: edit instruction
- `size` string, optional: output dimensions
- `quality` string, optional: output quality

```bash
curl https://codexauthapi.dev/v1/images/edits \
  -H "Authorization: Bearer $KEY" \
  -F "image=@source.png" \
  -F "prompt=Make the background transparent" \
  -F "size=1024x1024"
```

## Codex plan usage limits

The authenticated [`/usage`](https://codexauthapi.dev/usage) page shows current provider-reported Codex limits separately from requests and tokens recorded by CodexAuthAPI. The plan limit percentages are not derived from local token counts.

### GET `/v1/limits` (alias: `/limits`)

Requires a bearer API key and returns the normalized provider quota schema below for that API key’s owner. It never uses the browser session to select an owner. Account identifiers, email, OAuth tokens, and API-key data are omitted. Successes and errors return `Cache-Control: private, no-store`.

```bash
curl https://codexauthapi.dev/v1/limits \
  -H "Authorization: Bearer $CODEXAUTHAPI_KEY"
```

### GET `/api/provider-usage`

Requires the logged-in `cxa_session` browser cookie and a connected Codex account. It fetches the owner’s current Codex quota windows, removes account identifiers and OAuth data, and returns `Cache-Control: private, no-store`.

Each `limits[].windows[]` item contains:

- `kind`: `five_hour`, `weekly`, or `other`
- `label`: display label such as `5-hour` or `Weekly`
- `usedPercent` and `remainingPercent`: provider-reported utilization, clamped to 0–100
- `windowSeconds`: provider-reported duration
- `resetsAt`: ISO timestamp derived from Codex’s absolute reset time

```json
{
  "available": true,
  "fetchedAt": "2026-08-08T12:00:00.000Z",
  "planType": "pro",
  "limits": [{
    "id": "codex",
    "name": "Codex",
    "meteredFeature": null,
    "allowed": true,
    "limitReached": false,
    "windows": [
      { "kind": "five_hour", "label": "5-hour", "usedPercent": 25, "remainingPercent": 75, "windowSeconds": 18000, "resetsAt": "2026-08-08T17:00:00.000Z" },
      { "kind": "weekly", "label": "Weekly", "usedPercent": 60, "remainingPercent": 40, "windowSeconds": 604800, "resetsAt": "2026-08-12T12:00:00.000Z" }
    ]
  }]
}
```

Windows are identified by their reported duration, not by assuming `primary` means 5-hour or `secondary` means weekly. Codex may return either window in either position, omit one entirely, or report additional model-specific limits. CodexAuthAPI displays only windows present in the provider response and never fabricates a missing 5-hour or weekly quota. Reset times are account-specific backend values, not assumed calendar-week boundaries. Results are cached per owner/account for 30 seconds.

## Speech to text

### POST `/v1/audio/transcriptions`

Transcribe an uploaded audio file with the bearer API key owner's connected Codex OAuth session. This uses the same private batch transcription route as the Codex prompt box. Audio bytes and completed transcripts are not logged or stored by CodexAuthAPI.

To test transcription without writing code, log in and open the [Speech-to-Text page](https://codexauthapi.dev/speech-to-text). It records from the browser microphone or accepts a supported audio file, then calls a same-origin session endpoint without exposing an API key in page code.

Send `multipart/form-data` fields:

- `file` (required): one non-empty audio file, up to 25 MB. Accepted containers are FLAC, M4A, MP3/MP4/MPEG/MPGA, OGA/OGG, WAV, and WebM.
- `model` (required): `codex-transcribe` or the compatibility alias `gpt-4o-mini-transcribe`.
- `language`: optional ISO language code such as `en` or `en-US`.
- `response_format`: `json` (default, `{ "text": "..." }`) or `text`.

```bash
curl https://codexauthapi.dev/v1/audio/transcriptions \
  -H "Authorization: Bearer $CODEX_API_KEY" \
  -F "file=@recording.webm" \
  -F "model=codex-transcribe" \
  -F "language=en"
```

OpenAI Python SDK:

```python
from openai import OpenAI

client = OpenAI(base_url="https://codexauthapi.dev/v1", api_key=KEY)
with open("recording.webm", "rb") as audio:
    result = client.audio.transcriptions.create(
        model="codex-transcribe",
        file=audio,
        language="en",
    )
print(result.text)
```

Successful responses use `Cache-Control: no-store`. This is a restricted OpenAI transcription compatibility subset: `prompt`, `temperature`, timestamps, and `verbose_json` are not supported because Codex's prompt-box route does not accept or return them. Requests have a 180-second service timeout plus per-owner and global admission limits. A transient Cloudflare challenge is retried once with the exact same audio; OAuth `401` responses trigger one owner-scoped token refresh and one replay.

## Text to speech

### POST `/v1/audio/speech`

Generate speech with the bearer API key owner's connected Codex OAuth session. The endpoint uses Codex's GA realtime WebSocket output-audio stream server-side and returns audio only after the upstream response completes. Credentials, input text, transcripts, and audio are not logged or stored.

To test synthesis without writing code, log in and open the [Text-to-Speech page](https://codexauthapi.dev/text-to-speech). Select a voice, optionally enter style guidance, and play the generated WAV in your browser.

JSON fields:

- `input` (required): 1–1000 characters.
- `model`: `codex-tts` (default), `gpt-4o-mini-tts` compatibility alias, or `gpt-realtime-1.5`.
- `voice`: `marin` (default), `alloy`, `ash`, `ballad`, `cedar`, `coral`, `echo`, `sage`, `shimmer`, or `verse`.
- `response_format`: `wav` (default, 24 kHz mono 16-bit PCM in a WAV container) or `pcm` (raw 24 kHz mono signed 16-bit little-endian PCM).
- `speed`: currently must be `1`.
- `instructions`: optional voice-style guidance, up to 500 characters. The spoken text must still match `input`.

```bash
curl https://codexauthapi.dev/v1/audio/speech \
  -H "Authorization: Bearer $CODEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"codex-tts","input":"Hello from Codex.","voice":"marin","response_format":"wav"}' \
  --output speech.wav
```

OpenAI Python SDK:

```python
from pathlib import Path
from openai import OpenAI

client = OpenAI(base_url="https://codexauthapi.dev/v1", api_key=KEY)
with client.audio.speech.with_streaming_response.create(
    model="codex-tts",
    voice="marin",
    input="Hello from Codex.",
    response_format="wav",
) as response:
    response.stream_to_file(Path("speech.wav"))
```

Successful responses use `Cache-Control: no-store`. This is a restricted OpenAI speech compatibility subset: MP3 and other encoded formats are not available, and `speed` values other than `1` are rejected. Codex speech is model-mediated: the service instructs the realtime model to read the input literally and verifies its completed output transcript against the input before returning audio. A mismatch fails with `502 incomplete_speech` instead of returning known-wrong audio. Requests have a 60-second timeout plus per-owner and global admission limits.

## Realtime voice transport

The authenticated Voice Lab at [`/voice`](https://codexauthapi.dev/voice) tests the internal/experimental Codex Frameless Bidi V3 WebRTC voice transport. API clients can register their own OpenAI-style function tools when creating a voice session. Functions always execute in the caller’s application; Codex Auth API only transports calls and results. Authentication comes from the owner’s Codex/ChatGPT OAuth connection stored by this service.

### POST `/api/voice/session`

Exchange a browser-generated, ICE-complete WebRTC SDP offer for an SDP answer. Browser calls require the logged-in `cxa_session` cookie and a same-origin `Origin`. Origin-less API clients may use an existing cxa bearer key; the API key’s owner must have connected Codex. JSON fields:

- `sdp`: required bounded WebRTC SDP offer containing audio media, ICE, and fingerprint attributes
- `voice`: optional Codex V3 voice; one of `juniper`, `maple`, `spruce`, `ember`, `vale`, `breeze`, `arbor`, `sol`, or `cove` (default)
- `tools`: optional array of 1–128 flat OpenAI function definitions. Each has `type: "function"`, a unique 1–64 character `name`, optional `description`, optional object JSON Schema `parameters` (defaults to an empty object schema), and optional Boolean `strict`. The session endpoint validates the definition and places a bounded name/description registry in the voice instructions; your backend resubmits the full tools to `/v1/responses`, then allowlists names and validates arguments before execution.
- `tool_choice`: with `tools`, optional `auto` (default), `none`, `required`, or `{ "type": "function", "name": "supplied_name" }`
- `instructions`: with `tools`, optional non-empty assistant instructions up to 16,384 characters
- `response_model`: with `tools`, optional model used by the Responses adapter; defaults to `gpt-5.5`
- `conversation_only`: optional Boolean, default `false`. When `true`, creates a conversational session with no client delegation or tools. It cannot be combined with `tools`, `tool_choice`, `instructions`, or `response_model`; the server owns the fixed no-tools instructions. This mode is used by the curl-installed client's private localhost dashboard.

```json
{
  "sdp": "v=0\\r\\n…",
  "voice": "cove",
  "instructions": "Use functions when needed.",
  "tools": [{
    "type": "function",
    "name": "get_weather",
    "description": "Get current weather for a city.",
    "parameters": {
      "type": "object",
      "properties": { "city": { "type": "string" } },
      "required": ["city"],
      "additionalProperties": false
    },
    "strict": true
  }],
  "tool_choice": "auto"
}
```

The `201` response contains `sdp` and `protocol`. `protocol` identifies experimental Frameless Bidi WebRTC version 3, model `gpt-live-1-codex`, selected voice, the `oai-events` data-channel label, and `delegation: "client"`. With custom tools it also reports `functionTransport: "responses-adapter"`, `responseModel`, normalized `toolChoice`, and `toolCount`. It contains no OAuth token, account ID, upstream call ID, or tool implementation. Responses use `Cache-Control: private, no-store`. The server retries one upstream 401 after refreshing the owner’s OAuth token and rate-limits session creation per owner.

The browser sets the returned SDP as its remote answer. Microphone and assistant audio are WebRTC media tracks; audio is not copied into base64 data-channel events. Create the `oai-events` data channel before creating the WebRTC offer, then parse each incoming data-channel message as one UTF-8 JSON object. The transport does not require a local WebSocket or sideband connection.

### Transcript event contract

Use the event type to select the payload path. Do not assume transcript text is in a top-level `delta`, `text`, or `transcript` property.

| Event type | Role | Text path | Rendering action |
| --- | --- | --- | --- |
| `input_transcript.added` | user | `event.item.text` | Append to the current user draft |
| `output_transcript.added` | assistant | `event.item.text` | Append to the current assistant draft |
| `turn.done` | `event.turn.role` | `event.turn.transcript` | Replace that role’s draft with the authoritative final transcript, then finalize the row |

Representative data-channel messages:

```json
{"type":"input_transcript.added","item":{"text":"Can you "}}
{"type":"input_transcript.added","item":{"text":"hear me?"}}
{"type":"turn.done","turn":{"role":"user","transcript":"Can you hear me?"}}
{"type":"output_transcript.added","item":{"text":"Yes, I "}}
{"type":"output_transcript.added","item":{"text":"can."}}
{"type":"turn.done","turn":{"role":"assistant","transcript":"Yes, I can."}}
```

Keep separate in-progress buffers for `user` and `assistant`, because events can interleave. The `*.added` text is an incremental fragment, so append it exactly once. The `turn.done` transcript is a complete replacement, not another delta; appending it would duplicate the sentence. Ignore unknown event types so an upstream addition does not break the session.

A minimal event mapper:

```js
function mapVoiceEvent(event) {
  if (event?.type === 'input_transcript.added') {
    return { kind: 'transcript', role: 'user', text: String(event.item?.text || ''), done: false };
  }
  if (event?.type === 'output_transcript.added') {
    return { kind: 'transcript', role: 'assistant', text: String(event.item?.text || ''), done: false };
  }
  if (event?.type === 'turn.done' && ['user', 'assistant'].includes(event.turn?.role)) {
    return { kind: 'transcript', role: event.turn.role, text: String(event.turn.transcript || ''), done: true };
  }
  if (event?.type === 'error') return { kind: 'error', message: 'The realtime service reported an error.' };
  return null;
}

channel.addEventListener('message', ({ data }) => {
  let event;
  try { event = JSON.parse(data); } catch { return; }
  const mapped = mapVoiceEvent(event);
  if (!mapped) return;
  if (mapped.kind === 'transcript') {
    // done=false: append mapped.text to that role’s draft.
    // done=true: replace the draft with mapped.text and finalize it.
    renderTranscript(mapped.role, mapped.text, mapped.done);
  }
});
```

### Custom function-call contract

Supplying `tools` registers a client-delegation Responses adapter. The server places a bounded tool-name/description registry in the voice instructions so Frameless Bidi can delegate relevant requests. Functions use the flat OpenAI Responses shape, not the Chat Completions `{ type: "function", function: {...} }` wrapper.

#### Choose the correct deployment topology

Do not put a cxa API key in browser JavaScript. An external website should use its own authenticated backend as a narrow proxy:

```text
Browser                         Your application backend              Codex Auth API
-------                         ------------------------              --------------
getUserMedia + RTCPeerConnection
POST /voice/session ----------> inject fixed tools + bearer key ----> POST /api/voice/session
<----------------------------- SDP answer + app session ID <-------- 201 SDP answer

WebRTC oai-events:
delegation.created
POST /voice/delegation -------> verify app session; select/execute --> POST /v1/responses (SSE)
  (app session ID + item ID)    allowlisted tool
<----------------------------- sanitized result <-------------------- completed function call
send delegation.context.append over the existing oai-events channel
```

The Codex Auth API browser routes are only for the same-origin, logged-in Voice Lab:

- External browser origins cannot call `/api/voice/session` directly, even with a bearer key. Send the browser-generated SDP to your backend, then make an origin-less server-to-server request with `Authorization: Bearer $CODEXAUTHAPI_KEY`.
- External applications use bearer-authenticated `https://codexauthapi.dev/v1/responses` from their backend.
- `/api/voice/responses` is a same-origin, session-cookie route for Codex Auth API's own `/voice` page. It is not a cross-origin replacement for `/v1/responses`.

#### Complete browser WebRTC client

This browser code calls two routes on **your own backend**: `/voice/session` and `/voice/delegation`. Your backend implementations are shown in the next section. Render a per-session CSRF token into `<meta name="csrf-token" content="…">` and validate the `X-CSRF-Token` header on both routes.

```js
const VOICE = 'cove';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
if (!csrfToken) throw new Error('CSRF token is missing');
const pendingDelegations = new Map();
let applicationVoiceSessionId;
let peer;
let events;

function waitForIceComplete(pc, timeoutMs = 10_000) {
  if (pc.iceGatheringState === 'complete') return Promise.resolve();
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => finish(new Error('ICE gathering timed out')), timeoutMs);
    function finish(error) {
      clearTimeout(timer);
      pc.removeEventListener('icegatheringstatechange', onChange);
      error ? reject(error) : resolve();
    }
    function onChange() {
      if (pc.iceGatheringState === 'complete') finish();
    }
    pc.addEventListener('icegatheringstatechange', onChange);
  });
}

function delegatedPrompt(event) {
  const item = event?.item;
  if (item?.type !== 'delegation' || item.target !== 'client' || !item.id) {
    throw new Error('Invalid delegation event');
  }
  const prompt = (item.content || [])
    .filter((part) => part?.type === 'input_text' && typeof part.text === 'string')
    .map((part) => part.text)
    .join(' ')
    .trim();
  if (!prompt) throw new Error('Delegation has no input text');
  return { delegationItemId: item.id, prompt };
}

function appendDelegationResult(channel, delegationItemId, result) {
  let text;
  if (result.ok) {
    // function_call_output is encoded as text context for the live model.
    // The natural-language prefix is recommended, not a separately parsed protocol field.
    text = `Function ${result.name} completed successfully. ${JSON.stringify({
      type: 'function_call_output',
      call_id: result.call_id,
      output: result.output // already a JSON string
    })}`;
  } else {
    // Always resolve a failed/no-call delegation so the spoken turn is not left waiting.
    text = `The requested function was not executed. ${result.error}`;
  }
  channel.send(JSON.stringify({
    type: 'delegation.context.append',
    delegation_item_id: delegationItemId,
    channel: 'speakable',
    content: [{ type: 'input_text', text }]
  }));
}

async function handleDelegation(event) {
  const { delegationItemId, prompt } = delegatedPrompt(event);
  if (pendingDelegations.has(delegationItemId)) return; // duplicate event

  const controller = new AbortController();
  const work = (async () => {
    try {
      const response = await fetch('/voice/delegation', {
        method: 'POST',
        credentials: 'same-origin',
        signal: controller.signal,
        headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
        body: JSON.stringify({
          application_voice_session_id: applicationVoiceSessionId,
          delegation_item_id: delegationItemId,
          prompt
        })
      });
      const result = await response.json().catch(() => null);
      if (!response.ok || !result) throw new Error(result?.error || 'Function request failed');
      appendDelegationResult(events, delegationItemId, result);
    } catch {
      if (controller.signal.aborted || events?.readyState !== 'open') return;
      appendDelegationResult(events, delegationItemId, {
        ok: false,
        error: 'The function request failed safely. Please try again.'
      });
    }
  })();
  pendingDelegations.set(delegationItemId, { controller, work });
  await work;
}

export async function startVoice(remoteAudioElement) {
  const microphone = await navigator.mediaDevices.getUserMedia({ audio: true });
  peer = new RTCPeerConnection();
  for (const track of microphone.getAudioTracks()) peer.addTrack(track, microphone);

  peer.addEventListener('track', (event) => {
    remoteAudioElement.srcObject = event.streams[0] || new MediaStream([event.track]);
    remoteAudioElement.autoplay = true;
    remoteAudioElement.play().catch(() => {}); // a user gesture may still be required
  });

  // This label must exist before createOffer().
  events = peer.createDataChannel('oai-events');
  events.addEventListener('message', ({ data }) => {
    let event;
    try { event = JSON.parse(data); } catch { return; }
    if (event.type === 'delegation.created') void handleDelegation(event);
    if (event.type === 'delegation.context.appended') {
      pendingDelegations.delete(event.delegation_item_id);
    }
    // Handle input_transcript.added, output_transcript.added, and turn.done here too.
  });

  await peer.setLocalDescription(await peer.createOffer());
  await waitForIceComplete(peer);

  const response = await fetch('/voice/session', {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
    body: JSON.stringify({ sdp: peer.localDescription.sdp, voice: VOICE })
  });
  const answer = await response.json().catch(() => null);
  if (!response.ok || !answer?.sdp) {
    throw new Error(answer?.error?.message || answer?.error || 'Voice negotiation failed');
  }
  applicationVoiceSessionId = answer.application_voice_session_id;
  if (!applicationVoiceSessionId) throw new Error('Application voice session ID is missing');
  await peer.setRemoteDescription({ type: 'answer', sdp: answer.sdp });
}

export function stopVoice() {
  for (const { controller } of pendingDelegations.values()) controller.abort();
  pendingDelegations.clear();
  events?.close();
  for (const sender of peer?.getSenders() || []) sender.track?.stop();
  peer?.close();
  applicationVoiceSessionId = undefined;
}
```

#### Application backend

Keep the tool registry and implementations on your backend. Authenticate both proxy routes, enforce CSRF protection for cookie-authenticated requests, rate-limit them, and bind delegation work to the same application user/session that created the voice call. The following Node/Express example assumes `express.json()` and your own browser authentication middleware are already enabled.

```js
const { randomUUID } = require('node:crypto');
const CODEX_BASE = 'https://codexauthapi.dev';
const CODEXAUTHAPI_KEY = process.env.CODEXAUTHAPI_KEY;
const RESPONSE_MODEL = 'gpt-5.5';
const TOOL_CHOICE = 'auto';
const APPLICATION_SESSION_TTL_MS = 10 * 60_000;
const MAX_TOOL_OUTPUT_CHARS = 16_384; // application policy; summarize larger results
if (!CODEXAUTHAPI_KEY) throw new Error('CODEXAUTHAPI_KEY is required');

const voiceSessions = new Map(); // use your shared TTL store in multi-process production
const delegationWork = new Map(); // cache result promises for idempotency

const tools = [{
  type: 'function',
  name: 'get_weather',
  description: 'Get current weather for a city.',
  strict: true,
  parameters: {
    type: 'object',
    properties: { city: { type: 'string', minLength: 1, maxLength: 100 } },
    required: ['city'],
    additionalProperties: false
  }
}];

const functions = {
  async get_weather(args, { signal }) {
    // Validate with your JSON Schema library before execution. This explicit
    // check enforces every constraint in the dependency-free sample schema.
    if (!args || typeof args.city !== 'string' || !args.city.trim()
        || args.city.length > 100
        || Object.keys(args).some((key) => key !== 'city')) {
      throw new Error('Invalid get_weather arguments');
    }
    signal.throwIfAborted();
    return { city: args.city, temperature: 72, unit: 'fahrenheit' };
  }
};

async function codexFetch(path, init) {
  return fetch(`${CODEX_BASE}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${CODEXAUTHAPI_KEY}`,
      'Content-Type': 'application/json',
      ...init.headers
    }
  });
}

// Browser -> your backend -> Codex Auth API. Never forward a browser Origin.
app.post('/voice/session', requireYourUser, requireCsrf, async (req, res) => {
  const upstream = await codexFetch('/api/voice/session', {
    method: 'POST',
    body: JSON.stringify({
      sdp: req.body.sdp,
      voice: req.body.voice || 'cove',
      instructions: 'Delegate requests that match a registered function and wait for its result.',
      tools,
      tool_choice: TOOL_CHOICE,
      response_model: RESPONSE_MODEL
    })
  });
  const body = await upstream.text();
  if (!upstream.ok) {
    return res.status(upstream.status)
      .set('Content-Type', upstream.headers.get('content-type') || 'application/json')
      .send(body);
  }
  let answer;
  try { answer = JSON.parse(body); }
  catch { return res.status(502).json({ error: 'Voice service returned an invalid answer' }); }

  const applicationVoiceSessionId = randomUUID();
  voiceSessions.set(`${req.user.id}:${applicationVoiceSessionId}`, Date.now() + APPLICATION_SESSION_TTL_MS);
  res.status(201).json({ ...answer, application_voice_session_id: applicationVoiceSessionId });
});

function parseSseBlock(block) {
  const data = block.split(/\r?\n/)
    .filter((line) => line.startsWith('data:'))
    .map((line) => line.slice(5).replace(/^ /, ''))
    .join('\n');
  if (!data || data === '[DONE]') return null;
  try { return JSON.parse(data); }
  catch { throw new Error('Malformed Responses SSE event'); }
}

async function readOneCompletedFunctionCall(response, maxBytes = 512 * 1024) {
  if (!response.ok || !response.body) {
    throw new Error(`Responses request failed with HTTP ${response.status}`);
  }
  if (!String(response.headers.get('content-type')).includes('text/event-stream')) {
    throw new Error('Responses endpoint did not return SSE');
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  const calls = new Map();
  let completed = false;
  let received = 0;
  let buffer = '';

  function consume(event) {
    if (!event) return;
    if (event.type === 'response.failed' || event.type === 'error') {
      throw new Error('Responses function selection failed');
    }
    if (event.type === 'response.completed') {
      completed = true;
      return;
    }
    if ((event.type === 'response.output_item.added'
        || event.type === 'response.output_item.done')
        && event.item?.type === 'function_call') {
      const item = event.item;
      if (!item.id || !item.call_id || !item.name) throw new Error('Invalid function identity');
      if (!calls.has(item.id) && calls.size) throw new Error('Multiple function calls are not allowed');
      const call = calls.get(item.id) || {
        id: item.id, call_id: item.call_id, name: item.name, arguments: '', argumentsDone: false
      };
      if (call.call_id !== item.call_id || call.name !== item.name) {
        throw new Error('Function identity changed');
      }
      if (event.type === 'response.output_item.done' && typeof item.arguments === 'string') {
        call.arguments = item.arguments;
        call.argumentsDone = true;
      }
      calls.set(item.id, call);
      return;
    }
    if (event.type === 'response.function_call_arguments.done') {
      const call = calls.get(event.item_id);
      if (!call || typeof event.arguments !== 'string') {
        throw new Error('Uncorrelated function arguments');
      }
      call.arguments = event.arguments;
      call.argumentsDone = true;
    }
  }

  for (;;) {
    const { value, done } = await reader.read();
    received += value?.byteLength || 0;
    if (received > maxBytes) throw new Error('Responses SSE body is too large');
    buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
    const blocks = buffer.split(/\r?\n\r?\n/);
    buffer = blocks.pop() || '';
    for (const block of blocks) consume(parseSseBlock(block));
    if (done) break;
  }
  if (buffer.trim()) consume(parseSseBlock(buffer));
  if (!completed) throw new Error('Responses stream ended before response.completed');
  if (calls.size !== 1) throw new Error('Expected exactly one function call');

  const call = [...calls.values()][0];
  if (!call.argumentsDone) throw new Error('Function arguments did not complete');
  let args;
  try { args = JSON.parse(call.arguments || '{}'); }
  catch { throw new Error('Function arguments are invalid JSON'); }
  if (!args || Array.isArray(args) || typeof args !== 'object') {
    throw new Error('Function arguments must be an object');
  }
  return { ...call, args };
}

app.post('/voice/delegation', requireYourUser, requireCsrf, async (req, res) => {
  const applicationVoiceSessionId = String(req.body.application_voice_session_id || '');
  const delegationId = String(req.body.delegation_item_id || '');
  const prompt = String(req.body.prompt || '').trim();
  const sessionKey = `${req.user.id}:${applicationVoiceSessionId}`;
  const expiresAt = voiceSessions.get(sessionKey);
  if (!applicationVoiceSessionId || !expiresAt || expiresAt <= Date.now()) {
    voiceSessions.delete(sessionKey);
    return res.status(409).json({ ok: false, error: 'Voice session is missing or expired' });
  }
  if (!delegationId || !prompt || prompt.length > 4_000) {
    return res.status(400).json({ ok: false, error: 'Invalid delegation request' });
  }

  // Cache by application user, voice session, and delegation ID so duplicate
  // browser events cannot execute a side-effecting function twice.
  const workKey = `${sessionKey}:${delegationId}`;
  if (!delegationWork.has(workKey)) {
    delegationWork.set(workKey, (async () => {
      const response = await codexFetch('/v1/responses', {
        method: 'POST',
        signal: AbortSignal.timeout(30_000),
        body: JSON.stringify({
          model: RESPONSE_MODEL, // session response_model becomes Responses model
          input: prompt,
          instructions: 'Select a registered function only when it fulfills the request. Never invent functions or arguments.',
          tools,
          tool_choice: TOOL_CHOICE, // retain the session's normalized choice
          parallel_tool_calls: false,
          stream: true
        })
      });
      const call = await readOneCompletedFunctionCall(response);
      const implementation = functions[call.name];
      if (!implementation) throw new Error('Unknown function');

      const functionController = new AbortController();
      let timer;
      const timeout = new Promise((_, reject) => {
        timer = setTimeout(() => {
          functionController.abort();
          reject(new Error('Function execution timed out'));
        }, 15_000);
      });
      let value;
      try {
        // Implementations must pass signal to their own fetch/DB work so timeout
        // aborts the underlying operation, not only this response promise.
        value = await Promise.race([
          implementation(call.args, { signal: functionController.signal }),
          timeout
        ]);
      } finally {
        clearTimeout(timer);
      }
      const output = JSON.stringify(value);
      if (typeof output !== 'string') throw new Error('Function output is not JSON-serializable');
      if (output.length > MAX_TOOL_OUTPUT_CHARS) throw new Error('Function output is too large');
      return { ok: true, name: call.name, call_id: call.call_id, output };
    })());
  }

  try {
    res.json(await delegationWork.get(workKey));
  } catch {
    // Sanitize upstream and implementation details. The browser converts this
    // into failure context so the live voice turn can continue.
    res.status(502).json({ ok: false, error: 'The function could not be completed safely.' });
  }
});
```

The session's `response_model` value maps to the `/v1/responses` request field named `model`. Retain the session's normalized `tool_choice` and send it as `/v1/responses.tool_choice`; the sample keeps both in the `TOOL_CHOICE` constant. Voice `instructions` tell the live model when to delegate, while Responses `instructions` tell the selector how to choose and populate a tool. Keep both aligned, but do not blindly copy user-provided voice instructions into a privileged backend selector.

#### Event and correlation contract

`delegation.created` and `delegation.context.append*` are `oai-events` data-channel messages. `response.output_item.added/done`, `response.function_call_arguments.delta/done`, `response.failed`, `error`, and terminal `response.completed` are SSE events from the Responses request, not WebRTC messages.

A delegated voice request looks like:

```json
{
  "type": "delegation.created",
  "item": {
    "id": "delegation_123",
    "type": "delegation",
    "target": "client",
    "handoff_id": "handoff_1",
    "content": [{"type":"input_text","text":"What is the weather in Chicago?"}]
  }
}
```

Use `item.id` as the `delegation_item_id`. The optional `handoff_id` is transport metadata and is not the Responses function-call ID. Submit the joined `input_text` plus the same tools to `/v1/responses`. The function identity arrives in Responses SSE:

```json
{"type":"response.output_item.added","item":{"id":"fc_item_123","type":"function_call","call_id":"call_123","name":"get_weather","arguments":""}}
{"type":"response.function_call_arguments.done","item_id":"fc_item_123","arguments":"{\"city\":\"Chicago\"}"}
{"type":"response.output_item.done","item":{"id":"fc_item_123","type":"function_call","call_id":"call_123","name":"get_weather","arguments":"{\"city\":\"Chicago\"}"}}
{"type":"response.completed","response":{"status":"completed"}}
```

Require `response.completed`, exactly one allowlisted call, complete valid JSON arguments, and successful local schema validation before execution. Keep these identifiers separate:

| Identifier | Source | Purpose |
| --- | --- | --- |
| `delegation_item_id` | `delegation.created.item.id` | Binds context back to the spoken turn |
| `call_id` | Responses `function_call.call_id` | Identifies the selected function call and its output |
| `handoff_id` | `delegation.created.item.handoff_id` | Optional voice transport metadata; do not substitute it for either ID above |

#### Success, no-call, failure, and retry behavior

| Outcome | Required action |
| --- | --- |
| One valid call completes | Execute once, stringify the output, and send `delegation.context.append` with the Responses `call_id` |
| Responses completes with zero calls | Do not execute anything; append sanitized failure/no-match context so voice can continue |
| More than one call | Fail closed because voice sessions require `parallel_tool_calls: false`; append failure context |
| Unknown name, invalid JSON, or schema mismatch | Do not execute; append failure context |
| `response.failed`, `error`, malformed/truncated SSE, timeout, or disconnect | Do not execute; append failure context if the data channel remains open |
| Function throws or authorization is denied | Sanitize details and append failure context |
| Duplicate `delegation.created` | Reuse cached work keyed by `item.id`; never execute a side effect twice |
| Missing `delegation.context.appended` | Do not blindly re-execute. Keep the result cached and retry only the context append according to your idempotency policy |

`output` inside `function_call_output` must be a string. JSON-stringify structured values; do not place binary data there. The upstream context bound is experimental and not guaranteed, so enforce a small application limit and summarize or store large results elsewhere. The example uses 16,384 characters as an application policy, not a server guarantee.

The natural-language prefix and encoded `function_call_output` are text context for the live model. The prefix is recommended for intelligible speech; it is not a separately parsed field. For a failure before a Responses `call_id` exists, send a short plain-text failure result rather than inventing a call ID.

The data channel acknowledges accepted context with `delegation.context.appended`, after which the live model can continue speaking naturally. Maintain independent state keyed by `delegation_item_id`; delegations and transcript events may interleave.

The authenticated `/voice` page runs this adapter for its registered `change_voice` and `clear_voice_transcript` functions, so the complete negotiation, selection, browser execution, and continuation path can be tested without exposing an OAuth token or API key in page code.

Requests without `tools` create a plain client-delegation voice session without custom Responses-adapter metadata. The caller is still responsible for handling any `delegation.created` event. Codex Auth API's `/voice` page always registers its two local webpage tools.

Known V3 data-channel events include `session.started`, `session.updated`, `turn.created`, `delegation.created`, `delegation.context.appended`, transcript events, and `error`. Ignore unknown events and never display raw upstream error details. Because the Codex endpoint is internal/experimental, smoke-test it after upstream Codex changes.

## Errors

Errors use an OpenAI-compatible envelope.

- HTTP 400: invalid request or input
- HTTP 401: missing or invalid API key
- HTTP 403: account authorization problem
- HTTP 502: upstream generation failure

```json
{
  "error": {
    "message": "Incorrect API key provided.",
    "type": "invalid_request_error",
    "param": null,
    "code": "invalid_api_key"
  }
}
```

## Machine-readable contract

Use `https://codexauthapi.dev/openapi.json` when the client can consume OpenAPI 3.1. Use this Markdown document when providing context directly to a language model or coding agent.
