# Struere Documentation — Integrations > Filtered section from the Struere docs. Full docs: https://docs.struere.dev/llms.txt --- ## WhatsApp Integration > WhatsApp messaging integration via Kapso Source: https://docs.struere.dev/integrations/whatsapp.md Struere integrates with WhatsApp Business through Kapso's Cloud API, providing 7 built-in tools for sending messages, templates, interactive menus, and media. WhatsApp conversations are tracked as threads with channel metadata (phone number, contact name) accessible in system prompts via template variables and in custom tools via the execution context. ## Architecture ``` WhatsApp User | v WhatsApp Business API | v Kapso Service (manages phone numbers, message routing) | v Convex Webhooks (/webhook/kapso/project, /webhook/kapso/messages) | v Struere Backend (message storage, agent routing) | v Agent LLM Execution | v Kapso API (outbound message delivery) | v WhatsApp User receives response ``` ## Database Tables ### whatsappConnections Stores the connection state between an organization and a WhatsApp phone number. Scoped by environment. | Field | Type | Description | |-------|------|-------------| | `organizationId` | `Id<"organizations">` | The owning organization | | `environment` | `"development" \| "production"` | Environment scope | | `status` | `"disconnected" \| "pending_setup" \| "connected"` | Current connection state | | `phoneNumber` | `string?` | The connected phone number | | `kapsoCustomerId` | `string?` | Kapso customer identifier | | `kapsoPhoneNumberId` | `string?` | Kapso phone number identifier | | `agentId` | `Id<"agents">?` | The agent assigned to handle inbound messages | | `routerId` | `Id<"routers">?` | The router assigned to handle inbound messages (alternative to `agentId`) | | `setupLinkUrl` | `string?` | URL for the phone number setup flow | | `lastConnectedAt` | `number?` | Timestamp of last successful connection | | `lastDisconnectedAt` | `number?` | Timestamp of last disconnection | ### messages (WhatsApp) WhatsApp messages are stored in the unified `messages` table alongside all other conversation messages. WhatsApp-specific fields (direction, phone number, delivery status, Kapso message ID) are stored in the `channelData` field on each message record. ## Setup Flow ### 1. Enable the Integration Enable WhatsApp for your organization and environment through the dashboard or API: ```typescript await whatsapp.enableWhatsApp({ environment: "development" }) ``` This creates an integration config entry with provider `"whatsapp"` and status `"active"`. ### 2. Start Phone Setup Initiate the WhatsApp phone number connection: ```typescript await whatsapp.setupWhatsApp({ environment: "development" }) ``` This triggers an asynchronous flow: 1. A Kapso customer is created for your organization 2. A setup link URL is generated 3. The connection status moves to `"pending_setup"` 4. The setup link is stored on the connection record ### 3. Complete Phone Connection The user follows the setup link to connect their WhatsApp Business phone number through Kapso's interface. Once complete, the `whatsapp.phone_number.created` webhook fires and: 1. The connection status updates to `"connected"` 2. The Kapso phone number ID and phone number are stored 3. A message webhook is registered with Kapso pointing to `/webhook/kapso/messages` ### 4. Assign an Agent or Router Assign an agent to handle inbound messages: ```typescript await whatsapp.setWhatsAppAgent({ agentId: "agent_id_here", environment: "development", }) ``` Alternatively, assign a router to route messages between multiple agents. When `routerId` is set, inbound messages go through the router's classification or rules engine instead of directly to a single agent: ```typescript await whatsapp.setWhatsAppRouter({ routerId: "router_id_here", environment: "development", }) ``` You can also set the router via the CLI: ```bash struere whatsapp set-router --slug support-router ``` A WhatsApp connection can have either an `agentId` or a `routerId`, but not both. ## Footguns Behaviors that aren't obvious from the type signatures. ### Symptom: Inbound WhatsApp messages are silently dropped **Cause:** WhatsApp connection has no router AND no agent assigned (or its router has no fallback). `processInboundMessage` returns null without warning. **Fix:** Ensure every connection has either a `routerId` (with a valid `fallback`) or an `agentId`. Verify with `bunx struere integration whatsapp --status`. ### Symptom: `whatsapp.send` (interactive/template) throws "24-hour window expired" even though the customer just replied **Cause:** Window check reads from `thread.metadata.lastInboundAt`, but inbound timestamps are written to `thread.channelParams.lastInboundAt`. The metadata field is never populated. **Fix (workaround):** Send a regular `whatsapp.send` text first to refresh the window state, then send your interactive message. Real fix is a platform patch. ### Symptom: Template returns `{ status: "sent" }` but customer never receives it **Cause:** `sendTemplate` doesn't pre-validate template approval status. If the template isn't APPROVED in Meta's system, the send silently no-ops. **Fix:** Check template approval state via `bunx struere templates list` before relying on it. Templates pending approval are not visible. ### Symptom: Templates created in dev show up in production (or vice versa) **Cause:** `whatsappOwnedTemplates` is **org-scoped, not env-scoped** -- by design (Meta enforces template uniqueness per WABA). **Fix:** Name templates accordingly (`prod_welcome` vs `dev_welcome`) if you want env separation. ### Symptom: Voice messages arrive without text **Cause:** Kapso may deliver the audio webhook before the transcript is ready; the platform doesn't refetch transcripts later. **Fix:** Handle the `[Sent a voice message]` placeholder explicitly in your agent's prompt. ### Symptom: Same inbound message appears twice in your thread after a Kapso retry **Cause:** Dedup is via `externalId` only; if the insert succeeds but the response times out, Kapso retries and the second insert is blocked by the unique index -- but if either step is fast and the index isn't checked tightly, edge cases exist. **Fix:** In your agent prompt, treat duplicates defensively (check `externalId` if you persist domain state from the message). See [Platform Gotchas](/platform/gotchas) for cross-cutting silent failures across the platform. ## Inbound Message Routing When a WhatsApp message arrives, the following sequence executes: ``` POST /webhook/kapso/messages | v Verify HMAC-SHA256 signature | v Look up connection by kapsoPhoneNumberId | v processInboundMessage (store message, deduplicate by messageId) | v scheduleAgentRouting (mutation, schedules action via ctx.scheduler) | v routeInboundToAgent (action) | v threads.getOrCreate with externalId = "whatsapp:{connectionId}:{phoneNumber}" | v agent.chatAuthenticated (system actor, no user) | v Send response via Kapso sendTextMessage API ``` ### Thread Reuse Conversations with a given phone number are grouped into a single thread using the `externalId` pattern `whatsapp:{connectionId}:{phoneNumber}`. The `threads.getOrCreate` mutation looks up existing threads by this external ID, ensuring all messages from the same WhatsApp number flow through the same conversation context. ### Thread Data When a WhatsApp conversation creates or resumes a thread, the following data is stored on the thread: | Field | Value | |-------|-------| | `channel` | `"whatsapp"` | | `externalId` | `whatsapp:{connectionId}:{phoneNumber}` | | `channelParams.phoneNumber` | Sender's phone number with country code (e.g., `+56912345678`) | | `channelParams.contactName` | WhatsApp profile display name (if available) | | `channelParams.lastInboundAt` | Timestamp of the last inbound message | This data is available to agents in two ways: **In system prompts** via template variables: ``` Sender phone: {{threadContext.params.phoneNumber}} Sender name: {{threadContext.params.contactName}} Channel: {{threadContext.channel}} ``` **In custom tools** via the `context` parameter: ```typescript handler: async (args, context, struere) => { const phone = context.channelParams?.phoneNumber const results = await struere.entity.query({ type: "teacher", filter: { field: "phone", operator: "eq", value: phone }, }) return results?.results?.[0] || { error: "Teacher not found" } } ``` This enables patterns like auto-identifying the sender by phone number and loading their entity data without asking the user to identify themselves. ### System Actor Context Inbound WhatsApp messages are processed using a **system actor context** because there is no authenticated Clerk user for the incoming message. The system actor has `isOrgAdmin: true` and operates with full permissions within the organization's environment. ## Outbound Messages Agents send responses back to WhatsApp users through the Kapso API. When the agent's LLM loop completes: 1. The response text is extracted from the agent's reply 2. The `sendTextMessage` function calls the Kapso API with the phone number and text 3. The outbound message is stored in the `messages` table with WhatsApp-specific data in `channelData` 4. Delivery status updates arrive via the status update webhook ## Message Status Tracking Outbound message status progresses through these states: ``` sent -> delivered -> read \ -> failed ``` Status updates are received via the `whatsapp.message.status_update` event type on the messages webhook and applied to the corresponding message record. ## WhatsApp Tools Agents can also interact with WhatsApp programmatically through built-in WhatsApp tools: ### whatsapp.send Send a text message to a phone number: ```typescript { to: "+1234567890", text: "Your session is confirmed for tomorrow at 3 PM." } ``` ### whatsapp.getConversation Retrieve message history for a phone number: ```typescript { phoneNumber: "+1234567890", limit: 20 } ``` ### whatsapp.getStatus Check the current WhatsApp connection status for the organization. ## Template Management WhatsApp message templates are pre-approved message formats required for outbound messages outside the 24-hour messaging window. Struere supports full template lifecycle management — create, list, check status, delete — directly from the dashboard and API. Templates are stored on Meta's side and queried dynamically via the Kapso Meta proxy. There is no local caching table. ### Template Categories | Category | Use Case | |----------|----------| | `UTILITY` | Transactional updates (order confirmations, appointment reminders) | | `MARKETING` | Promotional content and offers | | `AUTHENTICATION` | OTP/verification codes (special Meta rules apply) | ### Template Status Flow ``` Created -> PENDING -> APPROVED \-> REJECTED \-> PAUSED ``` Templates must be approved by Meta before they can be sent. Status is checked by querying the Meta API directly. ### Creating Templates Create templates via the dashboard (Settings > WhatsApp > Templates) or the `createTemplate` action: ```typescript await whatsappActions.createTemplate({ environment: "development", connectionId: "connection_id", name: "order_update", language: "en_US", category: "UTILITY", components: [ { type: "BODY", text: "Hi {{customer_name}}, your order {{order_id}} is ready.", example: { body_text_named_params: [ { param_name: "customer_name", example: "Alex" }, { param_name: "order_id", example: "ORDER-123" }, ], }, }, ], }) ``` **Returns:** `{ id: string, status: string, category: string }` ### Template Component Rules - **HEADER** (optional): TEXT, IMAGE, VIDEO, or DOCUMENT format - **BODY** (required): Main message text with optional variables - **FOOTER** (optional): Short footer text, no variables - **BUTTONS** (optional): QUICK_REPLY, URL, or PHONE_NUMBER Parameter formats: - **NAMED** (recommended): `{{customer_name}}` — use `parameter_format: "NAMED"` at creation - **POSITIONAL**: `{{1}}`, `{{2}}` — sequential, no gaps If variables appear in HEADER or BODY, you must include examples in the component. Button ordering: do not interleave QUICK_REPLY with URL/PHONE_NUMBER buttons. ### Listing Templates ```typescript await whatsappActions.listTemplates({ environment: "development", connectionId: "connection_id", }) ``` Returns all templates with name, status, category, language, and components. ### Checking Template Status ```typescript await whatsappActions.getTemplateStatus({ environment: "development", connectionId: "connection_id", name: "order_update", }) ``` Returns the template details filtered by name, including current approval status. ### Deleting Templates ```typescript await whatsappActions.deleteTemplate({ environment: "development", connectionId: "connection_id", name: "order_update", }) ``` Deletes the template from Meta. This cannot be undone. ### Sending Template Messages For sending approved templates in a conversation, use the `sendTemplate` action: ```typescript await whatsappActions.sendTemplate({ threadId: "thread_id", templateName: "order_update", language: "en_US", components: [ { type: "body", parameters: [ { type: "text", parameter_name: "customer_name", text: "Alex" }, { type: "text", parameter_name: "order_id", text: "ORDER-123" }, ], }, ], }) ``` Template messages are stored with the text `[Template: templateName]` in the message history. ### Dashboard Template Management Connected phone numbers display a **Message Templates** section in the WhatsApp settings page. From there you can: - View all templates with their name, language, category, and approval status - Create new templates with a JSON component editor - Delete templates (with confirmation) - Refresh the template list from Meta ## Disconnecting To disconnect WhatsApp from an environment: ```typescript await whatsapp.disconnectWhatsApp({ environment: "development" }) ``` This sets the connection status to `"disconnected"` and clears the phone number and setup link fields. The Kapso customer record is retained for potential reconnection. ## Required Environment Variables | Variable | Location | Description | |----------|----------|-------------| | `KAPSO_API_KEY` | Convex | API key for the Kapso service | | `KAPSO_WEBHOOK_SECRET` | Convex | Shared secret for webhook signature verification | | `CONVEX_SITE_URL` | Convex | Your Convex site URL (used to construct webhook callback URLs) | --- ## Flow Payments > Payment processing with Flow.cl integration Source: https://docs.struere.dev/integrations/flow-payments.md Struere integrates with **Flow** (flow.cl) as a payment provider. AI agents can create payment links during conversations and return URLs to users. The integration supports payment link generation, HMAC-SHA256 request signing, webhook-based status updates, automatic reconciliation, and agent tools. ## Setup ### 1. Configure via Dashboard Navigate to **Settings > Integrations > Flow.cl** and provide: | Field | Type | Required | Description | |-------|------|----------|-------------| | `apiUrl` | `string` | Yes | Flow API base URL (`https://www.flow.cl/api` or `https://sandbox.flow.cl/api` for testing) | | `apiKey` | `string` | Yes | Your Flow API key | | `secretKey` | `string` | Yes | Your Flow secret key for request signing | | `returnUrl` | `string` | No | URL to redirect users after payment completion | | `defaultCurrency` | `string` | No | Default currency code (defaults to `"CLP"`) | The webhook URL is displayed on the settings page — set this as your confirmation URL in Flow.cl. ### 2. Configure via CLI ```bash bunx struere integration flow \ --api-url https://www.flow.cl/api \ --api-key YOUR_API_KEY \ --secret-key YOUR_SECRET_KEY \ --return-url https://yoursite.com/payment/complete \ --test ``` ### 3. Add Tools to Your Agent ```typescript export default defineAgent({ name: "Billing Agent", slug: "billing", version: "0.1.0", systemPrompt: "You help users make payments.", tools: ["payment.create", "payment.getStatus"], }) ``` ## Footguns Behaviors that aren't obvious from the type signatures. ### Symptom: Anyone can mark a payment as paid by POSTing to `/webhook/flow` **Cause:** Webhook handler trusts the `token` parameter without verifying Flow's HMAC-SHA256 signature against the request body. **Fix:** This is an open security gap -- track and patch. Defense in depth: add a network-level allowlist for Flow's IPs at the edge. ### Symptom: Same payment can be marked paid twice if Flow retries the webhook **Cause:** Only Polar uses `processedPayments` for dedup; Flow doesn't. **Fix:** Agents that consume `payment.status` should be defensive about double-events. ### Symptom: Payment created with empty `returnUrl` redirects users to root domain **Cause:** `flow.ts` defaults `returnUrl` to `""` if not configured. **Fix:** Always set `returnUrl` in the integration config. ### Symptom: Polar webhook silently does nothing for some events **Cause:** Org lookup falls through three fields (`metadata.organizationId`, `customer.externalId`, `customer.metadata.userId`); if all are empty, the handler returns without logging. **Fix:** Ensure `metadata.organizationId` is set on all Polar customer creations. See [Platform Gotchas](/platform/gotchas) for cross-cutting silent failures across the platform. ## Agent Tools ### `payment.create` Creates a payment entity, calls the Flow API to generate a payment link, and returns the link URL. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `amount` | `number` | Yes | Payment amount | | `description` | `string` | Yes | Description of the payment | | `currency` | `string` | No | Currency code (defaults to config default or `"CLP"`) | | `customerEmail` | `string` | Yes | Customer email address (required by Flow's API) | | `entityId` | `string` | No | Optional entity ID to link the payment to via a `payment_for` relation | **Returns:** ```json { "paymentId": "ent_abc123", "paymentLinkUrl": "https://www.flow.cl/app/web/pay.php?token=xyz", "flowOrderId": "12345" } ``` ### `payment.getStatus` Checks the current status of a payment. Queries the Flow API for live status if a provider reference exists. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `entityId` | `string` | Yes | Payment entity ID to check | **Returns:** ```json { "entityId": "ent_abc123", "status": "pending", "flowStatus": 1, "flowStatusMessage": "Pending payment", "paymentLinkUrl": "https://www.flow.cl/app/web/pay.php?token=xyz", "amount": 5000, "currency": "CLP" } ``` ## Request Signing All requests to the Flow API are signed using HMAC-SHA256 via the Web Crypto API (`crypto.subtle`): 1. Sort the request parameters alphabetically by key 2. Concatenate them as `key1value1key2value2` (key-value pairs with no separators) 3. Compute the HMAC-SHA256 digest using `crypto.subtle.sign()` with the secret key 4. Append the hex-encoded signature as the `s` parameter ### Flow API HTTP Methods | Endpoint | Method | Signature Delivery | |----------|--------|--------------------| | `payment/create` | POST | Form-encoded body | | `payment/getStatus` | GET | Query string | | `payment/getStatusByFlowOrder` | GET | Query string | ## Payment Status Webhook **Endpoint:** `POST /webhook/flow` Flow sends payment status updates as form-encoded POST data with a `token` parameter. The webhook uses a fast-path lookup by `flowToken` stored on the payment entity for direct org/environment resolution, falling back to iterating all Flow configs for legacy payments. ### Processing Flow ``` Flow sends POST /webhook/flow with token | v Extract token from form data | v Fast-path: look up payment entity by flowToken Found? -> resolve org/environment directly -> verify via Flow API | v (not found) Fallback: query all active Flow configurations | v For each config, try both environments: Verify payment status via Flow API | v Map status code to action: Status 2 (Paid) -> markAsPaid Status 3 (Rejected) -> markAsFailed Status 4 (Cancelled) -> markAsFailed ``` ### Status Codes | Flow Status | Meaning | Action | |-------------|---------|--------| | `1` | Pending | No action | | `2` | Paid | Mark payment as paid | | `3` | Rejected | Mark payment as failed | | `4` | Cancelled | Mark payment as failed | ## Reconciliation A cron job runs every 5 minutes to reconcile pending payments older than 1 hour. For each pending payment with a Flow provider reference, it queries the Flow API directly to check the current status and updates the payment entity accordingly. ## Payment Entity Schema Payments are stored as entities of type `payment`: | Field | Type | Description | |-------|------|-------------| | `amount` | `number` | Payment amount | | `currency` | `string` | Currency code (e.g., `"CLP"`) | | `description` | `string` | Payment description | | `status` | `string` | Payment status (`"draft"`, `"pending"`, `"paid"`, `"failed"`) | | `providerReference` | `string` | The Flow order ID | | `paymentLinkUrl` | `string` | The generated payment link URL | | `flowToken` | `string` | Flow token for direct webhook lookup | | `customerEmail` | `string` | Customer email address | | `paidAt` | `number?` | Timestamp when payment was confirmed | | `failureReason` | `string?` | Reason for payment failure | ## Payment Automations Payment state changes fire automations just like any other entity mutation. When a payment is marked as paid or failed (via webhook or reconciliation), the automation engine checks for matching automations with `entityType: "payment"` and `action: "updated"`. ### Example: Notify Customer on Payment ```typescript export default defineTrigger({ name: "Notify Payment Received", slug: "notify-payment-received", on: { entityType: "payment", action: "updated", condition: { "data.status": "paid" }, }, actions: [ { tool: "whatsapp.send", args: { to: "{{trigger.data.customerPhone}}", text: "Your payment of {{trigger.data.amount}} {{trigger.data.currency}} has been received.", }, }, ], }) ``` ### Example: Handle Failed Payment ```typescript export default defineTrigger({ name: "Handle Failed Payment", slug: "handle-failed-payment", on: { entityType: "payment", action: "updated", condition: { "data.status": "failed" }, }, actions: [ { tool: "email.send", args: { to: "{{trigger.data.customerEmail}}", subject: "Payment failed", text: "Your payment could not be processed. Please try again.", }, }, ], }) ``` ### Payment Events The following events are emitted during the payment lifecycle: | Event Type | When | |------------|------| | `payment.created` | Payment entity is created | | `payment.paid` | Payment is confirmed via webhook or reconciliation | | `payment.failed` | Payment is rejected or cancelled | | `payment.link_created` | Payment link is generated via Flow API | Automations match on entity lifecycle actions (`created`, `updated`, `deleted`), not on custom event types. Use `condition` to filter by payment status. ## Configuration Storage Flow credentials are stored in the `integrationConfigs` table rather than as environment variables. The configuration is loaded at runtime from the database for each organization and environment. No Convex environment variables are required specifically for Flow. --- ## Google Calendar > Connect Google Calendar for scheduling, availability checks, and event management Source: https://docs.struere.dev/integrations/google-calendar.md Struere integrates with Google Calendar through OAuth2, giving agents the ability to list, create, update, and delete calendar events and check availability. ## Setup ### 1. Connect Google Calendar In the dashboard, navigate to **Integrations > Google Calendar** and click **Connect**. This initiates an OAuth2 flow with Google. ### 2. Grant permissions Authorize Struere to access your Google Calendar. The connection is stored in the `calendarConnections` table, scoped to the current environment. ### 3. Add calendar tools to your agent ```typescript import { defineAgent } from 'struere' export default defineAgent({ name: "Scheduler", slug: "scheduler", tools: [ "calendar.list", "calendar.create", "calendar.update", "calendar.delete", "calendar.freeBusy", "entity.query", ], systemPrompt: `You are a scheduling assistant for {{organizationName}}. Current time: {{currentTime}} When booking: 1. Check availability with calendar.freeBusy 2. Create the event with calendar.create 3. Record the session entity with entity.create`, model: { model: "openai/gpt-5-mini" }, }) ``` ## Footguns Behaviors that aren't obvious from the type signatures. ### Symptom: Calendar tools start failing with "User has not connected Google OAuth" **Cause:** OAuth token expired in Clerk but `calendarConnections.status` is still "connected" -- no refresh logic. **Fix:** Have the user reconnect Google in Clerk. Consider monitoring this and prompting users on persistent failures. ### Symptom: `calendar.create` fails with 403 Forbidden **Cause:** User selected a shared calendar they later lost access to; the stored `calendarId` is stale. **Fix:** Re-pick a calendar in the dashboard. ### Symptom: Updating an event clears all attendee RSVPs **Cause:** `calendar.update` accepts `attendees` and PATCHes the full list -- Google replaces existing acceptances. **Fix:** Pass only the additions/removals you intend; don't include `attendees` if you only want to update other fields. ### Symptom: Updating one instance of a recurring event modifies the whole series **Cause:** `calendar.update` doesn't expose `recurrenceId`. **Fix:** Today, modify recurring events outside Struere (Google web UI) until the tool exposes per-instance updates. ### Symptom: Two duplicate events created from the same agent run **Cause:** `calendar.create` has no idempotency key. **Fix:** Include unique data in the title/description so you can detect dupes downstream; or wrap creation in your own dedup check. See [Platform Gotchas](/platform/gotchas) for cross-cutting silent failures across the platform. ## Available Tools ### calendar.list List calendar events within a time range. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `userId` | `string` | Yes | User ID (Convex or Clerk) whose calendar to query | | `timeMin` | `string` | Yes | Start of range (ISO 8601) | | `timeMax` | `string` | Yes | End of range (ISO 8601) | | `maxResults` | `number` | No | Maximum events to return | ### calendar.create Create a new calendar event. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `userId` | `string` | Yes | User ID (Convex or Clerk) whose calendar to create the event on | | `summary` | `string` | Yes | Event title | | `description` | `string` | No | Event description | | `startTime` | `string` | Yes | Start time (ISO 8601) | | `endTime` | `string` | No | End time (ISO 8601). Provide either `endTime` or `durationMinutes` | | `durationMinutes` | `number` | No | Duration in minutes. Used to calculate `endTime` if not provided | | `attendees` | `string[]` | No | Email addresses of attendees | | `timeZone` | `string` | No | IANA timezone (e.g., `America/Santiago`) | | `addGoogleMeet` | `boolean` | No | Set to `true` to auto-create a Google Meet link for the event | ### calendar.update Update an existing calendar event. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `userId` | `string` | Yes | User ID (Convex or Clerk) whose calendar contains the event | | `eventId` | `string` | Yes | Google Calendar event ID | | `summary` | `string` | No | Updated title | | `description` | `string` | No | Updated description | | `startTime` | `string` | No | Updated start time | | `endTime` | `string` | No | Updated end time | | `attendees` | `string[]` | No | Updated attendees | ### calendar.delete Delete a calendar event. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `userId` | `string` | Yes | User ID (Convex or Clerk) whose calendar contains the event | | `eventId` | `string` | Yes | Google Calendar event ID | ### calendar.freeBusy Check availability across calendars for a time range. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `userId` | `string` | Yes | User ID (Convex or Clerk) whose availability to check | | `timeMin` | `string` | Yes | Start of range (ISO 8601) | | `timeMax` | `string` | Yes | End of range (ISO 8601) | Returns busy time slots within the range, allowing agents to find open slots before booking. ## Common Patterns ### Booking with Availability Check ``` User: "Book a session with Alice on Tuesday at 2 PM" Agent flow: 1. calendar.freeBusy — check if 2 PM Tuesday is available 2. If busy → suggest alternative times from the free slots 3. If free → calendar.create with the event details 4. entity.create — record the session in Struere ``` ### Timezone Handling All times are in ISO 8601 format. The Google Calendar API respects the timezone in the ISO string. If no timezone offset is provided, the calendar's default timezone is used. ``` "2025-03-15T14:00:00-05:00" ← Eastern Time "2025-03-15T14:00:00Z" ← UTC "2025-03-15T14:00:00+09:00" ← Japan Standard Time ``` Instruct your agent about timezone expectations in the system prompt to avoid confusion. ## Environment Scoping Calendar connections are environment-scoped. A connection created in development is not available in production. Connect Google Calendar separately in each environment where you need it. --- ## Embeddable Chat Widget > Add a Struere AI chatbot to any website with a single script tag Source: https://docs.struere.dev/integrations/embeddable-widget.md Struere provides a lightweight embeddable widget that adds a floating AI chatbot to any website. Visitors can chat with your deployed agent directly on your page — no authentication required, no framework dependencies. ## How It Works ``` Your Website | v ``` That's it. A chat bubble will appear in the bottom-right corner of your page. ## Finding Your Slugs Your **org slug** and **agent slug** are visible in the public chat URL for your agent: ``` https://app.struere.dev/chat/{org-slug}/{agent-slug} ``` You can also find these in the Struere dashboard: - **Org slug**: Settings → Organization → Slug - **Agent slug**: Agents → Select agent → Settings → Slug ## Configuration The widget accepts configuration through URL parameters on the script `src`: | Parameter | Default | Description | |-----------|---------|-------------| | `org` | (required) | Your organization slug | | `agent` | (required) | Your agent slug | | `theme` | `dark` | Chat theme: `dark` or `light` | | `accent` | `#3B82F6` | Bubble button color (hex, URL-encoded) | | `position` | `br` | Bubble position: `br` (bottom-right), `bl` (bottom-left), `tr` (top-right), `tl` (top-left) | ### Example with All Options ```html ``` ## Thread Context You can pass custom context about the current user or session to your agent by adding extra URL parameters to the script tag. Any parameter that isn't a reserved config parameter (`org`, `agent`, `theme`, `position`) is forwarded as thread context. ```html ``` The extra parameters (`email`, `name`, `plan`) are stored on the thread as `channelParams` and become available to the agent via system prompt templates: ``` {{threadContext.channel}} → "widget" {{threadContext.params.email}} → "jane@example.com" {{threadContext.params.name}} → "Jane" {{threadContext.params.plan}} → "pro" ``` ### Using Thread Context in System Prompts Define `threadContextParams` in your agent config to declare what parameters your agent expects: ```typescript import { defineAgent } from 'struere' export default defineAgent({ name: "Support", slug: "support-bot", version: "0.1.0", systemPrompt: `You are a support agent for {{organizationName}}. Channel: {{threadContext.channel}} Customer email: {{threadContext.params.email}} Customer name: {{threadContext.params.name}} Plan: {{threadContext.params.plan}} ## Customer Profile {{entity.get({"type": "customer", "id": "{{threadContext.params.customerId}}"})}} Greet them by name and tailor your responses to their plan level.`, model: { model: "openai/gpt-5-mini" }, tools: ["entity.query", "entity.get"], threadContextParams: [ { name: "email", type: "string", required: true, description: "Customer email" }, { name: "name", type: "string", description: "Customer display name" }, { name: "plan", type: "string", description: "Subscription plan" }, { name: "customerId", type: "string", description: "Entity ID for customer lookup" }, ], }) ``` ### Validation When `threadContextParams` is defined, the backend validates incoming parameters: - **Required params** — If a param has `required: true` and is missing, the request fails with an error - **Type checking** — Values are validated against the declared type (`string`, `number`, `boolean`) - **Unknown params** — Parameters not declared in `threadContextParams` are silently dropped ### Dynamic Widget Parameters You can set parameters dynamically using JavaScript: ```html ``` ### Direct Iframe with Context Thread context works identically with direct iframe embeds. Add parameters to the iframe `src`: ```html ``` ## Direct Iframe Embed If you prefer to embed the chat directly in your page layout (not as a floating widget), use an iframe: ```html ``` The embed page renders a minimal chat UI with no header, no sidebar, and no navigation — just the message input and conversation. ### Iframe Query Parameters | Parameter | Default | Description | |-----------|---------|-------------| | `theme` | `dark` | Visual theme: `dark` or `light` | ## Events The widget communicates with your host page via `postMessage`. You can listen for chat events: ```javascript window.addEventListener("struere:message", function (event) { console.log("Thread ID:", event.detail.threadId) console.log("Agent response:", event.detail.message) }) ``` ### Event Types | Event | Detail Fields | Description | |-------|--------------|-------------| | `struere:message` | `threadId`, `message` | Fired when the agent responds to a message | ## Requirements For the widget to work, your agent must be: 1. **Deployed to production** — The embed uses the production environment. Run `struere deploy` or deploy from the dashboard. 2. **Status: active** — Agents with status `deleted` or `draft` will show "Agent Not Found". 3. **Has a production config** — The agent needs an `agentConfig` for the `production` environment. No API key is needed. The embed uses the public chat action which resolves the agent by org slug + agent slug and runs it in the production environment with a system actor context. ## Security The embed route serves these headers to allow iframe embedding on any domain: ``` X-Frame-Options: ALLOWALL Content-Security-Policy: frame-ancestors * ``` The chat operates in **public mode** — tool call details, tool results, and system messages are hidden from the user. Only user messages and assistant text responses are displayed. ## Architecture ``` Host Page (your-site.com) | |-- widget.js (served from app.struere.dev, cached 1 hour) | | | |-- Creates floating button (position: fixed) | |-- Creates iframe container (position: fixed) | |-- Handles open/close toggle with animation | v Iframe (app.struere.dev/embed/{org}/{agent}) | |-- ChatInterface component (mode="public", embedded=true) | | | |-- usePublicThreadMessages (Convex subscription) | |-- sendPublicChat (Convex action) | v Convex Backend | |-- publicChat.sendPublicChat → agent.chatAuthenticated |-- Progressive message writing (onStepFinish) |-- publicChat.getPublicThreadMessages (real-time subscription) ``` Messages appear progressively as the agent processes each step. The user sees their message immediately, then the agent's response streams in as it becomes available — no spinner waiting for the full response. ## Troubleshooting **Widget doesn't appear** - Check your browser console for errors - Verify the org and agent slugs are correct - Ensure the script tag has `async` and `defer` attributes - Confirm the agent is deployed to production and has status `active` **"Agent Not Found" in the chat** - The agent doesn't have a production config. Deploy it with `struere deploy`. - The org slug or agent slug is wrong. Check the public chat URL in the dashboard. - The agent status is not `active`. **Chat loads but messages fail** - The agent's LLM provider key may not be configured. Check provider configs in the dashboard. - The organization may have insufficient credits if no provider keys are configured. **iframe blocked by CSP** If your host page has a strict Content-Security-Policy, add the Struere domain to your `frame-src` directive: ``` Content-Security-Policy: frame-src https://app.struere.dev; ``` --- ## Airtable > Read and write Airtable records from your agents Source: https://docs.struere.dev/integrations/airtable.md Struere integrates with Airtable via Personal Access Tokens (PAT), giving agents the ability to list bases, browse table schemas, and perform full CRUD on records. ## Setup ### 1. Create an Airtable Personal Access Token Go to [airtable.com/create/tokens](https://airtable.com/create/tokens) and create a new token. Grant the scopes your agents need: | Scope | Required for | |-------|-------------| | `data.records:read` | `airtable.listRecords`, `airtable.getRecord` | | `data.records:write` | `airtable.createRecords`, `airtable.updateRecords`, `airtable.deleteRecords` | | `schema.bases:read` | `airtable.listBases`, `airtable.listTables` | Select the specific bases the token should have access to, or grant access to all bases. ### 2. Configure the integration You can configure Airtable from the **CLI** or the **dashboard**. **CLI:** ```bash bunx struere integration airtable --token --base-id --test ``` **Dashboard:** Navigate to **Settings > Integrations > Airtable**, paste your PAT, and click **Save**. Then click **Test Connection** to verify the token is valid. See [`struere integration`](/cli/integration) for all CLI options. ### 3. Add Airtable tools to your agent ```typescript import { defineAgent } from 'struere' export default defineAgent({ name: "Data Manager", slug: "data-manager", tools: [ "airtable.listBases", "airtable.listTables", "airtable.listRecords", "airtable.getRecord", "airtable.createRecords", "airtable.updateRecords", "airtable.deleteRecords", ], systemPrompt: `You manage data in Airtable for {{organizationName}}. When the user asks about data, query the relevant Airtable base and table. When creating or updating records, confirm the changes with the user first.`, model: { model: "openai/gpt-5-mini" }, }) ``` ## Footguns Behaviors that aren't obvious from the type signatures. ### Symptom: All Airtable tool calls 403 with no clear cause **Cause:** PAT scopes don't include `data.records:read` / `data.records:write` / `schema.bases:read`. **Fix:** Regenerate the PAT with the right scopes and re-run `bunx struere integration airtable --token ...`. ### Symptom: Agent forgets the base ID and tool calls fail **Cause:** There's no default `baseId` per integration; every call needs it. **Fix:** Include the base ID in your agent's system prompt explicitly, e.g. "Always use baseId: appXYZ123". ### Symptom: `createRecords` with a date string silently writes a wrong value **Cause:** Airtable's strongly typed fields coerce or reject; the platform doesn't validate field types client-side. **Fix:** Pass dates in ISO 8601 and reference Airtable's expected formats explicitly in your agent prompt. ### Symptom: Agent only sees the first 100 records of a large table **Cause:** `listRecords` returns up to 100; agent must paginate via `offset`. **Fix:** Instruct the agent to call `listRecords` in a loop until `offset` is null, OR fetch all pages server-side via a custom tool. ### Symptom: Linked record fields appear as opaque IDs (`["rec123"]`) in agent context **Cause:** Airtable returns IDs, not names. **Fix:** Use a custom tool to resolve linked IDs to display values, or include resolution instructions in your agent prompt. See [Platform Gotchas](/platform/gotchas) for cross-cutting silent failures across the platform. ## Available Tools ### airtable.listBases Lists all Airtable bases accessible with the configured token. **Parameters:** None. **Returns:** ```typescript { bases: Array<{ id: string name: string permissionLevel: string }> } ``` --- ### airtable.listTables Lists all tables in an Airtable base, including field definitions. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `baseId` | `string` | Yes | Airtable base ID (e.g., `"appXXXXXXXXXXXXXX"`) | **Returns:** ```typescript { tables: Array<{ id: string name: string fields: Array<{ id: string name: string type: string }> }> } ``` --- ### airtable.listRecords Lists records from an Airtable table with optional filtering, sorting, and pagination. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `baseId` | `string` | Yes | Airtable base ID | | `tableIdOrName` | `string` | Yes | Table ID or name | | `pageSize` | `number` | No | Records per page (max 100) | | `offset` | `string` | No | Pagination offset from a previous response | | `filterByFormula` | `string` | No | Airtable formula filter (e.g., `"{Status} = 'Active'"`) | | `sort` | `array` | No | Sort configuration: `[{ field: "Name", direction: "asc" }]` | | `fields` | `string[]` | No | Only return specific field names | | `view` | `string` | No | Name or ID of an Airtable view | **Returns:** ```typescript { records: Array<{ id: string fields: Record createdTime: string }> offset?: string } ``` When `offset` is present in the response, pass it back to fetch the next page. --- ### airtable.getRecord Gets a single record by ID. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `baseId` | `string` | Yes | Airtable base ID | | `tableIdOrName` | `string` | Yes | Table ID or name | | `recordId` | `string` | Yes | Record ID (e.g., `"recXXXXXXXXXXXXXX"`) | **Returns:** ```typescript { id: string fields: Record createdTime: string } ``` --- ### airtable.createRecords Creates up to 10 records in a single request. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `baseId` | `string` | Yes | Airtable base ID | | `tableIdOrName` | `string` | Yes | Table ID or name | | `records` | `array` | Yes | Array of `{ fields: { ... } }` objects (max 10) | **Example:** ```json { "baseId": "appABC123", "tableIdOrName": "Customers", "records": [ { "fields": { "Name": "Alice", "Email": "alice@example.com" } }, { "fields": { "Name": "Bob", "Email": "bob@example.com" } } ] } ``` **Returns:** ```typescript { records: Array<{ id: string fields: Record createdTime: string }> } ``` --- ### airtable.updateRecords Updates up to 10 records in a single request. Only the specified fields are updated; unspecified fields are left unchanged. **Parameters:** | Param | Type | Required | Description | |-------|------|----------|-------------| | `baseId` | `string` | Yes | Airtable base ID | | `tableIdOrName` | `string` | Yes | Table ID or name | | `records` | `array` | Yes | Array of `{ id: "recXXX", fields: { ... } }` objects (max 10) | **Example:** ```json { "baseId": "appABC123", "tableIdOrName": "Customers", "records": [ { "id": "recXYZ789", "fields": { "Status": "Active" } } ] } ``` **Returns:** ```typescript { records: Array<{ id: string fields: Record 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