# Client secrets

Let a player's device talk to a Nur character directly, over WebSocket or WebRTC, with settings it cannot change: instructions, voice, call length, silence hang-up, a signed reply webhook, and what the session's words may be used for.

Your server keeps the API key and mints a **client secret** for each player. The
player's device (a browser, a phone, a game build) opens the session with it,
straight to EESI, and never sees your key. The session it opens is already the
character you configured, and the device cannot change what you lock.

1. The player's device asks your server to talk to a character.
2. Your server mints a secret with its API key (`POST /v1/realtime/client_secrets`) and sends the device the secret's `value` (or `secret.join` from the SDKs).
3. The device opens the session with it, over WebSocket or WebRTC, straight to EESI.
4. Optionally, EESI posts each reply to your server, signed, and your server steers the session over a control channel.

## Mint a secret

```bash
curl https://api.eesi.ai/v1/realtime/client_secrets \
  -H "Authorization: Bearer $EESI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "session": {
      "instructions": "You are Brann, the gate guard of Neotopia...",
      "audio": {"output": {"voice": "Atlas"}}
    },
    "expires_after": {"anchor": "created_at", "seconds": 60},
    "eesi_lock": "all",
    "eesi_limits": {"max_duration_seconds": 600, "silence_hangup_seconds": 45},
    "eesi_webhook": {"url": "https://api.neotopia.example/nur/replies", "secret": "whsec_..."},
    "eesi_metadata": {"npc_id": "guard-7", "player_id": "p-123"},
    "eesi_data": {"train": false, "record": false}
  }'
```

The response's `value` is the secret. It opens one session, and expires after
`expires_after.seconds` (10 to 600) if unused. The session itself lasts as long
as any session, or `max_duration_seconds`.

With the SDKs, `character.clientSecret(...)` (TypeScript) and
`character.client_secret(...)` (Python) compose the session from your character,
its memories and live state, and return `secret.join`: everything the device
needs, and nothing it must not have.

## What the device can and cannot change

`eesi_lock` names what is fixed; `"all"` fixes everything. A locked field in the
device's `session.update` is dropped, and the rest applies. A locked event is
refused with an `error` whose code is `session_locked`.

| Lock | The device cannot |
| --- | --- |
| `instructions` | Replace the character's instructions. |
| `tools` | Change the tools, ask for other tools in a reply, or turn on EESI's own tools. |
| `voice` | Change the voice or its speed. |
| `turn_detection` | Change how turns are detected or whether replies start by themselves. |
| `context` | Send live state. What you passed when minting is final. |
| `conversation` | Steer a reply with its own instructions or input, add system or assistant messages, or delete messages. |

Even with everything locked, the device can still stream microphone audio, add
the player's own typed messages (`conversation.item.create` with a user
message), ask for a reply (`response.create`, plain), and send tool results.

`eesi_limits`, `eesi_webhook`, `eesi_metadata` and `eesi_data` are never part
of the session configuration, so no client event can touch them.

## Call length and silence

| Field | Range | What happens |
| --- | --- | --- |
| `max_duration_seconds` | 10 to the platform maximum | The call ends at that length, mid-sentence if need be. |
| `silence_hangup_seconds` | 5 to 3600 | The call ends after that long with nobody speaking, typing or being answered. |

Microphone audio alone is not activity: a device streams silence
continuously. The player speaking, a typed message, a request for a reply and
the character answering all are. When a limit is reached the device receives

```json
{"type": "eesi.hangup", "reason": "silence"}
```

(`reason` is `silence` or `max_duration`), and the socket closes normally
(1000). The SDKs end the session with reason `hangup`.

## WebRTC

The same secret opens a WebRTC call, so phones and browsers get the platform's
echo cancellation and Opus. Post the SDP offer to `/v1/realtime/calls` with the
secret where OpenAI's clients put their ephemeral key:

```bash
curl https://api.eesi.ai/v1/realtime/calls \
  -H "Authorization: Bearer $CLIENT_SECRET" \
  -H "Content-Type: application/sdp" \
  --data-binary @offer.sdp
```

The answer is `application/sdp`. Offer exactly one bidirectional Opus audio
track and a data channel; events flow on the data channel as they do on the
WebSocket. The call is the session the secret binds, with every lock, limit,
webhook and data setting. The secret is spent by the first call or socket that
presents it. In `secret.join` the SDKs include `token` and `callsUrl` for this.

## Webhooks

With `eesi_webhook`, EESI posts the session's conversation to your URL as it
happens: what the player said or typed (`input.completed`), each finished reply
(`response.completed`), and the session's end with its whole transcript
(`session.ended`). The URL must be public HTTPS; it is checked when you mint and
resolved again before every post.

```http
POST /nur/replies HTTP/1.1
Content-Type: application/json
EESI-Webhook-Id: whk_6f0c1d…
EESI-Webhook-Timestamp: 1760000000
EESI-Signature: v1=5a8f…

{"id": "whk_6f0c1d…", "type": "response.completed", "created_at": 1760000000,
 "session_id": "conv_…", "metadata": {"npc_id": "guard-7", "player_id": "p-123"},
 "response": {"id": "resp_…", "status": "completed", "text": "Halt. State your business."}}
```

What the player said (`"source": "speech"`) or typed (`"text"`):

```json
{"id": "whk_…", "type": "input.completed", "created_at": 1760000003, "session_id": "conv_…",
 "metadata": {"npc_id": "guard-7", "player_id": "p-123"},
 "input": {"item_id": "item_…", "source": "speech", "text": "I'm Karim, I'm building a treehouse."}}
```

When the session ends, with the whole conversation, oldest first:

```json
{"id": "whk_…", "type": "session.ended", "created_at": 1760000061, "session_id": "conv_…",
 "metadata": {"npc_id": "guard-7", "player_id": "p-123"}, "reason": "client_disconnect", "duration_seconds": 61.0,
 "transcript": [{"role": "player", "text": "I'm Karim, I'm building a treehouse."},
                {"role": "character", "text": "A treehouse! Use oak for the floor."}]}
```

`EESI-Signature` is `v1=` and the hex HMAC-SHA256, keyed with your secret, of
`{EESI-Webhook-Id}.{EESI-Webhook-Timestamp}.{raw body}`. Check it over the raw
body, and refuse a timestamp more than a few minutes old. The SDKs do both:

<CodeGroup>
```ts TypeScript
import { verifyWebhook } from "@eesi/sdk/node";

app.post("/nur/replies", express.raw({ type: "application/json" }), (req, res) => {
    const event = verifyWebhook(req.body, req.headers, process.env.NUR_WEBHOOK_SECRET!);
    if (event.type === "response.completed") moderation.check(event.metadata.player_id, event.response!.text);
    res.sendStatus(204);
});
```

```python Python
from eesi import verify_webhook

@app.post("/nur/replies")
async def replies(request: Request):
    event = verify_webhook(await request.body(), request.headers, NUR_WEBHOOK_SECRET)
    ...
```
</CodeGroup>

Answer with any 2xx. A failed post is retried three times over about 40
seconds; a 4xx other than 408 and 429 is not retried. Posts are in order, one at
a time, and never hold up the conversation. Replies carry text; there is no
per-reply audio URL.

## Remember players across calls

A character can remember each player from one call to the next even though the
player's device holds the session. Your server keeps the memories (in your
database, or a file), the SDK puts the player's into each client secret it
mints, and the `session.ended` webhook is what it learns from:

<CodeGroup>
```ts TypeScript
import { NurClient, Memory } from "@eesi/sdk";
import { verifyWebhook } from "@eesi/sdk/node";

const sage = nur.npc({
    name: "Sage",
    persona: "The gardener of Neotopia.",
    // Your database: implement MemoryStore (put, get, query, search, delete…), or fileMemory("./memory").
    memory: new Memory(myDatabaseStore, {
        rules: "Only game facts: what they build, their favourite items, the name they use in the game, what you talked about. Never age, school, location, contact details or social accounts.",
    }),
});

// A player walks up: the secret starts with what Sage remembers about them.
const secret = await sage.clientSecret({
    playerId: player.id,
    lock: "all",
    webhook: { url: "https://api.neotopia.example/nur", secret: process.env.NUR_WEBHOOK_SECRET! },
    metadata: { player_id: player.id },
});

// The call ended: Sage learns what was worth keeping.
app.post("/nur", express.raw({ type: "application/json" }), async (req, res) => {
    const event = verifyWebhook(req.body, req.headers, process.env.NUR_WEBHOOK_SECRET!);
    res.sendStatus(204);
    if (event.type === "session.ended") await sage.learnFromSession(event, { playerId: event.metadata.player_id });
});
```

```python Python
sage = nur.npc(
    name="Sage",
    memory=Memory(my_database_store, policy=MemoryPolicy(rules="Only game facts...")),
)
secret = await sage.client_secret(player.id, lock="all", webhook_url=URL, webhook_secret=SECRET,
                                  metadata={"player_id": player.id})

event = verify_webhook(body, headers, SECRET)
if event["type"] == "session.ended":
    await sage.learn_from_session(event, player_id=event["metadata"]["player_id"])
```
</CodeGroup>

Memories are scoped to one player and one character: a player's never reach
another player's session. `rules` say what may be kept, in your words. When a
player deletes their account, `sage.memory.erase({ playerId })` (Python:
`erase(player_id=...)`) removes everything about them. This needs no WebSocket on
your server, so it runs on Node 18 and later.

## What the session's audio and words are used for

`eesi_data` narrows what your organization's own settings allow; it never
widens them.

- `"train": false`: the session is never used to train, evaluate or improve any
  model, whatever your organization grants now or later. It is marked when it
  opens, and every review, dataset and evaluation skips it.
- `"record": false`: no recording or transcript is kept after the session, and
  the copy a dropped call would resume from is erased when it ends. The device
  is told it is not recorded (`eesi.recording_disclosure`).

Without them, a session follows your organization's settings: sessions are
recorded unless recording is turned off in Settings (and then, as with
`"record": false`, no recording or transcript is kept after the session ends),
recordings and transcripts are kept for your retention period (one year unless
your organization set another), and none are used to improve Nur unless your
organization turned on **Help improve Nur**.
