persona_config = anam.PersonaConfig( name="Maya", # Display name for the avatar avatarId="uuid-here", # Avatar appearance ID avatarModel="cara-4", # Avatar model version)
Inline cues let the avatar shift emotion or delivery mid-turn (see Director Notes for the cue list and preset styles). Your LLM writes cues as square-bracketed tags in the spoken text, for example [warm] Hello there. [curious] What brings you here?.Director Notes require a Cara 4 avatar. They work best with a voice that matches the intended performance and a neutral-expression avatar source image; an overly smiley, sad, or angry source image can limit how far cues can move the performance.In a LiveKit agent, Anam reads those cues from the agent’s TTS-aligned transcript, which you publish to the room. Wiring this up takes two things:
use_tts_aligned_transcript=True on the AgentSession, so the agent produces a transcript with per-word timings.
A JSON transcription sink scoped to the agent’s own identity, so that timed transcript (cue tags included) is published to the room on the lk.transcription stream, where the Anam engine reads it to drive per-utterance expression.
This path leaves the [cue] tags in the text sent to your TTS, so it only works with a TTS that does not read square-bracketed cues aloud — for example Cartesia sonic-3.5. Providers that speak the brackets (such as ElevenLabs) are not supported here.
from livekit.agents import AgentSessionfrom livekit.agents.voice.room_io import _ParticipantTranscriptionOutputfrom livekit.plugins import anam, cartesiasession = AgentSession( # ... stt / llm / vad / turn_detection ... tts=cartesia.TTS(model="sonic-3.5"), # does not vocalise "[cue]" tags use_tts_aligned_transcript=True,)avatar = anam.AvatarSession( persona_config=anam.PersonaConfig( name="Cara", avatarId=os.getenv("ANAM_AVATAR_ID"), avatarModel="cara-4", ), api_key=os.getenv("ANAM_API_KEY"),)# Order matters: avatar.start sets the audio sink, then attach the transcription# sink, then session.start — both sinks stay in place.await avatar.start(session, room=ctx.room)# Publish the timed, cue-tagged transcript so the engine can resolve cues against it.# `participant` MUST be the agent's own identity (it is the transcript's sender);# leave it unset and every chunk is silently dropped.session.output.transcription = _ParticipantTranscriptionOutput( room=ctx.room, is_delta_stream=True, participant=ctx.room.local_participant.identity, json_format=True,)await session.start(agent=Assistant(), room=ctx.room)
_ParticipantTranscriptionOutput is currently an internal LiveKit Agents API. Set the transcription sink afteravatar.start() and beforesession.start().
The transcript sink only forwards cues that are already in the agent’s replies — nothing generates them for you. Your LLM has to write the [cue] tags inline, so add a short instruction to the agent’s system prompt:
Use Anam cue tags sparingly and naturally to mark emotional shifts in your spoken replies.Available cue tags are: [happy], [warm], [playful], [curious], [supportive], [concerned], [sad], [surprised], [angry], [distressed], and [laughter].Place tags inline before the words they should affect.Only use a tag when the delivery should noticeably change. Do not explain the tags.
from livekit.agents import function_tool@function_toolasync def fill_form_field(field_name: str, value: str) -> str: """Fill in a form field on the user's screen. Args: field_name: The name of the field to fill value: The value to enter Returns: Confirmation message """ await send_command_to_frontend("fill_field", {"field": field_name, "value": value}) return "Field filled successfully"session = AgentSession( llm=llm, tools=[fill_form_field],)