---
name: vr-handler-dev
description: Develop voice agent handler code using the VoiceRun event-driven architecture. Covers events, context, LLM completions, tools, TTS, and conversation patterns.
---

# Develop a VoiceRun Agent Handler

You are helping the user write or modify their voice agent's `handler.py`. VoiceRun uses an event-driven architecture where the handler receives input events and yields output events. Follow these guidelines:

## Handler Structure

The handler is an async generator function:

```python
from primfunctions.events import (
    Event, StartEvent, TextEvent, StopEvent, InterruptEvent,
    TimeoutEvent, DTMFEvent, TextToSpeechEvent
)
from primfunctions.actions import (
    AudioEvent, SilenceEvent,
    StopEvent as StopAction, TransferSessionEvent,
    STTUpdateSettingsEvent, CustomEvent, ExternalEvent,
    InputAllowedEvent, StartRecordingEvent, StopRecordingEvent
)
from primfunctions.context import Context

async def handler(event: Event, context: Context):
    # Handle events here
    pass
```

## Input Events Reference

### StartEvent
Emitted when a new session begins. Use for greetings and initialization.

```python
if isinstance(event, StartEvent):
    yield TextToSpeechEvent(text="Hello! How can I help you?", voice="nova")
```

### TextEvent
Emitted when the user speaks or types. Contains the transcribed text.

```python
if isinstance(event, TextEvent):
    user_message = event.data.get("text", "")
    source = event.data.get("source")  # "speech" or "text"
    language = event.data.get("language")  # Language code from STT
```

### TimeoutEvent
Emitted after 5 seconds of silence. Counter increments with consecutive timeouts and resets when the user speaks.

```python
if isinstance(event, TimeoutEvent):
    count = event.data.get("count", 0)
    if count >= 3:
        yield TextToSpeechEvent(text="Are you still there?")
```

### DTMFEvent
Emitted when phone keypad tones are detected.

```python
if isinstance(event, DTMFEvent):
    digits = event.data.get("digits")
    if digits == "1":
        yield TextToSpeechEvent(text="You pressed one.")
```

### InterruptEvent
Emitted when the user interrupts the agent while it's speaking.

### StopEvent
Emitted when the session ends.

## Output Events Reference

### TextToSpeechEvent — Speak text
```python
yield TextToSpeechEvent(
    text="Hello!",
    voice="nova",                    # Voice name or {"provider": "azure", "identifier": "en-US-JennyNeural"}
    cache=True,                      # Cache generated audio
    interruptible=True,              # Can user interrupt
    instructions="warm and friendly", # Voice styling (OpenAI voices only)
    speed=1.0,                       # Playback speed
    language="en",                   # Language code
    stream=True,                     # Enable streaming TTS
)
```

### AudioEvent — Play audio from URL
```python
yield AudioEvent(path="https://example.com/audio.mp3", interruptible=True, loop=False)
```

### SilenceEvent — Pause
```python
yield SilenceEvent(duration=2000)  # 2 seconds
```

### StopAction — End session
```python
yield StopAction(closing_speech="Goodbye!", voice="nova")
```

### TransferSessionEvent — Transfer to phone number or agent
```python
# Phone transfer
yield TransferSessionEvent(
    phone_number="+15555551234",
    closing_speech="Transferring you now.",
    data={"reason": "technical_support"}
)

# Agent transfer
yield TransferSessionEvent(
    agent_id="specialist_agent",
    environment="production",
    data={"context": "billing_issue"}
)
```

### STTUpdateSettingsEvent — Change speech-to-text settings mid-conversation
```python
yield STTUpdateSettingsEvent(language="es", model="nova-3", endpointing=500)
```

### InputAllowedEvent — Enable/disable user input
```python
yield InputAllowedEvent(allowed=False)  # Disable input during processing
# ... do work ...
yield InputAllowedEvent(allowed=True)   # Re-enable
```

### Recording control
```python
yield StartRecordingEvent()
yield StopRecordingEvent()
```

### CustomEvent / ExternalEvent — Advanced
```python
yield CustomEvent(name="ui_update", data={"screen": "checkout"})
yield ExternalEvent(agent_id="supervisor", session_id="abc", name="escalation", data={})
```

## Context Object

The `context` object manages session state and is available via import:

```python
from primfunctions.context import context
```

### Key properties
- `context.agent_id` — Agent identifier
- `context.session_id` — Session identifier
- `context.environment` — Environment name
- `context.variables` — Environment and organization secrets/variables (e.g., API keys)
- `context.data` — Custom session data store

### Session data
```python
context.set_data("user_name", "John")
name = context.get_data("user_name", "Guest")
```

### Completion messages (conversation history for LLMs)
```python
messages = context.get_completion_messages()
context.add_completion_message(message)
context.set_completion_messages(messages)
```

### Background tasks
```python
async def long_running_task(ctx):
    await asyncio.sleep(5)
    ctx.set_data("result", "done")

context.create_task(long_running_task(context), name="my_task", timeout=60)
```

### A/B testing
```python
greeting = context.add_test("greeting_style", {
    "Hello! How can I help?": 0.5,
    "Hi there! What can I do for you?": 0.5,
})
yield TextToSpeechEvent(text=greeting)
```

### Outcomes tracking
```python
context.add_outcome("message_count", "integer")
context.trigger_outcome("message_count")  # Increments by 1
context.set_outcome("order_value", 29.99)
```

## LLM Integration with voicerun_completions

For agents that use LLMs, use the `voicerun_completions` library:

```python
from voicerun_completions import (
    generate_chat_completion,
    generate_chat_completion_stream,
    deserialize_conversation,
    UserMessage,
    SystemMessage,
    ToolResultMessage,
    ToolDefinition,
    FunctionDefinition,
)

if isinstance(event, TextEvent):
    user_message = event.data.get("text", "")

    # Get conversation history
    messages = deserialize_conversation(context.get_completion_messages())
    messages.append(UserMessage(content=user_message))

    # Non-streaming completion
    response = await generate_chat_completion({
        "provider": "anthropic",
        "api_key": context.variables.get("ANTHROPIC_API_KEY"),
        "model": "claude-haiku-4-5",
        "messages": messages,
        "temperature": 0.7,
        "max_tokens": 500,
    })

    messages.append(response.message)
    context.set_completion_messages(messages)
    yield TextToSpeechEvent(text=response.message.content, voice="nova")
```

### Streaming completions (lower latency)

```python
stream = await generate_chat_completion_stream(
    request={
        "provider": "anthropic",
        "api_key": context.variables.get("ANTHROPIC_API_KEY"),
        "model": "claude-haiku-4-5",
        "messages": messages,
    },
    stream_options={"stream_sentences": True, "clean_sentences": True}
)

async for chunk in stream:
    if chunk.type == "content_sentence":
        yield TextToSpeechEvent(text=chunk.sentence, voice="nova")
    elif chunk.type == "response":
        messages.append(chunk.response.message)

context.set_completion_messages(messages)
```

### Tool calling with LLMs

```python
tools = [
    ToolDefinition(
        type="function",
        function=FunctionDefinition(
            name="get_weather",
            description="Get weather for a location",
            parameters={
                "type": "object",
                "properties": {"location": {"type": "string"}},
                "required": ["location"]
            }
        )
    )
]

response = await generate_chat_completion({
    "provider": "anthropic",
    "api_key": context.variables.get("ANTHROPIC_API_KEY"),
    "model": "claude-haiku-4-5",
    "messages": messages,
    "tools": tools,
    "tool_choice": "auto",
})

# Handle tool calls
if response.message.tool_calls:
    messages.append(response.message)
    for tool_call in response.message.tool_calls:
        result = await execute_tool(tool_call.function.name, tool_call.function.arguments)
        messages.append(ToolResultMessage(
            tool_call_id=tool_call.id,
            name=tool_call.function.name,
            content=result
        ))

    # Second turn with tool results
    response = await generate_chat_completion({...same config..., "messages": messages})
    messages.append(response.message)
```

### Provider fallbacks and retries

```python
response = await generate_chat_completion({
    "provider": "anthropic",
    "api_key": context.variables.get("ANTHROPIC_API_KEY"),
    "model": "claude-haiku-4-5",
    "messages": messages,
    "retry": {
        "max_retries": 3,
        "retry_delay": 1.0,
        "backoff_multiplier": 2.0,
    },
    "fallbacks": [
        {"provider": "openai", "api_key": context.variables.get("OPENAI_API_KEY"), "model": "gpt-4.1-mini"},
        {"provider": "google", "api_key": context.variables.get("GEMINI_API_KEY"), "model": "gemini-2.5-flash"},
    ]
})
```

## Common Patterns

### Greeting + LLM conversation loop

```python
async def handler(event, context: Context):
    if isinstance(event, StartEvent):
        context.set_completion_messages([
            {"role": "system", "content": "You are a helpful voice assistant. Keep responses concise."}
        ])
        yield TextToSpeechEvent(text="Hello! How can I help you today?", voice="nova")

    if isinstance(event, TextEvent):
        user_message = event.data.get("text", "")
        messages = deserialize_conversation(context.get_completion_messages())
        messages.append(UserMessage(content=user_message))

        response = await generate_chat_completion({...})
        messages.append(response.message)
        context.set_completion_messages(messages)
        yield TextToSpeechEvent(text=response.message.content, voice="nova")

    if isinstance(event, StopEvent):
        yield TextToSpeechEvent(text="Goodbye!")
```

### Timeout handling

```python
if isinstance(event, TimeoutEvent):
    count = event.data.get("count", 0)
    if count == 1:
        yield TextToSpeechEvent(text="Take your time, I'm here when you're ready.")
    elif count >= 3:
        yield StopAction(closing_speech="It seems like you've stepped away. Goodbye!")
```

### DTMF menu

```python
if isinstance(event, StartEvent):
    yield TextToSpeechEvent(text="Press 1 for sales, 2 for support, or 0 for an operator.")

if isinstance(event, DTMFEvent):
    digits = event.data.get("digits")
    if digits == "1":
        yield TransferSessionEvent(agent_id="sales_agent", environment="production")
    elif digits == "2":
        yield TransferSessionEvent(agent_id="support_agent", environment="production")
    elif digits == "0":
        yield TransferSessionEvent(phone_number="+15555550000")
```

## Documentation

Refer to the [VoiceRun documentation](https://docs.voicerun.com) for complete API references:
- [Event Reference](https://docs.voicerun.com/agent-building/events)
- [Context Reference](https://docs.voicerun.com/agent-building/context)
- [VoiceRun Completions](https://docs.voicerun.com/voicerun-completions/overview)
- [Speech to Text](https://docs.voicerun.com/agent-building/speech-to-text)
- [Text to Speech](https://docs.voicerun.com/agent-building/text-to-speech)
- [Guides](https://docs.voicerun.com/agent-building/guides)

Help the user implement their specific use case by combining these patterns. Always test with `vr debug` after making changes.
