--- title: Director-note cues with Pipecat, timed to word timings description: "Make an Anam avatar perform on cue inside a Pipecat pipeline — write inline director notes, let Cartesia time them for you, and send them to Anam over the data channel." tags: [python, pipecat, director-notes, avatar] date: 2026-07-12 authors: [sr-anam] --- [Director notes](https://anam.ai/docs/personas/director-notes) let a Cara-4 avatar *perform* its speech — a laugh on the funny line, concern on the caveat. In a turnkey session you write inline `[tag]` cues in the text and Anam handles them. But in a Pipecat pipeline with audio passthrough, Anam only receives **audio**, not text — so it can't see your tags. You send cues yourself, over the WebRTC data channel, each stamped with the moment it should fire. This recipe wires that up: a Pipecat agent (Deepgram STT, OpenAI LLM, Cartesia TTS, Anam avatar via `AnamTransport`) that reads Cartesia's word timestamps and fires each cue on the word it belongs to. The complete code is at [examples/pipecat-director-notes](https://github.com/anam-org/anam-cookbook/tree/main/examples/pipecat-director-notes). ## The timing problem A cue over the data channel is a small JSON message: ```json { "message_type": "director_note_cue", "cue": { "tag": "laughter" }, "at_seconds": 1.2 } ``` `at_seconds` is an offset **from the start of the current speech turn**. So to land a laugh on the word "hilarious", you need to know *when* the avatar will say "hilarious" — before it says it, with enough lead time for the message to reach Anam. ## Cartesia hands you the timing Here's the shortcut: Cartesia (Sonic) accepts inline `[tag]` markers in the transcript and **echoes them straight back in its word timestamps**, each stamped with the moment it's spoken, with respect to the TTS audio this turn. One turn is one Cartesia context, so those offsets are already turn-relative — exactly your `at_seconds`. Send `"Hello [laughter] everyone today."` with `add_timestamps` on, and the timestamps come back like this: ```python words = ['Hello', '[laughter]', 'everyone', 'today.'] starts = [ 0.122, 0.706, 1.219, 1.721] ``` The `[laughter]` marker comes back as its own token, timed to where it sits — `0.706`, just ahead of "everyone". That start time *is* your `at_seconds`; no anchor-matching required. You asked Cartesia to speak the marked text, and it told you exactly when each mark lands. (The tag takes its own short slot in the audio; glue it to the next word — `[laughter]everyone` — and Cartesia folds them into one token with no gap, if you prefer.) So there's no stripping-and-re-deriving from neighbouring words: the TTS already did the alignment. You read it off. ## Reading the cue Two small pieces. `cues.py` pulls the tag(s) out of a Cartesia token — pure logic, so `python cues.py` self-checks: ```python _TAG_RE = re.compile(r"\[([a-zA-Z_]+)\]") def cue_tags(token): """Valid Anam tags in a Cartesia word token: '[happy]' -> ['happy'].""" return [t for m in _TAG_RE.finditer(token) if (t := m.group(1).lower()) in CUE_TAGS] ``` The TTS subclass overrides one method — `add_word_timestamps`, which Cartesia calls with the raw word timings ahead of playback — and fires a cue for any token that carries one: ```python class DirectorCueCartesiaTTSService(CartesiaTTSService): def __init__(self, *args, on_cue, **kwargs): super().__init__(*args, **kwargs) self._on_cue = on_cue async def add_word_timestamps(self, word_times, context_id=None, *args, **kwargs): for word, at_seconds in word_times: # [(word, start_seconds), ...] for tag in cue_tags(word): await self._fire(tag, max(0.0, float(at_seconds))) await super().add_word_timestamps(word_times, context_id, *args, **kwargs) # keep normal behaviour ``` `on_cue` is a callback, so the TTS never has to know about the transport; `_fire` wraps it in a try/except so a cue that arrives before the session is live can't kill the pipeline. ## Authoring cues Write the tag in square brackets **just before the word it should land on**. It comes back as its own token, timed to that spot: ``` "Hi [warm] there! [concerned] Honestly, that story was [laughter] hilarious." ``` The system prompt teaches the LLM the tag vocabulary and where to place cues (just before the word), so it can add its own as the conversation goes. ## Wiring it up `AnamTransport.send_director_note_cue(tag, at_seconds=...)` sends the cue on the active Anam session. Wire it straight into the TTS as `on_cue`: ```python transport = AnamTransport( api_key=os.environ["ANAM_API_KEY"], persona_config=PersonaConfig( avatar_id=os.environ["ANAM_AVATAR_ID"], avatar_model="cara-4", # director notes require Cara-4 enable_audio_passthrough=True, ), daily_room_url=os.environ["DAILY_ROOM_URL"], ) tts = DirectorCueCartesiaTTSService( api_key=os.environ["CARTESIA_API_KEY"], on_cue=transport.send_director_note_cue, # cue → Anam, over the data channel settings=CartesiaTTSService.Settings(voice="e8e5fffb-252c-436d-b842-8879b84445b6"), ) ``` Drop `tts` into the pipeline in its usual spot (`... llm, tts, transport.output(), ...`). Set a baseline delivery for the whole session with `PersonaConfig(director_notes=DirectorNotes(preset_style="warm", expressivity=0.7))`; the per-word cues shift it for the moments you mark. ## Running it ```bash cd examples/pipecat-director-notes uv sync cp .env.example .env # add your keys and DAILY_ROOM_URL uv run python bot.py ``` Open your `DAILY_ROOM_URL` in a browser to join. On connect the avatar opens with a short LLM greeting — primed to use a cue so you can see one fire — and adds more as the conversation goes (it's told the tag vocabulary in the system prompt). ## Caveats - **Cara-4 only.** Director notes need a Cara-4 avatar; stock avatars default to Cara-3. - **Cartesia sees the tags too.** The markers go to Cartesia unchanged. An emotion tag it doesn't recognise (`[warm]`, `[concerned]`, …) is absorbed into the word as a timing marker with negligible audio effect; a non-verbal it *does* know — notably `[laughter]` — it will also render in the voice. Usually a plus (voice and face align), but test your tag set, and expect the markers to show up in the assistant transcript. - **A spaced tag takes its own slot.** `[tag] word` comes back as its own token — its start is the cue's `at_seconds`, a beat ahead of the word, with a short slot in the audio. Glue it (`[tag]word`) and Cartesia folds it into the word with no gap instead; the code reads both. - **One turn, one context.** `at_seconds` is relative to the turn's Cartesia context, which spans the whole turn (`reuse_context_id_within_turn`). The echoed start times are already in that frame — nothing to reset. - **Cues need a live session.** `send_director_note_cue` raises if the Anam session isn't active yet, so the demo runs its opening LLM turn from `on_avatar_connected` — which fires only after the session is up (reactive turns are naturally post-connection). `_fire` still wraps the call in a try/except, so a cue that races a session drop is logged and skipped, never fatal. - **Provider-specific.** This leans on Cartesia echoing inline markers in its timestamps (verified on `sonic-3.5`). A TTS that strips unknown markers, or omits them from timestamps, won't work this way — you'd fall back to matching a cue against a neighbouring word's timing.