Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ async with client.connect(session_options=session_options) as session:
The main client class for connecting to Anam AI.

```python
from anam import AnamClient, PersonaConfig, ClientOptions
from anam import AnamClient, ClientOptions, DirectorNotes, PersonaConfig

# Simple initialization for pre-defined personas - all other parameters are ignored except enable_audio_passthrough
client = AnamClient(
Expand All @@ -172,6 +172,10 @@ client = AnamClient(
system_prompt="You are a helpful assistant...",
avatar_model="cara-4",
language_code="en",
director_notes=DirectorNotes(
preset_style="warm",
expressivity=0.7,
),
enable_audio_passthrough=False,
),
)
Expand Down Expand Up @@ -300,6 +304,9 @@ async with client.connect() as session:

# Interrupt the avatar if speaking
await session.interrupt()

# Runtime director-note cue (turn-scoped)
await session.send_director_note_cue("curious", at_seconds=0.5)

# Get message history
history = client.get_message_history()
Expand All @@ -310,6 +317,43 @@ async with client.connect() as session:
await session.wait_until_closed()
```

### Director Notes

Director notes control how the avatar performs a conversation (how something is said, not just what is said). Set session defaults on `PersonaConfig(director_notes=...)`, then send runtime `director_note_cue` messages with `session.send_director_note_cue(...)`.
For supported styles/cues and timing semantics (`at_seconds` vs `in_seconds`), see the official docs: [Director Notes](https://anam.ai/docs/personas/director-notes).

#### Initial Director Notes

Set director notes directly on `PersonaConfig` to apply them from session start:

```python
from anam import DirectorNotes, PersonaConfig

persona = PersonaConfig(
avatar_id="your-avatar-id",
director_notes=DirectorNotes(
preset_style="warm",
expressivity=0.7,
),
)
```
The initial director notes are the avatar's default presence for the session.

##### Runtime Director Note Cues

@cubic-dev-ai cubic-dev-ai Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The markdown heading for Runtime Director Note Cues uses ##### (h5), but since it's a peer section to #### Initial Director Notes, it should use #### (h4). Using ##### makes it appear as a sub-subsection of Initial rather than a sibling section under ### Director Notes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 342:

<comment>The markdown heading for `Runtime Director Note Cues` uses `#####` (h5), but since it's a peer section to `#### Initial Director Notes`, it should use `####` (h4). Using `#####` makes it appear as a sub-subsection of `Initial` rather than a sibling section under `### Director Notes`.</comment>

<file context>
@@ -310,6 +317,43 @@ async with client.connect() as session:
+```
+The initial director notes are the avatar's default presence for the session.
+
+##### Runtime Director Note Cues
+
+Use `session.send_director_note_cue(...)` to adjust delivery style during the active turn.
</file context>
Suggested change
##### Runtime Director Note Cues
#### Runtime Director Note Cues
Fix with cubic


Use `session.send_director_note_cue(...)` to adjust delivery style during the active turn.

```python
import asyncio

async with client.connect() as session:
cues = ["warm", "playful", "concerned", "laughter"]

for cue in cues:
await session.send_director_note_cue(cue, at_seconds=3.0)
await asyncio.sleep(8)
```

## Examples

### Save Video and Audio
Expand Down Expand Up @@ -481,7 +525,7 @@ Ephemeral personas give you full control over components at startup. Configure a
They are ideal for production environments where you need to control the components at startup.

```python
from anam import PersonaConfig
from anam import DirectorNotes, PersonaConfig

# Ephemeral: specify avatar_id, voice_id, and optionally llm_id, avatar_model
persona = PersonaConfig(
Expand All @@ -490,6 +534,10 @@ persona = PersonaConfig(
llm_id="your-llm-id", # From https://lab.anam.ai/llms (optional)
avatar_model="cara-4", # Video frame model (optional)
system_prompt="You are...", # See https://docs.anam.ai/concepts/prompting-guide
director_notes=DirectorNotes( # Optional: Initial director notes
preset_style="warm",
expressivity=0.7,
),
enable_audio_passthrough=False,
)
```
Expand Down
2 changes: 2 additions & 0 deletions src/anam/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ async def consume_audio():
AnamEvent,
ClientOptions,
ConnectionClosedCode,
DirectorNotes,
EgressDailyOptions,
EgressOptions,
Message,
Expand All @@ -82,6 +83,7 @@ async def consume_audio():
"AudioFrame",
"ClientOptions",
"ConnectionClosedCode",
"DirectorNotes",
"EgressDailyOptions",
"EgressOptions",
"Message",
Expand Down
59 changes: 46 additions & 13 deletions src/anam/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
from __future__ import annotations

import asyncio
import json
import logging
import math
import uuid
from collections.abc import AsyncIterator
from typing import Any, Awaitable, Callable, TypeVar
Expand Down Expand Up @@ -531,9 +533,6 @@ async def send_message(self, content: str) -> None:
Raises:
SessionError: If not connected or if LLM is not available.
"""
if not self._client._streaming_client:
raise SessionError("Not connected")

# Validate that LLM is available for processing messages
persona_config = self._get_persona_config()

Expand All @@ -545,13 +544,7 @@ async def send_message(self, content: str) -> None:
"Persona ID and LLM ID are not set, messages will not be processed by the backend."
)

# Wait for data channel to be ready
streaming = self._client._streaming_client
if not getattr(streaming, "_data_channel_open", False):
logger.debug("Waiting for data channel to open...")
if not await streaming.wait_for_data_channel(timeout=10.0):
raise SessionError("Data channel did not open in time")

streaming = await self._ensure_data_channel_open()
streaming.send_user_message(content)

async def interrupt(self) -> None:
Expand All @@ -560,17 +553,57 @@ async def interrupt(self) -> None:
Raises:
SessionError: If not connected.
"""
streaming = await self._ensure_data_channel_open()
streaming.send_interrupt()

async def send_director_note_cue(
self,
tag: str,
*,
at_seconds: float | None = None,
in_seconds: float | None = None,
) -> None:
"""Send a director-note cue over the active session data channel.

Args:
tag: Director-note cue tag (for example ``"curious"``).
at_seconds: Optional turn-relative cue time in seconds.
in_seconds: Optional delay relative to receipt time in seconds.

Raises:
SessionError: If not connected, data channel is unavailable, or send fails.
ValueError: If a provided timing value is not finite.
"""
for field_name, value in (("at_seconds", at_seconds), ("in_seconds", in_seconds)):
if value is not None and not math.isfinite(value):
raise ValueError(f"{field_name} must be a finite number")

payload: dict[str, Any] = {
"message_type": "director_note_cue",
"cue": {"tag": tag},
}

if at_seconds is not None:
payload["at_seconds"] = at_seconds

if in_seconds is not None:
payload["in_seconds"] = in_seconds

streaming = await self._ensure_data_channel_open()
if not streaming.send_data_message(json.dumps(payload)):
raise SessionError("Failed to send director note cue over data channel")

async def _ensure_data_channel_open(self):

@cubic-dev-ai cubic-dev-ai Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Static type checking will fail on this new helper because _ensure_data_channel_open has no return annotation while mypy disallows untyped defs. Adding -> StreamingClient matches the returned object and keeps the file consistent with the project config.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/anam/client.py, line 596:

<comment>Static type checking will fail on this new helper because `_ensure_data_channel_open` has no return annotation while mypy disallows untyped defs. Adding `-> StreamingClient` matches the returned object and keeps the file consistent with the project config.</comment>

<file context>
@@ -560,17 +553,57 @@ async def interrupt(self) -> None:
+        if not streaming.send_data_message(json.dumps(payload)):
+            raise SessionError("Failed to send director note cue over data channel")
+
+    async def _ensure_data_channel_open(self):
+        """Return the streaming client once the data channel is ready."""
         if not self._client._streaming_client:
</file context>
Suggested change
async def _ensure_data_channel_open(self):
async def _ensure_data_channel_open(self) -> StreamingClient:
Fix with cubic

"""Return the streaming client once the data channel is ready."""
if not self._client._streaming_client:
raise SessionError("Not connected")

# Wait for data channel to be ready (same as send_message)
streaming = self._client._streaming_client
if not getattr(streaming, "_data_channel_open", False):
logger.debug("Waiting for data channel to open...")
if not await streaming.wait_for_data_channel(timeout=10.0):
raise SessionError("Data channel did not open in time")

streaming.send_interrupt()
return streaming

def create_talk_stream(self, correlation_id: str | None = None) -> TalkMessageStream:
"""Create a talk message stream for sending text chunks to TTS.
Expand Down
34 changes: 34 additions & 0 deletions src/anam/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ class MessageRole(str, Enum):
SYSTEM = "system"


@dataclass
class DirectorNotes:
"""Director-notes settings applied at session start via ``PersonaConfig``.

These fields are optional and serialized only when set.
"""

custom_style_prompt: str | None = None
preset_style: str | None = None
idling_prompt: str | None = None
overlay_model_name_on_output: bool | None = None
expressivity: float | None = None

def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {}
if self.custom_style_prompt is not None:
result["customStylePrompt"] = self.custom_style_prompt
if self.preset_style is not None:
result["presetStyle"] = self.preset_style
if self.idling_prompt is not None:
result["idlingPrompt"] = self.idling_prompt
if self.overlay_model_name_on_output is not None:
result["overlayModelNameOnOutput"] = self.overlay_model_name_on_output
if self.expressivity is not None:
result["expressivity"] = self.expressivity

@cubic-dev-ai cubic-dev-ai Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Session startup can still send invalid JSON when DirectorNotes(expressivity=nan/inf) is used, because this float is serialized without the same finiteness guard added for runtime director-note cues. Consider validating expressivity with math.isfinite before adding it to directorNotes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/anam/types.py, line 74:

<comment>Session startup can still send invalid JSON when `DirectorNotes(expressivity=nan/inf)` is used, because this float is serialized without the same finiteness guard added for runtime director-note cues. Consider validating `expressivity` with `math.isfinite` before adding it to `directorNotes`.</comment>

<file context>
@@ -47,6 +47,34 @@ class MessageRole(str, Enum):
+        if self.overlay_model_name_on_output is not None:
+            result["overlayModelNameOnOutput"] = self.overlay_model_name_on_output
+        if self.expressivity is not None:
+            result["expressivity"] = self.expressivity
+        return result
+
</file context>
Fix with cubic

return result


@dataclass
class PersonaConfig:
"""Configuration for an Anam persona.
Expand All @@ -62,6 +90,7 @@ class PersonaConfig:
llm_id: LLM model to use (optional). Set to 'CUSTOMER_CLIENT_V1' to disable
Anam's default brain for custom LLM integration.
max_session_length_seconds: Maximum session duration (optional).
director_notes: Optional director-notes defaults applied at session start.
enable_audio_passthrough: If True, bypasses Anam's orchestration layer
and allows to ingest TTS audio directly through the socket.
"""
Expand All @@ -75,6 +104,7 @@ class PersonaConfig:
language_code: str | None = None
llm_id: str | None = None
max_session_length_seconds: int | None = None
director_notes: DirectorNotes | None = None
enable_audio_passthrough: bool | None = False

def to_dict(self) -> dict[str, Any]:
Expand All @@ -98,6 +128,10 @@ def to_dict(self) -> dict[str, Any]:
result["llmId"] = self.llm_id
if self.max_session_length_seconds is not None:
result["maxSessionLengthSeconds"] = self.max_session_length_seconds
if self.director_notes is not None:
director_notes = self.director_notes.to_dict()
if director_notes:
result["directorNotes"] = director_notes
if self.enable_audio_passthrough is not None:
result["enableAudioPassthrough"] = self.enable_audio_passthrough
return result
Expand Down
Loading
Loading