# Characters

Build a voice character in Python or TypeScript: a persona, a voice, tools, live game state, long memory, several players and any language, on Nur.

The EESI SDKs turn Nur into characters you can talk to. You describe who
the character is, give it a voice and some tools, and open a session. Nur
handles the conversation itself: listening while it speaks, knowing when a
turn ends, and stopping when someone cuts in. The SDK handles everything on
your side: the microphone and speaker, live state from your game, the tools
the character calls, and what it remembers about each player.

<CodeGroup>
```bash Python
pip install "eesi-sdk[audio] @ git+https://github.com/eesi-ai/eesi-sdk-python"
export EESI_API_KEY=sk-eesi-...
```

```bash TypeScript
npm install github:eesi-ai/eesi-sdk-typescript
export EESI_API_KEY=sk-eesi-...
```
</CodeGroup>

The source is on GitHub: [eesi-sdk-python](https://github.com/eesi-ai/eesi-sdk-python)
and [eesi-sdk-typescript](https://github.com/eesi-ai/eesi-sdk-typescript). PyPI
(`eesi-sdk`) and npm (`@eesi/sdk`) packages are on the way; the code you
import is the same.

The Python `audio` extra adds this computer's microphone and speaker. Leave it
out on a server that streams audio some other way. The TypeScript package runs
on Node 22 or later, Deno, Bun and in browsers; `@eesi/sdk/browser` has the
page's microphone and speakers, `@eesi/sdk/node` file memory and WAV files.
The examples below are Python; the TypeScript SDK has the same parts under
camelCase names, and its README walks through each one.

```ts
import { NurClient } from "@eesi/sdk";

const nur = new NurClient();
const kael = nur.npc({ name: "Kael", persona: "A mysterious elven merchant who distrusts kings.", voice: "Atlas", memory: true });
const session = await kael.connect({ playerId: "p42" }).start();
console.log((await session.ask("What do you sell?")).text);
```

## A character in a few lines

```python
import asyncio
from eesi import NurClient

nur = NurClient()

kael = nur.npc(
    name="Kael",
    persona="A mysterious elven merchant who distrusts kings.",
    voice="Atlas",
    memory=True,
)

asyncio.run(kael.start())
```

`start()` opens a session, connects your microphone and speaker, prints the
conversation, and runs until you press Ctrl+C. Kael greets you first. Talk
over him and he stops.

You can also try a character without writing code:

```bash
python -m eesi talk --name Luna --preset companion --persona "A curious, thoughtful companion."
python -m eesi chat --name Kael --preset npc --persona "An elven merchant."
```

`chat` lets you type instead of speak.

## Who the character is

```python
kael = nur.npc(
    name="Kael",
    persona="An elven merchant who keeps the northern gate of Eldermoor.",
    voice="Atlas",
    language="en",
    traits=["wry", "watchful", "fair to honest folk"],
    speaking_style="Short sentences. Old-fashioned words.",
    goals=["Keep the gate safe after dark."],
    knowledge=["The king raised the bridge toll last spring."],
    boundaries=["Never reveal the location of the hidden vault."],
    instructions="If the player is rude, raise your prices.",
)
```

Every field is optional except `name`. The SDK composes them into the
character's instructions, adds the rules every spoken character needs (short
turns, no lists or stage directions), and sends them when a session starts.
`kael.instructions_text()` shows exactly what was composed.

A character keeps its own name and stays in role. When someone sincerely asks
whether they are talking to a person, it says it is an AI.

### Presets

Presets are starting points with sensible defaults. Every option still works
on top of them.

| Factory | For |
| --- | --- |
| `nur.npc(...)` | A character inside your game's world |
| `nur.multiplayer_npc(...)` | An NPC several players talk to, keeping its story straight between them |
| `nur.companion(...)` | A personal companion with long memory (memory is on by default) |
| `nur.storyteller(...)` | An interactive story the listener steers |
| `nur.support_agent(...)` | Customer support that only answers from its knowledge and tools |
| `nur.assistant(...)` | A plain voice assistant |
| `nur.character(...)` | No preset |

### Voices

`voice` takes a built-in voice name (`"Atlas"`, `"Nova"`, `"Nur"`), any voice
id from your library (`"ev_…"`), or a voice described in words:

```python
voice = nur.voice_design("Kael", "a weathered old ship captain with a deep British voice")
```

A described voice is created once in your library and reused by name after
that, so Kael sounds the same in every session. `python -m eesi voices` lists
what you have. To hear takes before keeping one, use
[voice design](/speech/voices#design-a-voice) directly.

### Save and share a character

```python
kael.save("kael.json")
kael = nur.load_character("kael.json", tools=[open_gate])
kaela = kael.clone(name="Kaela", id="kaela")
```

A saved character is plain JSON. Tools are functions, so you pass them again
when you load it.

## Sessions

A character is defined once. Each conversation is a session, usually one per
player:

```python
async with kael.connect("player_42", player_name="Sam", audio=nur.audio.local()) as session:
    await session.run()
```

| Argument | What it does |
| --- | --- |
| `player_id` | Whose memories this session reads and writes. Your id, not a name. |
| `audio` | `nur.audio.local()` for this computer. Leave it out to stream audio yourself. |
| `context` | Live state this session starts with. |
| `memory` | `True` (default), `"read"` to recall without learning, or `False`. |
| `instructions` | Extra instructions for this session only. |
| `trace` | A path to write the session's timeline to. |

Without `audio`, send microphone audio with `await session.send_audio(pcm)`
(16-bit mono PCM, 16 kHz by default, at the pace it was recorded) and play the
character's voice from `audio.output.chunk` events. That is how a game server
relays audio from a player's machine.

You can also type. `await session.send_text("Where is the mill?")` adds the line
and asks for a reply; `reply = await session.ask("Where is the mill?")` also
waits for the character's answer to that line and returns it.

### Events

```python
@session.on("transcript.final")
def heard(event):
    print(event.player_id, event.text)

async for event in session:
    if event.type == "tool.completed":
        print(event.name, event.output)
```

| Event | When |
| --- | --- |
| `session.started` / `session.ended` | The session is ready, or over (with a `reason`). |
| `audio.input.started` / `audio.input.ended` | The player started or stopped talking. |
| `transcript.partial` / `transcript.final` | What the player is saying, then what they said. |
| `response.started` / `response.text.delta` | The character began a reply, and its words as they come. |
| `response.completed` / `response.cancelled` | The reply finished, or was cut off. Audio may still be playing. |
| `audio.output.chunk` | Part of the character's voice, as PCM. |
| `speech.interrupted` | The player cut the character off and its queued audio was dropped. |
| `tool.started` / `tool.completed` / `tool.failed` | A tool call and its result. |
| `context.updated` | A block of live state reached the character. |
| `memory.retrieved` / `memory.updated` | Memories were recalled, or learned. |
| `reconnecting` / `reconnected` | The connection dropped and is being resumed. |
| `session.hangup` | The character ended the conversation. |
| `error` | Something was refused. The session stays open unless it ends. |

Callbacks can be plain functions or `async` functions. `on("tool.*")` matches
every tool event and `on("*")` every event. A character's own `on(...)` hears
every one of its sessions.

### Who speaks when

`turn_taking` on the character decides who starts replies:

- `"natural"` (the default). Nur decides, with its full conversational
  timing: it backchannels, waits through pauses, and yields when cut off. It
  also takes part on its own: when a conversation goes quiet after its reply
  it may follow up, and it may chime in briefly when someone pauses
  mid-thought. A session that sets `"eesi_proactive": false` does neither.
- `"app"`. The SDK asks for a reply about half a second after each turn ends,
  once fresh state and memories are in place. Tool use is most reliable here.
- `"director"`. The character only speaks when your code says so, with
  `session.respond()` or `session.cue()`. Use it for scripted scenes and
  several characters in one room.

To make the character speak first about something that happened:

```python
await session.cue("The player just drew a sword.")
session.moment("A wolf howls in the distance.", kind="ambience", cooldown=30)
```

A cue is answered as soon as nobody is talking. A moment is optional: it is
paced so the character never talks over anyone and remarks at most once every
few seconds, and only the newest moment of each kind waits. `session.say(line)`
asks the character to say a line in its voice, and `session.interrupt()`
stops it mid-sentence.

## Tools

```python
from typing import Literal
from eesi import ToolContext

@kael.tool
async def open_gate(gate: Literal["north", "south"] = "north") -> str:
    """Open one of the castle gates.

    Args:
        gate: Which gate to open.
    """
    return await game.open_gate(gate)
```

The tool's name, description and argument schema come from the function: its
name, docstring and type hints. Arguments are validated before your function
runs; a bad call is answered with what was wrong so the character can try
again. Plain functions run in a worker thread, so a slow tool never holds up
the audio.

A parameter typed `ToolContext` receives the calling session and player
instead of a model argument:

```python
@kael.tool(allow=lambda ctx: game.reputation(ctx.player_id) != "untrusted")
def sell_item(item: str, price: int, ctx: ToolContext) -> str:
    """Sell the player an item."""
    return game.sell(ctx.player_id, item, price)
```

| Option | What it does |
| --- | --- |
| `allow` | Checked in your code before every call. Return `False` or a reason to refuse. This is where permissions belong. |
| `confirm=True` | The character asks the player before acting and calls again only after a clear yes. A conversational check, not an authorization. |
| `confirm=fn` | Your own confirmation, such as a dialog in your UI. |
| `timeout` | Seconds before the character is told the tool is still working (default 8). |
| `speak_after` | Whether the character speaks once the result is in. By default it does unless the tool returned `None`. |

For work that takes a while, return `Started`:

```python
from eesi import Started

@kael.tool
async def fetch_mead() -> Started:
    """Fetch a mead from the cellar."""
    return Started("Off to the cellar.", game.walk_to_cellar_and_back())
```

The character hears "Off to the cellar." at once and keeps talking. When the
work finishes, it is told and speaks about it as soon as nobody is talking.

Tool descriptions never authorize anything. Check permissions in `allow` or in
your own game code, and keep your game server the authority on what happened.

## Live context

Context is what is true right now, beside the conversation. Send it whenever it
changes:

```python
await kael.context.update({"time_of_day": "dusk", "weather": "rain"})        # every session of Kael
await session.context.update({"location": "Northern Gate", "health": "6/20"})  # this session only
```

The character reads the latest state on its next reply. Context never enters
the conversation history and never starts a reply by itself. When it
disagrees with something said earlier, the context wins. Nothing the character
says changes it: your application stays the authority.

- Updates merge field by field. `None` removes a field.
- `ttl=30` makes fields expire.
- Pydantic models and dataclasses are accepted, so typed game state works as is.
- Fields are grouped into named blocks. The character's own context is the
  `world` block and a session's is the `scene` block; pass `block="combat"` to
  start another.
- Update as often as you like. Each block is sent at most a few times a second,
  and only when its text changed.

| Limit | Value |
| --- | --- |
| Blocks per session | 8, including the SDK's own (`memory`, `world`, `scene`, and `mood`, `party` or `recall` when used) |
| One block | 200,000 bytes |
| All blocks | 256,000 bytes |

`session.inspect()` shows the instructions, every block as the character reads
it, the tools and the memories in use.

`await session.set_mood("annoyed", reason="the player insulted the king")`
colours how the character speaks for a while without changing who it is.

## Memory

```python
luna = nur.companion(
    name="Luna",
    persona="A curious, thoughtful companion.",
    memory=nur.memory.local("./luna-memory"),
)
```

Memory is a SQLite file on your machine. Nothing is hosted. With `memory=True`
the file is `.eesi/memory/memory.db`.

What happens in each session:

1. **When it starts**, the memories that matter most for this player are
   chosen by relevance to the current situation, importance and recency, and
   sent as the `memory` block. A new player is introduced as one: the
   character knows it has not met them.
2. **While it runs**, what the player says is searched against their memories
   and anything relevant is added to a small `recall` block. This happens off
   the audio path and usually reaches the next reply.
3. **When it ends**, what was worth keeping is learned: facts about the
   player, their preferences, events, promises, how the relationship changed,
   and a summary of the conversation. Corrections replace the memory they
   correct, and the old one is kept as history.

Learning asks Nur (`nur-v1`) in text, with your key. Pass
`MemoryPolicy(extractor=...)` to use your own model, or
`MemoryPolicy(extract=False)` to keep only what your code writes. Learning
updates the memory file. It does not train or fine-tune any model.

Write memories yourself:

```python
await kael.remember("The northern bridge collapsed.")                       # every player's Kael knows
await kael.remember("They betrayed the northern alliance.", player_id="p42") # only p42's sessions
await session.remember("They prefer to be called Sam.")                     # this session's player
```

A player's memories are only ever recalled into that player's sessions.
Memories without a player belong to the character and every session can use
them.

Inspect, correct and delete:

```python
sam = kael.memory.for_player("p42")
await sam.list()
await sam.search("alliance")
await sam.correct(record_id, "They left the alliance; they did not betray it.")
await sam.forget(record_id)
await sam.erase()                  # everything about this player, removed from the file
await kael.memory.export("kael-memory.json")
```

The same from a terminal: `python -m eesi memory list --character kael`.

`MemoryPolicy` sets what is kept: `retention_days` (episodes expire),
`max_records_per_player`, `redact` (a function applied to every text before it
is stored) and the `memory` block's size. `memory="read"` on `connect` recalls
without learning, for a guest or a player who has not agreed to be remembered.

### Preferences

```python
await session.preferences.set(verbosity="brief", language="Arabic", pace="slow")
```

Preferences apply at once and are kept for later sessions with this character.
`persist=False` adapts only this session. `pace` also sets the voice's speed.
`personalize=False` on the character stops it from learning preferences by
itself.

## Several players

**One character, many players.** Every player gets their own session from the
same character. Their memories never mix. With `multiplayer_npc` (or
`party=True`), each session also sees a short log of what the character said to
the other players, so its story stays consistent.

**Several people on one microphone.** Nur labels each voice `Speaker 1`,
`Speaker 2` once it has heard more than one. Name the players and the SDK binds
the labels to them from introductions ("I'm Layla") and the order they were
heard:

```python
session = kael.connect(players={"Layla": "p1", "Sam": "p2"}, audio=nur.audio.local())
```

`transcript.final` events then carry each part's `player_id`, and tools know
who asked. It is a best effort and inherits every mistake the voice labels
make.

**A room.** When your server already receives every player's microphone (your
game's voice chat runs through it), a room mixes them into one conversation
with the character and knows who spoke from who was loudest while each turn
was spoken, not from introductions:

```python
from eesi import Player

room = kael.room([Player("Ana", "p1", language="ar"), Player("Ben", "p2", language="es")])
async with room:
    room.on("audio.output.chunk", lambda event: voice_chat.to_everyone(event.pcm))
    room.feed("p1", pcm)  # each player's microphone, as it arrives
```

Give players a `language` and the character answers each in their own.

## Languages

A character follows the player by default and switches when they do. Pin one
language (a code or a name) and the greeting and every reply are in it;
`ar-EG` is Egyptian Arabic, and `language_name` names any variety:

```python
kael = nur.npc(name="Kael", language="ar-EG")
session = kael.connect("p42", language="fr")       # one session pinned
kael = nur.npc(name="Kael", language=["en", "es"])  # whichever of these the player uses
```

## Speaking first

A character can speak without being spoken to. Remarks wait for the floor, so
they never talk over anyone:

```python
import eesi

await session.cue("The player drew a sword.")                  # reacts as soon as nobody is talking
kael.when(lambda state: state.get("health", 100) < 20, "The player is badly hurt.")  # once each time it turns true
luna = nur.companion(name="Luna", proactive=eesi.Proactive(idle_after=30, guidance="how their exam went"))
```

After a quiet stretch a proactive character asks itself, out of band and in
text, whether something is worth saying, and says it only if so.

## Browsers and game engines

Never put your API key in a game client. Your server keeps it and hands each
client a client secret instead:

```python
secret = await kael.client_secret("player_42", context={"location": "Northern Gate"})
# send secret.url to the game client
```

The client opens `secret.url` as a WebSocket and is talking to Kael at once:
the session already has his instructions, voice, tools, the player's memories
and the context you passed. It streams audio and answers tool calls by
forwarding them to your server, which runs them. A secret opens one session and
expires after a minute unless you set `expires_in` (up to ten minutes).

`lock` decides what the client may not change. By default it is the
instructions and the tools.

| Lock | The client 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. It can still add the player's own messages and tool results. |

```python
secret = await kael.client_secret("player_42", context={"quest": "the lost crown"}, lock="all")
```

A locked field in a `session.update` is left out, and the rest of the update
applies. A locked event (live state, a system message, a deleted message) is
refused with an `error` event whose code is `session_locked`.

This works from any engine that has a WebSocket: browsers, Unity, Unreal,
Godot. The session speaks the protocol described in [Nur](/realtime/live).

In a browser, the TypeScript SDK opens it for you. Send the client
`secret.join` (in either SDK): the URL, and who greets, runs tools and asks for
replies. It never holds your key.

```ts
import { joinSession } from "@eesi/sdk";
import { browserAudio } from "@eesi/sdk/browser";

const join = await (await fetch("/api/nur/join", { method: "POST" })).json();
const session = await joinSession(join, { audio: await browserAudio() });
```

### Steer the session from your server

Mint the secret with `control=True` and your server can act in the session the
client opened, without relaying its audio: live state, tool calls, cues,
remarks and memory, as in a session of its own.

```python
secret = await kael.client_secret("player_42", control=True, lock="all")
# send secret.join to the game client, then:
async with kael.control(secret) as session:   # ready when the client connects
    await session.context.update({"location": "Northern Gate"})
    await session.run()
```

The server sees the session's events (transcripts, replies, tool calls; never
audio) over `wss://api.eesi.ai/v1/realtime/control/{id}`, authenticated with
your key. Tool calls are the server's to answer; `control="client"` leaves
them to the client. What the server sends is not held to the locks, and only
the organization that minted the secret can open its channel.

## When something goes wrong

- A dropped connection is resumed with the conversation intact
  (`reconnecting`, then `reconnected`). If it cannot be resumed, the session
  ends with `reason="connection_lost"`.
- A refused key raises `AuthenticationError` from `connect`.
- An unknown voice raises `ConfigurationError` listing the voices you have.
- Context over a limit raises `ContextError` saying which block and why.
- Errors inside your event handlers are logged and never end the session.

Write a trace to see what the character heard, recalled, did and said, and how
long each reply took:

```python
async with kael.connect("p42", trace="traces/kael.jsonl") as session: ...
```

```bash
python -m eesi trace traces/kael.jsonl
```

A trace holds no words by default: transcripts, replies, tool arguments and
memories are replaced by their length. Use `Trace(path, content=True)` while
you debug.

## Limits

- Python has no echo canceller. On open speakers, `nur.audio.local()` keeps the
  microphone from hearing the character, and you cut in by speaking up. Use
  headphones and `nur.audio.local(echo="off")` for the most natural barge-in.
  Browsers and phones cancel echo themselves.
- Latency measured in the SDK is from the end of a turn to the first audio of
  the reply reaching your machine. It does not include your speaker's own
  buffering.
- Everything else (session length, idle timeout, billing) is as described in
  [Nur](/realtime/live).
