> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reflections.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The OpenAPI document at /api-reference/openapi.yaml defines the target contract. Its x-reflections-availability field and each operation's availability notice identify supported test and live forms. Do not infer implementation from a schema or code sample. Live keys support account details, reads of the licenses the calling account holds as licensee or issued as talent (GET /v1/licenses?role=issuer), and GET /v1/generations?license=… for the state of outputs under such a license (summaries only: no prompt, reference, bytes or metadata). Live image checks, submissions and receipts are built but not activated: until activation, a live POST /v1/checks or /v1/generations returns 403 live_not_activated, which must not be retried. Discovery and JWKS are at the origin root (https://app.reflections.ai/.well-known/...), not under /v1.
> Three rules hold everywhere: a decision is never an error (deny and escalate are 200 responses, and a live check still evaluating is a 202 with status checking); generate nothing for a named talent without an allow; deliver nothing until the generation is allow.
> The sandbox accepts personal rk_test_ keys. Persist a separate Idempotency-Key and body for each logical /v1 POST; retry an unknown outcome with the same key and body. OAuth and Talent API MCP remain planned.

# Run real-time sessions

> Start a live voice or video session, renew its token, submit samples, and stop output within one second when it ends.

<Note>
  Planned protocol reference. Session creation, renewal, sampling and the session stream are not
  served by the current Talent API sandbox. The examples below cannot yet be run against it.
  Start with the [runnable quickstart](/quickstart) and [sandbox support matrix](/sandbox).
</Note>

Live voice and video cannot wait for a check on every output: a listener hears speech as it is
made. A session runs one full check at the start, then keeps the use authorized with a short-lived
token you renew every minute, sampled checks of what the session produces, and a stream that tells
you the moment it must stop.

```mermaid theme={null}
sequenceDiagram
    participant P as Your platform
    participant R as Reflections
    participant S as Session stream
    P->>R: POST /v1/sessions
    R-->>P: session, token, sampling
    P->>S: Connect
    loop Every 60 s
        P->>R: POST renew
        R-->>P: New token
    end
    loop At the sampling rate
        P->>R: POST samples
        R-->>P: allow or deny
    end
    S-->>P: session.terminated
    P->>P: Stop output within 1 s
    P->>R: POST end
```

## Start a session

Send the same fields as a check: the talent, the facet, the use, what the model receives, the end
user and the model.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://app.reflections.ai/v1/sessions \
    -H "Authorization: Bearer rk_test_…" \
    -H "Reflections-Version: 2026-10-15" \
    -H "Idempotency-Key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{
      "talent": ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
      "facet": "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
      "use": {
        "modality": "speech", "kind": "impersonation", "purpose": "personal",
        "commercial": false, "channels": ["in_app"], "territories": ["US"]
      },
      "inputs": [{ "role": "system", "text": "You are a fan voice chat. Speak as Maya Okafor in a friendly tone." }],
      "end_user": { "id": "usr_48213" },
      "execution": { "provider": "example-voice-provider", "model": "realtime-voice-2" },
      "max_duration": "PT30M"
    }'
  ```

  ```typescript TypeScript theme={null}
  const API = "https://app.reflections.ai/v1";
  const headers = {
    Authorization: `Bearer ${process.env.REFLECTIONS_KEY}`,
    "Reflections-Version": "2026-10-15",
    "Content-Type": "application/json",
  };

  const session = await fetch(`${API}/sessions`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({
      talent: ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
      facet: "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
      use: {
        modality: "speech", kind: "impersonation", purpose: "personal",
        commercial: false, channels: ["in_app"], territories: ["US"],
      },
      inputs: [{ role: "system", text: "You are a fan voice chat. Speak as Maya Okafor in a friendly tone." }],
      end_user: { id: "usr_48213" },
      execution: { provider: "example-voice-provider", model: "realtime-voice-2" },
      max_duration: "PT30M",
    }),
  }).then((r) => r.json());
  ```

  ```python Python theme={null}
  import os, uuid, requests

  API = "https://app.reflections.ai/v1"
  headers = {
      "Authorization": f"Bearer {os.environ['REFLECTIONS_KEY']}",
      "Reflections-Version": "2026-10-15",
  }

  session = requests.post(
      f"{API}/sessions",
      headers={**headers, "Idempotency-Key": str(uuid.uuid4())},
      json={
          "talent": ["tal_9t0wxyhk5zba2q8v1m3n7p4r6s"],
          "facet": "fct_v1m3n7p4r6s9t0wxyhk5zba2q8",
          "use": {
              "modality": "speech", "kind": "impersonation", "purpose": "personal",
              "commercial": False, "channels": ["in_app"], "territories": ["US"],
          },
          "inputs": [{"role": "system", "text": "You are a fan voice chat. Speak as Maya Okafor in a friendly tone."}],
          "end_user": {"id": "usr_48213"},
          "execution": {"provider": "example-voice-provider", "model": "realtime-voice-2"},
          "max_duration": "PT30M",
      },
  ).json()
  ```
</CodeGroup>

```json theme={null}
{
  "id": "ses_k5v9fvrx5w37jt6q6vm26zxzfh",
  "object": "session",
  "status": "active",
  "check": "chk_qpdejnr6jkk2ag2yks7v6sb7g2",
  "license": "lic_fmva0q8p2ymbhdy60snaphp4gt",
  "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InJmbC10ZXN0LTIwMjYtMTAtZG9jczMiLCJ0eXAiOiJ0bHAtc2Vzc2lvbitqd3QifQ…",
  "expires_at": "2026-10-17T18:05:00Z",
  "renew_every": "PT60S",
  "sampling": { "audio_window_max": "PT15S", "transcript": "every_turn" },
  "obligations": [
    { "type": "disclosure", "level": "licensed", "placement": "visible", "text": "AI voice of Maya Okafor, licensed." },
    { "type": "generation_instructions", "instructions": ["Do not discuss politics or religion.", "Do not make promises on her behalf."] },
    { "type": "usage_report", "units": "seconds", "within": "P1D" }
  ],
  "reasons": [],
  "stream_url": "wss://app.reflections.ai/v1/sessions/ses_k5v9fvrx5w37jt6q6vm26zxzfh/stream",
  "termination": null
}
```

Show the disclosure for the whole session and give the model every `generation_instructions` entry.
If your voice provider verifies session tokens, pass it `token` and every renewed token. See the
[session token format](/api-reference/formats/session-token).

Sessions never escalate. Anything that would escalate is a `deny`, and the session comes back with
`status: "denied"`, `token: null` and `reasons`. A license that holds every output for release
cannot start a session; the reason is `session_not_permitted`.

## Connect to the stream

Open a WebSocket to `stream_url`. From a client, offer the subprotocols `tlp.session.v1` and
`tlp.session.<token>`, with the session token. From a server, you can offer `tlp.session.v1` and send
your secret key in `Authorization` instead. Reflections selects `tlp.session.v1` and never echoes
the token. The stream checks the token once, when it opens. Renewing over REST keeps the session and
does not reconnect the stream; you need the newest token only if you reconnect.

<CodeGroup>
  ```typescript TypeScript theme={null}
  const ws = new WebSocket(session.stream_url, ["tlp.session.v1", `tlp.session.${session.token}`]);

  ws.onmessage = (message) => {
    const event = JSON.parse(message.data);
    switch (event.type) {
      case "session.terminated":
        stopOutput(); // within one second
        void endSession(session.id);
        break;
      case "session.warning":
        if (event.code === "renewal_due") void renew();
        if (event.code === "sample_overdue") void submitSampleNow();
        break;
      case "sample.decided": // a sample you sent on the stream
        if (event.decision === "deny") stopOutput(); // within one second
        break;
    }
  };

  // Keep the connection alive when no other traffic flows.
  setInterval(() => ws.send(JSON.stringify({ type: "ping" })), 25_000);
  ```

  ```python Python theme={null}
  import asyncio, json, websockets

  async def watch(session):
      async with websockets.connect(
          session["stream_url"], subprotocols=["tlp.session.v1", f"tlp.session.{session['token']}"]
      ) as ws:
          async def keepalive():
              while True:
                  await asyncio.sleep(25)
                  await ws.send(json.dumps({"type": "ping"}))

          pinger = asyncio.create_task(keepalive())
          try:
              async for message in ws:
                  event = json.loads(message)
                  if event["type"] == "session.terminated":
                      stop_output()  # within one second
                      await asyncio.to_thread(end_session, session["id"])
                      return
                  if event["type"] == "session.warning" and event["code"] == "renewal_due":
                      await asyncio.to_thread(renew)  # off the reader, so no message waits
                  if event["type"] == "sample.decided" and event["decision"] == "deny":
                      stop_output()  # a sample you sent on the stream; within one second
          finally:
              pinger.cancel()
  ```
</CodeGroup>

| Message | Meaning |
| - | - |
| `session.ready` | Sent once, when the connection is authenticated. |
| `session.warning` | Act by `act_by`: `renewal_due`, `sample_overdue`, `license_ending` or `max_duration`. |
| `session.terminated` | Stop output within one second. Carries `reason`, `sentence` and, after a revocation, `revocation`. |
| `sample.decided` | The decision on a sample you sent on the stream as `sample.submit`, with the same `ref`. A `deny` is followed by `session.terminated`. |
| `pong` | The reply to your `ping`. Send a ping at least every 30 seconds when nothing else flows. |

If the connection drops, reconnect and read the session with
[`GET /v1/sessions/{session_id}`](/api-reference/sessions/retrieve-a-session). A termination you
missed shows there as `status: "terminated"` with `termination.reason`, and in the
[`session.terminated`](/api-reference/webhooks/session-terminated) webhook.

## Renew every 60 seconds

The token expires 5 minutes after it is issued. Renew it every `renew_every`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://app.reflections.ai/v1/sessions/ses_k5v9fvrx5w37jt6q6vm26zxzfh/renew \
    -H "Authorization: Bearer rk_test_…" \
    -H "Reflections-Version: 2026-10-15"
  ```

  ```typescript TypeScript theme={null}
  // Run on your server: renewal uses your secret key.
  let expiry: ReturnType<typeof setTimeout> | undefined;

  function stopAt(expiresAt: string) {
    clearTimeout(expiry);
    expiry = setTimeout(stopOutput, Date.parse(expiresAt) - Date.now()); // never past expiry
  }

  async function renew() {
    try {
      const res = await fetch(`${API}/sessions/${session.id}/renew`, { method: "POST", headers });
      if (res.ok) {
        const { token, expires_at } = await res.json();
        session.token = token;
        stopAt(expires_at);
      } else if (res.status === 409) {
        stopOutput(); // session_not_active: revoked, suspended, ended or terminated
      } // 429 or 5xx: keep the current token and retry on the next tick
    } catch {
      // Network error: the same
    }
  }

  stopAt(session.expires_at);
  setInterval(renew, 60_000);
  ```

  ```python Python theme={null}
  from datetime import datetime, timezone

  def renew() -> bool:
      """Run on your server: renewal uses your secret key. Returns False once output must stop."""
      try:
          res = requests.post(f"{API}/sessions/{session['id']}/renew", headers=headers, timeout=5)
      except requests.RequestException:
          res = None  # Network error: retry on the next tick.
      if res is not None and res.ok:
          session.update(res.json())  # token, expires_at
          return True
      if res is not None and res.status_code == 409:
          stop_output()  # session_not_active: revoked, suspended, ended or terminated
          return False
      if datetime.now(timezone.utc) >= datetime.fromisoformat(session["expires_at"]):
          stop_output()  # 429, 5xx or no answer: never past the current token's expiry
          return False
      return True

  async def renew_every_minute():
      while True:
          await asyncio.sleep(60)
          if not await asyncio.to_thread(renew):
              return

  async def run(session):
      await asyncio.gather(watch(session), renew_every_minute())

  asyncio.run(run(session))
  ```
</CodeGroup>

`409 session_not_active` means the license was revoked or suspended, its window ended, or the
session was terminated. Stop output at once. On a network error, `429` or `5xx`, keep retrying: you
may continue until the current `expires_at`, never past it.

## Submit samples

`sampling` says how often Reflections checks what the session produces:

| Field | What to send |
| - | - |
| `audio_window_max` | An `audio` sample for every window of generated speech, each no longer than this (15 seconds) |
| `transcript: "every_turn"` | A `transcript` sample with the words of each turn the session speaks |
| `frame_interval` | For video, one `frame` sample per interval |

<CodeGroup>
  ```bash cURL theme={null}
  curl https://app.reflections.ai/v1/sessions/ses_k5v9fvrx5w37jt6q6vm26zxzfh/samples \
    -H "Authorization: Bearer rk_test_…" \
    -H "Reflections-Version: 2026-10-15" \
    -H "Content-Type: application/json" \
    -d '{
      "kind": "audio",
      "window": { "start_ms": 30000, "end_ms": 45000 },
      "media_type": "audio/wav",
      "data": "UklGRiQAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQ…"
    }'
  ```

  ```typescript TypeScript theme={null}
  async function submitAudio(wav: Uint8Array, startMs: number, endMs: number) {
    const sample = await fetch(`${API}/sessions/${session.id}/samples`, {
      method: "POST",
      headers,
      body: JSON.stringify({
        kind: "audio",
        window: { start_ms: startMs, end_ms: endMs },
        media_type: "audio/wav",
        data: Buffer.from(wav).toString("base64"),
      }),
    }).then((r) => r.json());
    if (sample.decision === "deny") stopOutput(); // within one second
  }
  ```

  ```python Python theme={null}
  import base64

  def submit_audio(wav: bytes, start_ms: int, end_ms: int):
      sample = requests.post(
          f"{API}/sessions/{session['id']}/samples",
          headers=headers,
          json={
              "kind": "audio",
              "window": {"start_ms": start_ms, "end_ms": end_ms},
              "media_type": "audio/wav",
              "data": base64.b64encode(wav).decode(),
          },
      ).json()
      if sample["decision"] == "deny":
          stop_output()  # within one second
  ```
</CodeGroup>

```json theme={null}
{
  "id": "smp_4r6s9t0wxyhk5zba2q8v1m3n7p",
  "object": "session_sample",
  "session": "ses_k5v9fvrx5w37jt6q6vm26zxzfh",
  "kind": "audio",
  "decision": "allow",
  "reasons": [],
  "findings": [],
  "created_at": "2026-10-17T18:00:46Z"
}
```

Audio is `audio/wav` or `audio/ogg`, frames are `image/jpeg`, each up to 2 MB. A transcript sample
sends `kind: "transcript"` with the words of one turn in `text`, such as "Great question. My
favourite trail is the ridge loop above the lake."

### Send samples on the stream

Instead of a `POST` per sample, you can send each sample on the open stream as `sample.submit`, with
the same fields plus a `ref` of your own. This saves a round trip per sample. The decision comes back
on the stream as `sample.decided` with the same `ref`; a sample sent with `POST` gets its decision in
the response instead.

<CodeGroup>
  ```typescript TypeScript theme={null}
  ws.send(JSON.stringify({
    type: "sample.submit",
    ref: "turn-42",
    kind: "transcript",
    window: { start_ms: 81200, end_ms: 84600 },
    text: "Great question. My favourite trail is the ridge loop above the lake.",
  }));
  ```

  ```python Python theme={null}
  await ws.send(json.dumps({
      "type": "sample.submit",
      "ref": "turn-42",
      "kind": "transcript",
      "window": {"start_ms": 81200, "end_ms": 84600},
      "text": "Great question. My favourite trail is the ridge loop above the lake.",
  }))
  ```
</CodeGroup>

```json theme={null}
{
  "type": "sample.decided",
  "session": "ses_k5v9fvrx5w37jt6q6vm26zxzfh",
  "ref": "turn-42",
  "sample": "smp_6s9t0wxyhk5zba2q8v1m3n7p4r",
  "decision": "allow",
  "reasons": []
}
```

## Stop within one second

Stop output within one second of any of these:

* a sample returns `decision: "deny"`, in its response or in `sample.decided`;
* `session.terminated` arrives on the stream or as a webhook;
* a renewal returns `session_not_active`;
* the current token reaches `expires_at` without a successful renewal.

Stopping means the listener hears nothing more: drop audio already queued in your buffers, not
only the model's next turn. Then call
[`POST /v1/sessions/{session_id}/end`](/api-reference/sessions/end-a-session). Reflections also
terminates the session on its side.

<Warning>
  Between samples, a session can produce up to 15 seconds of speech that nobody has checked. A
  revoked license reaches you as `session.terminated` within about 2 seconds (p95). If both the
  stream and renewals fail, the session can run until its token expires, at most 5 minutes. Size your
  buffers and timeouts so you never go past these bounds.
</Warning>

## End the session and report usage

Call end when the user hangs up. Then report the session's usage with a
[usage record](/build/reporting-usage) whose `subject` is the session ID, in the units and within
the time the `usage_report` obligation names (here, seconds within one day).

## Planned test coverage

Sessions, `rfl_test_terminate`, stream messages and license-revocation helpers are not available
in the current sandbox. Use local fixtures to test renewal failure and termination handling;
check [Sandbox](/sandbox) before attempting this flow against the service.

## Related

<Columns cols={2}>
  <Card title="Session stream" icon="tower-broadcast" href="/api-reference/realtime/session-stream">
    Every message on the WebSocket channel.
  </Card>

  <Card title="Session token" icon="key" href="/api-reference/formats/session-token">
    Claims and renewal of the session token.
  </Card>

  <Card title="Voice platforms" icon="microphone" href="/guides/voice-platforms">
    Voices held at a provider, scripts and sessions.
  </Card>

  <Card title="Handle revocations" icon="ban" href="/build/handling-revocations">
    What a revocation requires beyond the session.
  </Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.