createdTime: string
}>
}
```
---
### airtable.deleteRecords
Deletes up to 10 records by ID.
**Parameters:**
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `baseId` | `string` | Yes | Airtable base ID |
| `tableIdOrName` | `string` | Yes | Table ID or name |
| `recordIds` | `string[]` | Yes | Array of record IDs to delete (max 10) |
**Returns:**
```typescript
{
records: Array<{
id: string
deleted: boolean
}>
}
```
## Batch Limits
All write operations (create, update, delete) are limited to **10 records per request**. This matches the Airtable API limit. For larger operations, the agent should batch records into groups of 10.
## Common Patterns
### Syncing Entities to Airtable
An agent can sync Struere entities to an Airtable table for reporting:
```
User: "Sync all active students to the Students table in Airtable"
Agent flow:
1. entity.query — get all active student entities
2. airtable.listTables — verify the Students table exists and get field names
3. airtable.createRecords — batch create records (10 at a time)
```
### Importing from Airtable
```
User: "Import the leads from our Airtable CRM"
Agent flow:
1. airtable.listRecords — fetch records with pagination
2. entity.create — create Struere entities for each record
```
### Filtering Records
Use Airtable formulas to filter server-side:
```json
{
"baseId": "appABC123",
"tableIdOrName": "Tasks",
"filterByFormula": "AND({Status} = 'Open', {Priority} = 'High')",
"sort": [{ "field": "Created", "direction": "desc" }],
"pageSize": 20
}
```
## Environment Scoping
The Airtable integration configuration is environment-scoped. You can use different PATs (or the same PAT) for development and production environments. Configure each environment separately via `--env development` / `--env production` in the CLI, or in the dashboard.
---
## Resend
> Send transactional emails from your agents via Resend
Source: https://docs.struere.dev/integrations/resend.md
Struere integrates with [Resend](https://resend.com) to give agents the ability to send transactional emails. The integration is **platform-managed** — Struere holds the Resend API key, and all emails are sent from `noreply@mail.struere.dev`. Organizations can optionally configure a display name and reply-to address. Delivery status is tracked automatically via webhooks.
## Architecture
```
Agent calls email.send tool
|
v
Struere Backend (resolve org from-config, call Resend API)
|
v
Resend API (sends email, returns resendId)
|
v
emailMessages record created (status: "sent", credits deducted)
|
v
Resend Webhook (/webhook/resend)
|
v
Status updated: sent → delivered / bounced / complained
```
## Setup
### 1. Configure sender identity (optional)
You can configure sender identity from the **CLI** or the **dashboard**.
**CLI:**
```bash
bunx struere integration resend \
--from-name "Your App" \
--reply-to support@yourapp.com \
--test
```
**Dashboard:** Navigate to **Settings > Integrations > Resend**.
All emails are sent from `noreply@mail.struere.dev`. You can optionally configure:
| Field | Default | CLI Flag | Description |
|-------|---------|----------|-------------|
| From Name | None | `--from-name` | Display name shown to recipients |
| Reply-To | None | `--reply-to` | Where replies are directed |
If you skip this step, emails are sent from `noreply@mail.struere.dev` with no display name or reply-to.
See [`struere integration`](/cli/integration) for all CLI options.
### 2. Add the email tool to your agent
```typescript
import { defineAgent } from 'struere'
export default defineAgent({
name: "Notifications Agent",
slug: "notifications",
tools: [
"email.send",
],
systemPrompt: `You send email notifications for {{organizationName}}.
When asked to notify someone, compose a clear email and send it using the email.send tool.
Always confirm the recipient and subject before sending.`,
model: { model: "openai/gpt-5-mini" },
})
```
## Footguns
Behaviors that aren't obvious from the type signatures.
### Symptom: Outbound emails come from `noreply@struere.dev` no matter what you set in config
**Cause:** `email.send` hardcodes the default `fromEmail`; `resolveFromConfig` ignores the org's configured `fromEmail`.
**Fix:** This is a platform bug -- track on the issues board. Workaround: pass `from` explicitly in the tool args.
### Symptom: Emails fail to send with cryptic 400 from Resend API
**Cause:** From-email isn't verified in Resend dashboard; the platform doesn't pre-validate.
**Fix:** Verify the domain in Resend before configuring it.
### Symptom: Webhook events processed twice on Resend retries
**Cause:** `processedWebhooks` not consulted in the Resend handler -- `svixId` is available but unused.
**Fix:** Handle email status idempotently in your downstream logic.
### Symptom: `replyTo` set in config is ignored when an agent passes `replyTo` argument
**Cause:** Precedence is `args.replyTo || config.replyTo`.
**Fix:** Leave the agent's `replyTo` empty if you want config to take effect.
See [Platform Gotchas](/platform/gotchas) for cross-cutting silent failures across the platform.
## Available Tools
### email.send
Sends a transactional email via Resend.
**Parameters:**
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `to` | `string` | Yes | Recipient email address |
| `subject` | `string` | Yes | Email subject line |
| `html` | `string` | No | HTML body content |
| `text` | `string` | No | Plain text body content |
| `replyTo` | `string` | No | Override the reply-to address for this email |
At least one of `html` or `text` must be provided. If both are provided, Resend sends a multipart email.
**Returns:**
```typescript
{
resendId: string
to: string
subject: string
status: "sent"
}
```
**Example call:**
```json
{
"to": "parent@example.com",
"subject": "Session Reminder",
"html": "Your child's session is tomorrow at 3pm.
",
"replyTo": "support@school.com"
}
```
## Delivery Tracking
Every outbound email is stored in the `emailMessages` table with full lifecycle tracking:
| Field | Description |
|-------|-------------|
| `organizationId` | Owning organization |
| `environment` | `development` or `production` |
| `to` | Recipient address |
| `from` | Sender address (`noreply@mail.struere.dev`) |
| `subject` | Email subject |
| `resendId` | Resend's unique email ID |
| `status` | Current delivery status |
| `creditsConsumed` | Cost in microdollars |
### Status Flow
```
sent → delivered
sent → bounced
sent → complained
```
Status updates arrive via Resend's webhook system. The `/webhook/resend` endpoint verifies Svix signatures and updates the `emailMessages` record automatically.
## Pricing
Email sends are charged at a flat rate per email:
| Metric | Value |
|--------|-------|
| Base cost | $0.90 per 1,000 emails ($0.0009 per email) |
| Platform markup | 1.1x |
| Effective cost | **990 microdollars per email** (~$0.00099) |
Credits are deducted immediately when the email is sent. The `creditsConsumed` field on the `emailMessages` record tracks the exact amount charged.
## Common Patterns
### Sending notifications from triggers
Combine with triggers to send emails when entities change:
```typescript
import { defineTrigger } from 'struere'
export default defineTrigger({
name: "New Order Email",
slug: "new-order-email",
on: {
entityType: "order",
action: "created",
},
actions: [
{
tool: "email.send",
args: {
to: "{{entity.data.customerEmail}}",
subject: "Order Confirmed: #{{entity.data.orderNumber}}",
html: "Your order has been confirmed. We'll notify you when it ships.
",
},
},
],
})
```
### HTML emails with dynamic content
Agents can compose HTML emails dynamically based on conversation context:
```
User: "Send Alice a summary of today's sessions"
Agent flow:
1. entity.query — get today's session entities
2. Compose HTML table with session details
3. email.send — send formatted email to Alice
```
### Plain text fallback
For simple notifications, use `text` instead of `html`:
```json
{
"to": "team@example.com",
"subject": "Daily Report Ready",
"text": "The daily report for 2025-01-15 has been generated. Log in to view it."
}
```
## Environment Scoping
The Resend integration configuration is environment-scoped. You can configure different display names and reply-to addresses for development and production.
## Webhook Configuration
To enable delivery tracking, configure a webhook in your [Resend dashboard](https://resend.com/webhooks):
| Setting | Value |
|---------|-------|
| Endpoint URL | `https:///webhook/resend` |
| Events | `email.sent`, `email.delivered`, `email.bounced`, `email.complained` |
The webhook uses Svix signature verification. Set the `RESEND_WEBHOOK_SECRET` environment variable in your Convex dashboard to the signing secret from Resend (format: `whsec_...`).
---
## Voice Integration
> AI voice agents via Twilio and OpenAI Realtime API
Source: https://docs.struere.dev/integrations/voice.md
Struere integrates with Twilio for telephony and OpenAI's Realtime API for voice-to-voice AI conversations. Voice agents handle inbound and outbound phone calls with real-time speech recognition, natural voice synthesis, and optional auditor-based validation.
## Architecture
```
Phone Call (inbound or outbound)
|
v
Twilio Media Streams (WebSocket, g711_ulaw)
|
v
Voice Gateway (Fly.io, Hono + Bun)
|
v
OpenAI Realtime API (voice-to-voice, WebSocket)
|
v (optional)
Voice Agent responds <--- Auditor Agent polls /v1/chat
validates data, injects corrections
```
The voice gateway is a standalone service (`platform/voice-gateway/`) that bridges Twilio's Media Streams with OpenAI's Realtime API. Audio flows as raw g711_ulaw between Twilio and OpenAI with no transcoding.
## Dual-Agent Architecture
Voice calls support two modes:
### Single Agent
A voice agent handles the call directly. The agent's system prompt and tools are loaded from its config and sent to the OpenAI Realtime session.
### Dual Agent (Voice + Auditor)
A voice agent handles the conversation while an auditor agent runs in the background:
- **Voice agent** -- The OpenAI Realtime session that speaks with the caller. Follows a script, asks questions, collects information.
- **Auditor agent** -- A standard Struere text agent that polls the call transcript every N seconds via `/v1/chat`. Validates collected data, fills entities, and can inject corrections back into the voice call.
The auditor polls the voice gateway at a configurable interval (default 5 seconds). When the auditor calls `voice.inject`, the correction is spoken by the voice agent in its own voice.
## Database Tables
### voiceConnections
Stores the connection between an organization and a Twilio phone number. Scoped by environment.
| Field | Type | Description |
|-------|------|-------------|
| `organizationId` | `Id<"organizations">` | The owning organization |
| `environment` | `"development" \| "production" \| "eval"` | Environment scope |
| `status` | `"disconnected" \| "connected" \| "removed"` | Current connection state |
| `label` | `string?` | Display label for the connection |
| `twilioAccountSid` | `string` | Twilio Account SID |
| `twilioPhoneNumber` | `string` | The Twilio phone number |
| `phoneNumberSid` | `string?` | Twilio Phone Number SID |
| `agentId` | `Id<"agents">?` | Agent assigned to handle inbound calls |
| `routerId` | `Id<"routers">?` | Router assigned to handle inbound calls |
## Setup
### Quick start: bind a phone to a single agent
This is the simplest path: one agent answers every inbound call on the phone number. Use it when you don't need multi-agent routing or custom voice settings.
**Step 1. Configure Twilio credentials.**
```bash
bunx struere integration twilio --account-sid --auth-token
```
**Step 2. Connect a phone number to your agent.**
```bash
bunx struere integration twilio \
--phone-number +1XXXXXXXXXX \
--agent
```
This creates a `voiceConnections` row binding the phone number directly to the agent. Inbound calls reach the agent through OpenAI Realtime with the platform defaults: `provider: "openai"`, `voice: "alloy"`, single-agent mode (no auditor), `pollInterval: 5000`. The model, turn detection, and noise reduction fall back to OpenAI Realtime built-in defaults.
Use this path when one agent handles all calls. To override voice/model defaults or run multiple agents on the same number, see "Advanced: routing between agents" below.
### Advanced: routing between agents
Use a router when you need either of:
- Multiple agents on the same phone number (e.g. an intake agent that hands off to a support agent)
- Custom `voiceConfig` -- voice, model, auditor, turn detection, or noise reduction
Voice configuration lives on the router definition. **When you provide `voiceConfig`, `auditorAgent` is required at runtime** -- pass an empty string only if you understand single-agent mode is enforced upstream by omitting `voiceConfig` entirely.
```typescript
import { defineRouter } from 'struere'
export default defineRouter({
name: "Phone Support",
slug: "phone-support",
mode: "classify",
agents: [
{ slug: "intake-agent", description: "Handles new caller intake and data collection" },
{ slug: "support-agent", description: "Handles technical support questions" },
],
classifyModel: { model: "openai/gpt-5-mini" },
fallback: "intake-agent",
voiceConfig: {
provider: "openai-realtime",
model: "gpt-realtime-mini",
voice: "coral",
auditorAgent: "form-auditor",
pollInterval: 5000,
turnDetection: {
type: "semantic_vad",
eagerness: "medium",
},
noiseReduction: "near_field",
},
})
```
Bind the router to the phone number:
```bash
bunx struere integration twilio \
--phone-number +1XXXXXXXXXX \
--router
```
### Disconnect a phone number
Remove a single phone number from your voice setup without touching Twilio credentials:
```bash
bunx struere integration twilio --remove-phone +1XXXXXXXXXX
```
This soft-deletes the voice connection. You can reconnect the same number later with `--phone-number` + `--agent` (or `--router`).
To remove the entire Twilio integration (credentials AND all phone connections for the current environment), use:
```bash
bunx struere integration twilio --remove
```
## Footguns
Behaviors that aren't obvious from the type signatures.
### Symptom: Caller hears a confused vanilla model
**Cause:** `voice.call` was invoked without `agentSlug` -- the LLM didn't pass it because the system prompt didn't reference it. The voice gateway falls back to "You are a helpful voice assistant." with no application context.
**Fix:** Ensure the agent's system prompt instructs the LLM to pass `agentSlug` literally when invoking `voice.call`. As of CLI v0.14.8, `sync` blocks this case at validation time.
### Symptom: Sync fails with `voiceConfig.auditorAgent references unknown agent: undefined`
**Cause:** The SDK type marks `auditorAgent` as optional, but the runtime in `platform/voice-gateway/src/auditor/poller.ts` starts the poller whenever `voiceConfig` is provided, and crashes when the slug is undefined.
**Fix:** Set `auditorAgent` explicitly. For single-agent setups it can self-reference the same agent (e.g. `auditorAgent: 'voice-suplente'`). Or omit `voiceConfig` entirely to use platform defaults.
### Symptom: Voice agent doesn't know the match/customer/order it's calling about
**Cause:** Voice sessions don't inherit context from the caller. Each `voice.call` spawns an isolated thread (`platform/voice-gateway/src/ws/media-stream.ts:221`).
**Fix:** Thread context explicitly in the orchestrator's message -- pass IDs and key fields literally in the `agent.chat` message that triggers the voice agent.
### Symptom: Inbound call still routes to the old agent after you renamed/deleted it
**Cause:** `voiceConnections` stores `agentId`/`routerId`, and `/v1/voice/config` doesn't validate the referenced agent still exists -- silent fallback to vanilla model.
**Fix:** When renaming or deleting agents, reassign the voice connection via `bunx struere integration twilio --phone-number ... --agent ` or via the dashboard.
### Symptom: Voice connection stuck in `pending` status
**Cause:** Race in `media-stream.ts:269` where status is set after thread creation; if thread creation hangs, status never advances.
**Fix:** Hang up and retry. If persistent, clear with `--remove-phone` and reconnect.
### Symptom: Auditor injects corrections too aggressively, agent gets repeatedly cut off
**Cause:** `pollInterval` set very low.
**Fix:** Keep `pollInterval >= 5000`. Values below 3000ms can race and produce overlapping injections.
See [Platform Gotchas](/platform/gotchas) for cross-cutting silent failures across the platform.
## Voice Configuration Reference
> Voice configuration is set on a router via `voiceConfig`. When a phone is bound directly to an agent (no router), the platform uses these defaults: `provider: "openai"`, `voice: "alloy"`, single-agent mode (no auditor), `pollInterval: 5000`. Override these by switching to a router.
The `voiceConfig` object on a router controls how voice calls are handled.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `provider` | `string` | `"openai-realtime"` | Voice provider. Currently only `"openai-realtime"` is supported. |
| `model` | `string` | `"gpt-realtime-mini"` | OpenAI Realtime model. Options: `"gpt-realtime-mini"`, `"gpt-realtime-1.5"` |
| `voice` | `string` | `"alloy"` | Voice for speech synthesis |
| `auditorAgent` | `string?` | -- | Slug of the auditor agent for dual-agent mode |
| `pollInterval` | `number` | `5000` | Auditor polling interval in milliseconds |
| `turnDetection` | `object` | `semantic_vad, medium` | How the model detects when the user has finished speaking |
| `noiseReduction` | `string?` | -- | Noise reduction mode: `"near_field"` or `"far_field"` |
### Available Voices
| Voice |
|-------|
| `alloy` |
| `ash` |
| `ballad` |
| `coral` |
| `echo` |
| `sage` |
| `shimmer` |
| `verse` |
| `marin` |
| `cedar` |
### Turn Detection
Turn detection determines when the model considers the user's turn to be complete.
**Semantic VAD** (recommended) -- Uses semantic understanding to detect turn boundaries:
```typescript
turnDetection: {
type: "semantic_vad",
eagerness: "medium",
}
```
| Field | Type | Options | Description |
|-------|------|---------|-------------|
| `eagerness` | `string` | `"low"`, `"medium"`, `"high"`, `"auto"` | How eagerly the model responds. Lower values wait longer for the user to finish. |
**Server VAD** -- Traditional voice activity detection based on audio levels:
```typescript
turnDetection: {
type: "server_vad",
threshold: 0.5,
silenceDurationMs: 500,
prefixPaddingMs: 300,
}
```
| Field | Type | Description |
|-------|------|-------------|
| `threshold` | `number` | Audio level threshold (0.0 to 1.0) |
| `silenceDurationMs` | `number` | Milliseconds of silence before turn ends |
| `prefixPaddingMs` | `number` | Milliseconds of audio to include before detected speech |
## Voice Tools
### voice.call
Initiates an outbound voice call to a phone number. The call connects through Twilio and starts an OpenAI Realtime session with the configured voice settings.
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `phoneNumber` | `string` | Yes | Phone number to call (E.164 format) |
| `routerSlug` | `string` | No | Router slug to use for voice config and agent routing |
| `agentSlug` | `string` | No | Agent slug to handle the call directly |
| `entityType` | `string` | No | Entity type slug for form-filling scenarios |
| `entityId` | `string` | No | Existing entity ID to update during the call |
| `metadata` | `object` | No | Extra context passed to the voice agent |
**Returns:**
```typescript
{
callSid: string
status: "initiated"
}
```
**Example:**
```typescript
import { defineAgent } from 'struere'
export default defineAgent({
name: "Outreach Agent",
slug: "outreach",
systemPrompt: "You schedule follow-up calls with leads.",
model: { model: "openai/gpt-5-mini" },
tools: ["entity.query", "voice.call"],
})
```
### voice.inject
Injects a message into an active voice call. This tool is designed for auditor agents -- when called, the message is spoken by the voice agent in its own voice during the call.
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `message` | `string` | Yes | Message to inject into the voice call |
| `action` | `string` | Yes | Action type: `"correction"`, `"complete"`, or `"abort"` |
**Actions:**
| Action | Behavior |
|--------|----------|
| `correction` | The voice agent speaks the correction message to the caller |
| `complete` | Signals the form/process is complete |
| `abort` | Signals the call should end |
**Example auditor agent:**
```typescript
import { defineAgent } from 'struere'
export default defineAgent({
name: "Form Auditor",
slug: "form-auditor",
systemPrompt: `You validate data collected during voice calls.
When you detect incorrect or missing data, use voice.inject to correct the caller.
When all required fields are filled, use voice.inject with action "complete".`,
model: { model: "openai/gpt-5-mini" },
tools: ["entity.query", "entity.update", "voice.inject"],
})
```
## Auditor Correction Flow
When using dual-agent mode, the auditor correction flow works as follows:
```
Voice Gateway polls /v1/chat every {pollInterval}ms with transcript delta
|
v
Auditor agent processes transcript, validates data
|
v
Auditor calls voice.inject (tool result stored in _executionMeta.toolCallSummary)
|
v
Voice Gateway reads tool result from response
|
v
Correction injected into OpenAI Realtime session
|
v
Voice agent speaks correction in its own voice
```
## Thread Data
Voice calls create threads with `channel: "voice"`. Thread metadata includes:
| Field | Value |
|-------|-------|
| `channel` | `"voice"` |
| `channelStatus` | `"pending"`, `"active"`, `"stopped"`, `"completed"`, or `"failed"` |
| `channelParams.callerNumber` | Caller's phone number |
System prompt access:
```
Channel: {{threadContext.channel}}
Caller: {{threadContext.params.callerNumber}}
```
## Billing
Voice calls are billed through the standard credit system. The voice gateway reports token usage (input + output) to Convex when the call ends, and credits are deducted based on the model's pricing.
A cleanup cron marks voice threads stuck in `"active"` status for over 2 hours as `"failed"`.
## Required Environment Variables
| Variable | Location | Description |
|----------|----------|-------------|
| `VOICE_GATEWAY_URL` | Convex | URL of the voice gateway service |
| `VOICE_GATEWAY_SECRET` | Convex + Voice Gateway | Shared secret for gateway authentication |
| `OPENAI_API_KEY` | Voice Gateway | OpenAI API key for Realtime sessions |
| `TWILIO_AUTH_TOKEN` | Voice Gateway (optional) | For verifying inbound Twilio webhook signatures |
---
## Voice Cookbook
> Worked patterns for orchestrator + voice agent, with prompt templates and failure-mode triage.
Source: https://docs.struere.dev/integrations/voice-cookbook.md
A worked-pattern guide for shipping voice agents. Uses the sportistics callup project as the running example: a volleyball club where players cancel via WhatsApp and a voice agent calls a substitute.
**The pattern in three sentences:** an orchestrator agent receives WhatsApp from a player, looks up the player and their next match, and if the player cancels, calls a voice agent via `agent.chat` with the match context already packaged. The voice agent runs inside `voice.call`, lives on the phone, and picks a replacement candidate. It runs silent setup, greets once, and branches through confirmation without re-greeting.
## 1. Architecture: orchestrator vs voice session
The orchestrator and the voice agent do different jobs and run in different runtimes. Keeping them separate is what makes the system debuggable.
| Role | Runtime | Job |
|------|---------|-----|
| Orchestrator | Text — `agent.chat` / HTTP `/v1/chat` | Decide WHO to call and what context they need |
| Voice agent | Inside `voice.call` — OpenAI Realtime over Twilio Media Streams | Talk to a human on the phone |
You have two structural options:
**Option A: two separate agents** (recommended). One slug for the orchestrator (e.g. `whatsapp-callup`), one for the voice agent (e.g. `voice-suplente`). The orchestrator invokes the voice agent via `agent.chat`. Each agent has a focused system prompt, a focused tool set, and is tested independently.
**Option B: one agent with a two-mode prompt.** A single agent slug whose system prompt has a `MODO 1 — WhatsApp` block and a `MODO 2 — Voice session` block. The agent reads `{{threadContext.channel}}` to pick the mode. Simpler to deploy but harder to reason about and harder to swap models per mode.
This cookbook uses Option A.
## 2. The orchestrator agent (worked example)
The orchestrator runs over WhatsApp. It owns lookup, decision, and handoff. Slug: `whatsapp-callup`. Tools: `get_player_by_phone`, `list_matches`, `set_availability`, `whatsapp.send`, `agent.chat`.
```
Eres el bot de citaciones de {{organizationName}}, un club de voley. Atiendes mensajes inbound por WhatsApp de los jugadores...
Flujo (sigue en orden, nunca saltes pasos):
1. Extrae el numero de telefono del mensaje (formato E.164, comienza con "+"). Si el thread context tiene phone, usalo.
2. Llama a get_player_by_phone({ phone }). Si player es null -> responde por whatsapp.send "No te tengo registrado, avisa al coach" y termina.
3. Llama a list_matches({ status: "scheduled" }). Toma el primer partido (el mas cercano por fecha asc). Guarda su id como matchId.
4. Parsea el mensaje natural del jugador a available | unavailable | maybe.
5. Llama a set_availability({ matchId, playerId, value }).
6. Confirma por whatsapp.send.
Si value === "unavailable", DESPUES de paso 6, llama a agent.chat({ agentSlug: "voice-suplente", message: "El jugador cancelo para el partido ( vs ). Llama a un suplente activo y confirmalo." }).
Reglas:
- NUNCA inventes ids. Siempre obten matchId via list_matches y playerId via get_player_by_phone.
- No saltes pasos.
```
The load-bearing line is the `agent.chat` invocation in step 6. The orchestrator threads `matchId`, `player.name`, `match.date`, and `match.opponent` into the message string passed to the voice agent. This is the only way the voice agent will know the context — voice sessions do not inherit the caller thread's context, channel params, or scratchpad. Whatever you don't put in that message string is lost.
## 3. Why agentSlug is mandatory in voice.call
When an agent calls `voice.call` without `agentSlug`, the tool returns `status: "success"` and the orchestrator's chat looks fine. The human on the phone, meanwhile, hears a confused vanilla model with no script — because the voice runtime had no agent to load.
**Wrong:**
```
voice.call({ phoneNumber: '+1XXX...' })
```
**Right:**
```
voice.call({ phoneNumber: '+1XXX...', agentSlug: 'voice-suplente' })
```
The agent's system prompt MUST tell the LLM to pass `agentSlug` literally. Don't rely on tool descriptions — write the slug into the prompt template alongside the example.
As of CLI v0.14.8, `bunx struere sync` rejects agents that use `voice.call` without referencing `agentSlug` (or any known agent slug) in their system prompt. If your sync passes but calls still sound wrong, regenerate the CLI.
## 4. Prompt structure for voice agents
A voice agent prompt needs four things in order: silent setup, greeting (once), branches, and a never-re-greet rule. This template is the prompt for `voice-suplente` (sanitized):
```
MODO 2 — Voice session (estas dentro de una llamada activa):
PASO 0 — Setup silencioso (antes de hablar):
- Llama a list_matches({ status: 'scheduled' }) y guarda opponent, date.
PASO 1 — Saludo (UNA SOLA VEZ, no se repite nunca):
"Hola, soy el bot del coach. Tenemos un partido el contra y necesitamos un suplente. ¿Podes jugar?"
PASO 2+ — Responde turno por turno SIN repetir el saludo. Ramas:
- Confirmacion: "Buenisimo, te confirmo." -> fin.
- Negativa: "Entendido, gracias." -> fin.
- Pregunta sobre el partido: responde con opponent y date que ya tenes, despues "¿Podes vos?".
- Respuesta confusa: una sola repregunta "¿Si o no?", luego decide.
REGLAS CRITICAS:
- NUNCA repitas el saludo del Paso 1 despues de la primera vez.
- Una oracion por turno maximo.
- No menciones matchId ni IDs.
```
Why each piece matters:
- **Silent setup (PASO 0)** avoids dead air. OpenAI Realtime defaults to `tool_choice: "auto"`, so any tool call mid-sentence becomes audible latency. Pre-fetch everything before the greeting.
- **Greet once.** Realtime models can drift back to "first turn" state when fed a confused or partial input from the human. Without an explicit never-re-greet rule, the agent will start "Hola, soy el bot del coach..." again two turns in.
- **Single-sentence branches.** Long voice responses get interrupted, which causes the model to retry from a stale state. Keep turns short.
- **No IDs in speech.** Voice agents that read out `matchId` on the phone sound robotic and lose the human. Strip them before speaking.
## 5. Threading match context (the key skill)
The orchestrator's job is to package context so the voice agent can act without thinking. Look again at the message `whatsapp-callup` sends to `voice-suplente`:
> "El jugador Diego Soto cancelo para el partido (2026-05-12 vs Tigres). Llama a un suplente activo y confirmalo."
Three things are pre-resolved in that string: who cancelled, the match date, the opponent. The voice agent then runs PASO 0 to enrich (e.g. fetch the substitute candidate), calls `voice.call` with the candidate's number, and greets with the date and opponent already in hand.
Without this packaging, the voice agent would have to reason about which match is being discussed. Realtime models under voice latency pressure tend to hallucinate plausible-sounding rivals and dates when forced to reason mid-call. Pre-package everything you can in the orchestrator.
## 6. Failure-mode triage
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| Caller hears a confused vanilla model | `voice.call` invoked without `agentSlug` | Add `agentSlug: "voice-suplente"` to the system prompt example. CLI v0.14.8+ blocks sync if missing. |
| Voice agent says wrong opponent or date | Orchestrator didn't thread match context into `agent.chat` message | Include `matchId`, `match.date`, `match.opponent` literally in the message string. |
| Voice agent re-greets after every confused input | Missing "never re-greet" rule in prompt | Add the rule plus a fallback "¿Si o no?" pattern. |
| Long silence at the start of a call | Agent is calling tools mid-greeting | Move tool calls into a `PASO 0 — Setup silencioso` block before the greeting. |
| `Phone number is already connected` | Stale `voiceConnections` orphan from a prior setup | `bunx struere integration twilio --remove-phone ` (CLI v0.14.7+). |
| `Sync failed: voiceConfig.auditorAgent references unknown agent: undefined` | Docs say `auditorAgent` is optional but runtime requires it | Set it explicitly. For single-agent setups, self-reference is fine: `auditorAgent: 'voice-suplente'`. |
## 7. Inspecting --json output
`bunx struere chat --json` returns the full response including `_executionMeta.toolCallSummary` (which tools ran, in what order, with timing) and `errorCount` / `permissionDenialCount`. This is the first place to look when an orchestrator-side bug breaks voice handoff.
For voice specifically, the orchestrator's chat shows `voice.call` returned success — but the actual call quality lives in voice-gateway logs and the resulting `threads` row on the voice side. Voice transcripts are not surfaced in the orchestrator's response. Debug live calls by listening to the call in real time, or by inspecting `threads` rows with `channel: "voice"` after the call ends.
## 8. See also
- See [Voice Integration](/integrations/voice) for setup
- See [Routers](/sdk/define-router) if you need multi-agent voice routing
- See [Platform Gotchas](/platform/gotchas) for adjacent silent failures