Skip to Content

Developer Guide

Getting Started

Voicle lets you build AI voice assistants that handle real phone calls — connected to your business systems. Follow these steps to go from a blank assistant to a live phone number.

Step 1 — Create an assistant

From the home screen click New Assistant and choose a starting template. The Salesperson and Support templates come pre-configured with common stages and tools. Blank gives you a clean slate.

Step 2 — Set up your stages

Open the Stages tab. Each stage is a phase of the conversation with its own prompt and set of actions. Write a clear prompt for each stage or use the Generate button to create one from a short description.

Step 3 — Connect your backend

Go to Advanced → Backend to enter your backend credentials (Odoo URL, database, user, password). Then open the Resources tab and describe the data models your assistant will access — this helps the AI write accurate actions.

Step 4 — Add actions

In the Stages tab click + New Tool on a stage. Describe what the action should do and click Generate — the AI writes the code using your resource definitions. Test each action before going live.

Step 5 — Assign a phone number and go live

Go to the VoIP tab. Add your Telnyx phone number, then click Deploy. The assistant starts answering calls immediately. See the Telnyx Phone Setup guide in the sidebar if you haven't purchased a number yet.

Tip: Use the in-browser Test tab to have a full conversation with your assistant before connecting a real phone number. This lets you catch prompt and action issues without using call minutes.

Setting up Stages

A stage is a phase of the conversation. Each stage has its own prompt and a set of actions the assistant can perform. The assistant stays in the current stage until you configure it to move on.

When to use multiple stages
  • The conversation has distinct phases, e.g. greeting → taking an order → confirming.
  • You want different actions available at different points in the call.
  • The assistant's goal or tone should change after a key event.
Automatic advancement

Each stage can be configured to automatically advance to the next stage when a specific action completes. Configure this in the stage's Transition settings — choose which action triggers it and which stage comes next.

You can also provide a handover message — a short summary that the assistant carries into the next stage so it can continue naturally without re-asking for information already collected.

Tip: Keep each stage prompt focused on one goal. A greeting stage should only cover how to greet and qualify the caller — order-taking belongs in the next stage.
Actions per stage

Only the actions assigned to the current stage are available during that phase. Keep the list short and relevant — this reduces errors and keeps the assistant on track.

Warning: Always include the End Call action in every stage so the caller can hang up gracefully at any point.

Writing Good Prompts

The system prompt shapes everything: the persona, the goal, how the agent handles silence, how it asks for information, and how it responds to unexpected input.

What to include in a prompt
  • Who the assistant is — name, role, and company.
  • The goal for this stage — one clear sentence.
  • Tone and style — friendly, professional, concise.
  • What to do when information is missing — ask once, don't repeat.
  • What not to do — don't go off-topic, don't make up information.
Voice-specific rules
  • Keep every reply to 1–2 sentences — callers can't reread long answers.
  • Never use bullet points or lists — they sound unnatural when spoken.
  • Spell out numbers: "twenty-nine dollars" not "$29".
  • Use natural filler while waiting: "Let me check that for you."
  • Never ask two questions in one turn — the caller will only answer the last one.
Using Generate

Click the Generate button next to the prompt and describe in one sentence what this stage should achieve. The AI writes the full prompt for you in seconds.

Tip: Be specific: "Greet the caller warmly, ask for their name and what they'd like to order today." The more detail you give, the better the result.
Handover message

When configuring a stage transition, you can write a short handover message that summarises what was just accomplished. This helps the assistant continue naturally into the next stage.

Tip: Good: "Order placed. Total: $49.99. Ask the caller to confirm." — factual and actionable. Avoid vague messages like "done" or "order created".

Caller Personalisation

Every stage prompt and the greeting message support {variable} placeholders that are replaced with real caller data at the start of each call. This lets you greet callers by name, reference their account, or adjust the conversation based on who is calling — with no extra code.

Available variables

Two sets of variables are available: built-in call metadata and data fetched from your Odoo backend before the call starts.

Call metadata — always available
VariableDescription
{caller_phone}Caller's phone number in E.164 format, e.g. +12025551234. Empty in browser test sessions unless Test Caller Phone is set (see below).
{to_number}The phone number that was dialled — useful when one project handles multiple numbers.
{call_sid}The provider's unique call or stream ID.
Caller lookup — available when an Odoo backend is connected

When your project has an Odoo backend, a caller lookup runs automatically at the start of every call. It searches Odoo for a customer whose phone matches {caller_phone} and makes the result available in every prompt.

VariableTypeDescription
{name}textCustomer's full name. Empty string if not found.
{email}textCustomer's email address. Empty string if not found.
{phone}textThe phone number used for the lookup.
{customer_id}numberOdoo customer record ID. Absent when not found — tools can use this directly without searching again.
{found}True / FalseWhether a matching customer was found in Odoo.
Using variables in prompts

Place any variable inside curly braces directly in the prompt text:

You are a sales assistant for Acme Corp.
You are speaking with {name}. Their email on file is {email}.
Address them by name from the very first sentence.
Fallback values

Use {variable|fallback} to display a default when the variable is empty — for example when the caller is not in your system:

Hello {name|there}, welcome to Acme Corp. How can I help you today?

This outputs "Hello Alice, welcome…" for known callers and "Hello there, welcome…" for unknown ones.

Conditional greeting example
You are a sales agent for Acme Corp.

The caller's phone is {caller_phone}.

If the caller is known ({found} is True):
  Greet them by name — "{name}" — and offer to continue from where they left off.

If the caller is unknown ({found} is False):
  Introduce yourself, ask for their name, and proceed from there.
Escaping literal braces

If your prompt text needs a literal { or }, double them:

Our freephone number is {{0800-EXAMPLE}}.

Test Caller Phone — simulating a real caller in browser sessions

When you test your assistant in the browser (from the Test tab), there is no real phone call, so {caller_phone} is empty and the caller lookup returns nothing — meaning {name}, {email}, and {customer_id} are all blank.

To test the full personalisation flow in the browser, set a Test Caller Phone number in the Advanced tab:

  1. Open your project and go to the Advanced tab.
  2. Find the Test Caller Phone field.
  3. Enter a phone number in E.164 format (e.g. +12025551234) that exists as a customer phone in your Odoo database.
  4. Click Save.

With this set, every browser test session will:

  • Use the test number as {caller_phone}
  • Run the caller lookup against Odoo with that number
  • Populate {name}, {email}, {customer_id} exactly as a real call would
Note: Test Caller Phone has no effect on real incoming phone calls — it is only used when {caller_phone} would otherwise be empty (browser test sessions). Real calls always use the actual caller's number.

Greeting Preload

Greeting Preload lets you run a data lookup while your assistant is delivering its opening greeting — so the result is ready before the caller has finished their first sentence. There is no pause and no "let me look that up" filler needed.

Why it matters: A normal lookup tool fires only when the LLM decides to call it — adding 1–3 seconds of silence mid-conversation. With Greeting Preload the lookup runs in the background during the greeting (which the caller is already listening to), so the wait is zero.
How it works
  1. When a call connects, your greeting message begins playing to the caller.
  2. At the same moment, all Greeting Preload tools run in parallel in the background.
  3. By the time the caller finishes listening to the greeting and starts speaking, the lookup results are already available.
  4. The results are automatically injected into your assistant's system prompt — scalar values become {variable} placeholders, larger objects appear as a structured data block.
  5. From the very first sentence the caller speaks, your assistant already knows who they are.
What data is available to a preload tool

Preload tools receive call_inputs as their args dict. The LLM never calls these tools — there are no conversation inputs yet. Every value must be read with args.get(...). Two sources contribute to args:

Call metadata — always present
KeyDescription
caller_phoneCaller's phone number in E.164 format, e.g. +12025551234.
to_numberThe phone number that was dialled.
call_sidThe provider's unique call or stream ID.
Pre-call lookup results — present when configured

If your project has a Caller Lookup configured (the automatic Odoo phone lookup), its results are merged into args before greeting preload tools run. This means a greeting preload tool can use customer_id directly — no duplicate phone lookup needed:

KeyDescription
customer_idOdoo partner record ID. Present only when a matching customer was found.
nameCustomer's full name.
emailCustomer's email address.
foundTrue or False.
How to mark a tool as Greeting Preload
  1. Open your project and go to the Stages tab.
  2. Find the tool chip you want to convert — for example Get Customer by Phone in your greeting stage.
  3. Click the button on the chip. The tool moves out of the stage and appears in the Greeting Preload section that appears above the stages.
  4. Click Save.

To move a tool back to a stage, open the Greeting Preload section and click the button on its chip — it returns to the first stage's tool list.

Important: A tool in Greeting Preload is not called by the LLM during the conversation — it runs once at call start only. Do not move a tool here if the assistant also needs to call it interactively mid-call. Create a separate lookup-only tool for the preload and keep the interactive version in the stage.
Writing a good preload tool

A preload tool should be a fast, read-only lookup. Keep these rules in mind:

  • Always read inputs with args.get(...) — never use a variable name directly. The LLM does not call this tool, so there are no LLM-provided arguments. Everything comes from args (= call_inputs).
    # Correct
    customer_id = args.get('customer_id')
    if not customer_id:
        return {}
    
    # Wrong — causes NameError at runtime
    logger.info(f'customer_id={customer_id}')  # customer_id not defined!
  • Leave the Parameters section empty. Schema parameters are used by the LLM to fill in values when it calls a tool — but the LLM never calls a preload tool. Having parameters defined is misleading and serves no purpose.
  • If Caller Lookup is configured, use customer_id directly — it is already in args from the pre-call lookup. No need to search by phone again.
  • Return flat key–value pairs for the values you want as {variable} placeholders in prompts. For example: {"address": "123 Main St", "city": "Denver"}.
  • Do not set _run_llm: True. The LLM does not call this tool — there is no result to speak aloud.
  • Keep it fast. The preload runs while the greeting plays (typically 3–5 seconds). If the lookup takes longer than 3 seconds, the assistant proceeds without the data.
  • Read only. Do not create or modify records — the caller hasn't said anything yet.
Example — look up a caller by phone
# execute.py — Greeting Preload tool
caller_phone = args.get('caller_phone')
if not caller_phone:
    return {}

results = await server.search_read(
    'res.partner',
    domain=[['phone', '=', caller_phone], ['customer_rank', '>', 0]],
    fields=['id', 'name', 'email', 'phone'],
    limit=1,
)
if not results:
    return {'found': False}

customer = results[0]
return {
    'found':       True,
    'customer_id': customer['id'],
    'name':        customer.get('name', ''),
    'email':       customer.get('email', ''),
}

With this tool marked as Greeting Preload, your stage prompts can reference {name}, {customer_id}, and {found} from the very first turn — no extra lookup needed during the call.

Using the results in your prompt

Preload results are injected into the system prompt exactly like any other variable. Use the same {variable} and {variable|fallback} syntax described in the Caller Personalisation guide:

You are a sales agent for Acme Corp.

If {found} is True:
  Greet the caller by name — "{name}" — immediately.
  Their account ID is {customer_id}. Use it directly in order creation.

If {found} is False:
  Greet them warmly, ask for their name, and create a new customer record
  before placing an order.
Tip: Use the Test Caller Phone field in Advanced to test preload in browser sessions. Without a test number, caller_phone is empty and the preload tool returns nothing — which is the correct fallback behaviour.
Limitations
  • Preload tools run once per call, at the very start. They cannot be re-triggered mid-call.
  • Each tool has a 3-second budget. If it exceeds this, the call continues without its results — the assistant falls back to empty variables.
  • Multiple preload tools run in parallel — there is no ordering between them.
  • The tool is not visible to the LLM as a callable action. It does not appear in the tool list the assistant can use during the conversation.

Resources

Resources are backend model definitions — descriptions of the data objects your project can access through its backend (e.g. Odoo, ERPNext). They are used only at generation time: when you click Generate to create or improve a tool, the AI reads your resource definitions to understand what data is available and how to write correct tool code. Resources are not applied at runtime — the tool's generated code is the authority.

What a resource definition contains
  • Resource name — the backend model identifier, e.g. res.partner, sale.order, product.template.
  • Description — a plain-English explanation of what this model represents, used as context when the AI generates tool code.
  • Fields — name, type, optional description, and required flag. The AI uses these to know which fields to read or write.
  • Access rightscreate, read, write, delete, search_read. Tells the AI which types of tool make sense for this resource.
  • Base domain — optional Odoo domain conditions that always apply when searching this model (e.g. only active products, only customer contacts). The AI includes these in the generated tool's search calls.
  • Create extra — optional extra fields that must always be included when creating a record (e.g. {"customer_rank": 1} to mark a new partner as a customer). The AI merges these into the generated create calls.
Important: Resources are generation-time hints only — they are not applied at runtime. The tool's generated code includes all required domain conditions and create fields. If you change a resource's Base domain or Create extra after a tool was already generated, you need to regenerate or manually update the tool code for the change to take effect.
Example resource definition
Resource:     res.partner
Description:  Customer contacts in Odoo.
Access:       create  read  search_read
Base domain:  [["customer_rank", ">", 0], ["type", "=", "contact"]]
Create extra: {"customer_rank": 1}

Fields:
  id          integer   Record ID
  name        string    Customer name          (required)
  phone       string    Phone number
  email       string    Email address

A tool generated from this resource will automatically include the base domain in its search calls and merge the create extra into its create calls — the tool code is self-contained and does not rely on the backend to apply any filters.

Tip: The more complete your resource definitions — especially field descriptions and base domain — the better and more accurate the generated tool code.
Adding a resource
  1. Go to the Resources tab of your project.
  2. Click + Add Resource. A new card appears.
  3. Enter the model name (e.g. product.template) and a brief description.
  4. Toggle the access rights that apply to this resource.
  5. Add the fields the tool will need to read or write.
  6. Optionally add a Base domain if searches should always be scoped (e.g. only active records).
  7. Optionally add Create extra if new records always need certain fields set (e.g. a type flag or rank field).
  8. Click Save.
Access rights — what each operation means
  • read — fetch records by simple field equality filter.
  • search_read — search for records matching a domain filter and return their fields.
  • create — create a new record with given field values.
  • write — update fields on an existing record.
  • delete — delete a record by ID.
Note: Access rights here are hints for the AI, not runtime enforcement. They tell the AI which types of tool to generate for this resource. Actual permission enforcement is handled by your Odoo user account.

Writing Actions (Tools)

An action lets your assistant do something during a call — look up a product, create an order, book an appointment.

Creating an action
  • In the Stages tab, click + New Tool on the stage where the action should be available.
  • Give the action a clear name (e.g. "Search Products", "Create Order").
  • Write a description of what the action does and when the assistant should use it.
  • Add the parameters the assistant needs to collect from the caller.
  • Click Generate to have the AI write the action logic.
  • Review, adjust if needed, then save.
Writing a good description
  • Too vague: "Gets products."
  • Good: "Search the product catalogue by name or keyword. Call this when the caller asks about product availability or pricing."
Writing good parameter descriptions
  • Too vague: query — "The query"
  • Good: query — "The product name or keyword the caller mentioned, e.g. 'Widget A' or 'blue chair'"
Tip: If a parameter is optional, say so: "The customer's preferred delivery date. Leave blank if not mentioned."

Advanced: extra options on CRUD calls

All standard backend methods — search_read, create, write, delete, read — accept extra keyword arguments that are forwarded directly to the Odoo JSON-RPC call. Use these to pass Odoo context flags or other options.

# Search including archived records
results = await server.search_read(
    "product.product", domain=[], fields=["id", "name"], limit=50,
    context={"active_test": False}
)

# Skip many2one name resolution (faster for large reads)
results = await server.search_read(
    "sale.order", domain=[], fields=["id", "partner_id"], limit=100,
    load=""
)

# Pass context on create
order_id = await server.create(
    "sale.order", vals={"partner_id": partner_id},
    context={"default_company_id": 1}
)

Advanced: calling any Odoo method

When you need to trigger an Odoo business action that isn't a simple record write — confirming an order, validating a delivery, posting an invoice — use server.call() in the action code editor.

# Confirm a sale order
await server.call("sale.order", "action_confirm", ids=[order_id])

# Validate a stock picking (delivery)
await server.call("stock.picking", "button_validate", ids=[picking_id])

# Post an invoice
await server.call("account.move", "action_post", ids=[invoice_id])

# Cancel an order
await server.call("sale.order", "action_cancel", ids=[order_id])

server.call(model, method, ids=[...], **extra_args)

  • model — the Odoo model name, e.g. "sale.order", "stock.picking".
  • method — any ORM or business method on that model.
  • ids — list of record IDs the method should act on.
  • extra_args — any additional keyword arguments the method requires.
Tip: You can pass the customer_id variable from the caller lookup directly to actions without asking for it again — the assistant already has it from the start of the call via session["customer_id"].

Using Integrations in Tools

Integrations let your tools send emails, check calendar availability, or send WhatsApp messages during a call — without the AI needing to know the credentials. You attach an integration to a project once; every tool in that project can then access it via kwargs.

Step 1 — Connect an integration

In the manager, click Integrations in the left sidebar, then + New Integration. Choose a category (Email, Calendar, or Messaging), enter the credentials, and click Save.

Step 2 — Attach the integration to your project
  1. Open your project and go to the Advanced tab.
  2. Find the Integrations card.
  3. Click Attach next to the integration you want. It will be highlighted once attached.
  4. Click Save. The integration is now available to all tools in this project.
Note: Integrations work with both voice and chat projects. Any tool in any stage can access them — attach once, use everywhere.
Step 3 — Access integrations in your tool execute body

Integrations are passed into your execute function via **kwargs. Always use .get() and guard with an if check — the value is None if the integration was not attached to the project.

    integrations = kwargs.get("integrations", {})
    email    = integrations.get("email")      # EmailIntegration or None
    calendar = integrations.get("calendar")   # CalendarIntegration or None
    whatsapp = integrations.get("whatsapp")   # MessagingIntegration or None
Example — send a confirmation email
    order_id = await server.create("sale.order", vals={"partner_id": args["partner_id"]})

    integrations = kwargs.get("integrations", {})
    email = integrations.get("email")
    if email:
        await email.send(
            to=args["customer_email"],
            subject=f"Order {order_id} confirmed",
            body_text=f"Your order {order_id} has been placed successfully.",
        )

    return {"order_id": order_id}
Example — check calendar availability
    integrations = kwargs.get("integrations", {})
    calendar = integrations.get("calendar")
    if not calendar:
        return {"error": "Calendar not configured", "_run_llm": True}

    slots = await calendar.get_available_slots(date=args["date"])
    return {"slots": slots, "_run_llm": True}
Example — send a WhatsApp message
    integrations = kwargs.get("integrations", {})
    whatsapp = integrations.get("whatsapp")
    if whatsapp:
        await whatsapp.send(
            to=args["phone"],
            body=f"Hi {args['name']}, your appointment is confirmed for {args['date']}.",
        )
    return {"sent": bool(whatsapp)}
Available integration types
KeyTypeWhat it does
emailEmailIntegrationSend transactional emails (Gmail OAuth or IMAP/SMTP).
calendarCalendarIntegrationCheck availability and book appointments (Google Calendar / Outlook).
whatsappMessagingIntegrationSend WhatsApp messages via Twilio.
Rules to follow:
  • Always guard with if email: before calling — the integration is None if not attached.
  • Never import integration classes directly in your execute body — only use kwargs.get("integrations", {}).
  • Always use kwargs.get("integrations", {}) (with a default of {}) — safe even when no integrations are present.

Testing Actions

Every action has a built-in test runner so you can verify it works correctly against your backend before connecting a real phone number.

Run a test
  • Open an action in the editor and click the Test tab.
  • Click Generate to auto-fill realistic test values.
  • Review and adjust values to match real records in your system.
  • Click Run. The action executes against your live backend and the result is shown.
Common failures and fixes
  • Authentication error — check your backend credentials in Advanced → Backend.
  • Empty result — the record doesn't exist. Try a different name or value.
  • Missing parameter — a required parameter was left blank.
  • Permission denied — your backend user doesn't have access to this data.
Warning: Tests run against your real backend. Actions that create or update records will do so for real. Use test data if you don't want to affect live records.
Full conversation test

Use the Test tab on the main editor to have a full voice conversation with your assistant in the browser — without using a phone number or call minutes.

Telnyx Phone Setup

Telnyx is the VoIP provider that connects real phone calls to your assistant.

Step 1 — Create a Telnyx account
  • Go to telnyx.com and create an account.
  • Complete identity verification (required for phone number purchases).
  • Add credits to your Telnyx balance.
Step 2 — Buy a phone number
  • In the Telnyx portal go to Numbers → Buy Numbers.
  • Search by area code or country and purchase a number.
  • Note the E.164 format number, e.g. +12025551234.
Step 3 — Create a TeXML application
  • Go to Voice → TeXML Applications and click Add New Application.
  • Set the Webhook URL to:
https://voip.voicle.ai/incoming/telnyx
  • Set HTTP Method to POST and save.
Step 4 — Assign the number to the app
  • Go to Numbers → My Numbers and click your number.
  • Under Voice & Messaging, set the connection to your TeXML application and save.
Step 5 — Add the number in Voicle
  • Open your project in Voicle and go to the VoIP tab.
  • Click + Add Number, enter the E.164 number, then click Deploy.
Tip: Only one phone number can be assigned per project. Delete the existing one first if you need to change it.
Testing the connection
  • Call the number from your mobile. You should hear ringback (≈2 rings), then the greeting.
  • If you hear silence, check the project is deployed (green badge in the VoIP tab).
  • If the call goes to Telnyx voicemail, the webhook URL is not set correctly.

Website Integrations

Embed a voice or chat assistant on any website — Shopify, WordPress, or custom HTML — with a single script tag. No Odoo session or login required for your visitors.

How it works: You create an embed token in the manager, paste a snippet into your website, and the widget handles the rest — it exchanges the token for a short-lived session and opens a WebSocket to your assistant automatically.
Step 1 — Build and deploy your assistant

Before any website visitor can use the widget, your project must be deployed. Deploying snapshots your current draft so live visitors always get a stable version while you continue editing.

  1. Open your project in the manager.
  2. Set up your stages, prompts, and actions as described in the other guides.
  3. Click ↑ Deploy in the top bar. The button shows ↑ Redeploy with a timestamp once complete.
  4. Every time you make changes you want live, click ↑ Redeploy again.
Important: The embed widget connects to the deployed snapshot, not your draft. Changes you make after deploying are not visible to visitors until you redeploy.
Step 2 — Create an embed token

In the manager, click Embed in the left sidebar, then + New Token.

FieldWhat to enter
Label A name for your own reference, e.g. Homepage widget or Customer portal.
Project The voice or chat assistant to embed.
Allowed Origins Your website's exact origin, e.g. https://example.com. Leave empty to allow all origins (useful for testing only).
Require Authentication Leave unchecked for public widgets. Check it if only logged-in users of your app should access the assistant (see Step 4).

After saving, click the token in the list to open the detail panel and copy your snippet.

Step 3A — Anonymous / public widget

Any visitor to your page can use the widget — no login required. Copy the snippet from the manager and paste it before :

<div id="voicle-embed-root"
     data-embed-token="emb_YOUR_TOKEN_HERE">
</div>
<script src="https://app.voicle.ai/voicle_embed.js" defer></script>

That is all. The widget appears automatically when the page loads — no further configuration needed.

Step 3B — Authenticated widget (logged-in users only)

Use this when the widget should only be available to users who are already logged into your application (a customer portal, SaaS dashboard, etc.).

In the manager: check Require Authentication on the token, then click Reveal next to the Shared Secret. Copy this value and store it as a server-side environment variable — for example VOICLE_SHARED_SECRET. Never put the secret in your HTML or JavaScript.

How it works: your server computes a keyed hash (HMAC-SHA256) of the visitor's email address and the current time, then injects the result into the page. The widget sends it to Voicle, Voicle recomputes and compares — if it matches and is less than 2 minutes old, the session is granted.

Hash = HMAC-SHA256(
  message = "{user_email}|{unix_timestamp_seconds}",
  key     = shared_secret
)

Recommended — Identity URL (works with any stack, no template changes needed):
Add data-identity-url pointing to one endpoint on your own backend. The widget calls it automatically at load time — your HTML snippet is completely static:

<div id="voicle-embed-root"
     data-embed-token="emb_YOUR_TOKEN_HERE"
     data-identity-url="https://yourapp.com/api/voicle-identity">
</div>
<script src="https://app.voicle.ai/voicle_embed.js" defer></script>

Your /api/voicle-identity endpoint returns JSON with the HMAC computed from the logged-in user's session (see server-side examples below):

{ "user_email": "[email protected]", "user_hash": "a3f9...", "user_ts": 1746700800 }

The widget sends a credentialed GET request (cookies included) so your endpoint can read the session and identify the user. This works with any framework — Django, Rails, PHP, Express, Spring — because your HTML never changes.

Alternative — Pre-injected attributes (SSR only, saves one HTTP round trip):
If your site is server-rendered (Django, Rails, PHP), you can compute the HMAC at page render time and write the values directly into the HTML. This avoids the extra fetch but requires modifying every page template that shows the widget:

<div id="voicle-embed-root"
     data-embed-token="emb_YOUR_TOKEN_HERE"
     data-user-email="{{ current_user.email }}"
     data-user-hash="{{ voicle_hash }}"
     data-user-ts="{{ unix_ts }}">
</div>
<script src="https://app.voicle.ai/voicle_embed.js" defer></script>
Step 4 — Server-side HMAC (language examples)

Replace VOICLE_SHARED_SECRET with your secret from the manager and user_email with the logged-in user's email address.

import hashlib, hmac, time, os

def voicle_identity(user_email: str) -> dict:
    secret = os.environ["VOICLE_SHARED_SECRET"]
    ts     = int(time.time())
    msg    = f"{user_email}|{ts}".encode()
    h      = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return {"user_email": user_email, "user_hash": h, "user_ts": ts}

# Django/Flask view:
identity = voicle_identity(request.user.email)
# Pass identity["user_email"], identity["user_hash"], identity["user_ts"] to template
<?php
function voicle_identity(string $email): array {
    $secret  = $_ENV['VOICLE_SHARED_SECRET'];
    $ts      = time();
    $message = "{$email}|{$ts}";
    $hash    = hash_hmac('sha256', $message, $secret);
    return ['user_email' => $email, 'user_hash' => $hash, 'user_ts' => $ts];
}

// Laravel:
$identity = voicle_identity(auth()->user()->email);
// $identity['user_email'], $identity['user_hash'], $identity['user_ts']
const crypto = require('crypto');

function voicleIdentity(userEmail) {
  const secret = process.env.VOICLE_SHARED_SECRET;
  const ts     = Math.floor(Date.now() / 1000);
  const msg    = `${userEmail}|${ts}`;
  const hash   = crypto.createHmac('sha256', secret).update(msg).digest('hex');
  return { userEmail, userHash: hash, userTs: ts };
}

// Express / Next.js:
const identity = voicleIdentity(req.user.email);
// identity.userEmail, identity.userHash, identity.userTs
require 'openssl'

def voicle_identity(email)
  secret  = ENV['VOICLE_SHARED_SECRET']
  ts      = Time.now.to_i
  message = "#{email}|#{ts}"
  hash    = OpenSSL::HMAC.hexdigest('SHA256', secret, message)
  { user_email: email, user_hash: hash, user_ts: ts }
end

# Rails controller:
@voicle = voicle_identity(current_user.email)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.time.Instant;

public record VoicleIdentity(String userEmail, String userHash, long userTs) {}

public VoicleIdentity voicleIdentity(String email) throws Exception {
    String secret = System.getenv("VOICLE_SHARED_SECRET");
    long ts       = Instant.now().getEpochSecond();
    String msg    = email + "|" + ts;
    Mac mac       = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
    byte[] raw    = mac.doFinal(msg.getBytes());
    StringBuilder sb = new StringBuilder();
    for (byte b : raw) sb.append(String.format("%02x", b));
    return new VoicleIdentity(email, sb.toString(), ts);
}
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "os"
    "time"
)

type VoicleIdentity struct {
    UserEmail string
    UserHash  string
    UserTs    int64
}

func voicleIdentity(email string) VoicleIdentity {
    secret := os.Getenv("VOICLE_SHARED_SECRET")
    ts     := time.Now().Unix()
    msg    := fmt.Sprintf("%s|%d", email, ts)
    mac    := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(msg))
    return VoicleIdentity{email, hex.EncodeToString(mac.Sum(nil)), ts}
}
What happens at runtime
  1. The widget reads data-embed-token, data-user-email, data-user-hash, and data-user-ts from the div.
  2. It calls POST /api/embed/session with the token and identity fields.
  3. Voicle verifies the HMAC — if the hash matches and the timestamp is within 2 minutes, the visitor's identity is confirmed.
  4. If Require Authentication is on and verification fails, the widget stays hidden — no error is shown.
  5. On success, the widget opens a WebSocket and the session begins immediately.
  6. When identity is verified, the assistant already knows the visitor's email — it can look them up in your CRM at the start of the call without asking.
Identity outcomes
Require AuthHash valid?Result
OffYesSession granted — assistant knows visitor's email
OffNo / absentSession granted — anonymous visitor
OnYesSession granted — assistant knows visitor's email
OnNo / absentSession refused — widget stays hidden
Security checklist
  • Never put the shared secret in HTML or JavaScript. Only the computed hash goes into the page.
  • Always set Allowed Origins in production. A token without origin restrictions can be copied and used from any domain.
  • Use HTTPS. The Authorization header carrying your embed token is sent over TLS.
  • Tokens are revocable. Disable a token in the manager (toggle off Active) and all future sessions with it are rejected immediately — no code change needed.
  • Sessions expire after 1 hour. Visitors get a fresh session on the next page load.
  • Rate limited at 20 session exchanges per minute per token — protects against automated abuse.
Tip: Create one embed token per website or environment (e.g. Production, Staging). That way you can revoke or restrict a single environment without affecting the others.

Email Integration

Connect a mailbox so your chat assistant reads inbound emails, replies automatically, and handles full order or support conversations — all without a human in the loop.

How it works: Voicle polls your inbox every 60 seconds. When a new order-related email arrives it opens a session, processes the message through your assistant, and sends a reply from your address. The conversation continues with each reply until the order is placed or the maximum turns are reached.
Before you start

You need one of the following:

  • Gmail with App Password — a Gmail account with 2-Step Verification enabled. An App Password lets Voicle access the account without your main password.
  • Gmail OAuth — a Google Workspace account connected via Google sign-in (recommended for business accounts).
  • Any IMAP/SMTP mailbox — Outlook, Yahoo, custom domain mail, or any provider that supports IMAP over port 993 and SMTP over port 587.
Gmail note: Google disabled plain-password access in 2025. Personal Gmail accounts must use an App Password. Go to myaccount.google.com → Security → App Passwords and create one named "Voicle".
Step 1 — Add an email integration

In the Voicle manager, click Integrations in the left sidebar, then + New Integration.

  1. Choose the Email category.
  2. Select your provider:
    • Gmail OAuth — click Connect with Google and follow the consent screen. Credentials are saved automatically.
    • Gmail / IMAP — fill in the credential form below.
  3. Give the integration a name you'll recognise, e.g. [email protected].
  4. Click Test Connection to verify. A green checkmark means Voicle can log in successfully.
  5. Click Save.

IMAP / Gmail App Password credentials:

FieldGmailOutlookOther
IMAP Hostimap.gmail.comoutlook.office365.comYour provider's IMAP server
IMAP Port993993Usually 993
SMTP Hostsmtp.gmail.comsmtp.office365.comYour provider's SMTP server
SMTP Port587587Usually 587
UsernameFull Gmail addressFull Outlook addressFull email address
PasswordApp Password (16 chars, no spaces)Account passwordAccount password
Step 2 — Attach the integration to your project
  1. Open your chat project in the manager.
  2. Go to the Advanced tab and find the Activation section.
  3. In the Email dropdown, select the integration you just created.
  4. Click Save.

The email channel is now active. Voicle will start polling the inbox once the project is deployed.

Step 3 — Configure conversation settings

Still in Advanced → Activation, adjust these settings before clicking Save:

SettingWhat it doesRecommended
Max turns Maximum number of email exchanges before the conversation is handed off to a human. 1020
Forward to Email address that receives a full transcript when max turns is reached or when a non-order email arrives. Your support or sales inbox
Fetch last N hours Only poll emails from the last N hours. Prevents re-processing a large inbox backlog when the assistant is first deployed or restarted. 3 (start with this)
Tip: Always set Fetch last N hours before the first deploy. Without it, Voicle will attempt to process every unread email currently in the inbox.
Step 4 — Add an email signature (optional)

Every reply Voicle sends can include your branded signature — logo, name, phone, and links.

  1. In Advanced → Activation, scroll to Email Signature. This section appears only when an email integration is selected.
  2. Use the toolbar to format your signature: bold, italic, underline, coloured text, and hyperlinks are all supported.
  3. To add a logo, click Image in the toolbar and paste a public https:// image URL. The image must be publicly accessible — Voicle cannot upload images to your server directly.
  4. Click Save. The signature is appended to every outbound reply automatically.
Logo sizing: Images are automatically constrained to 60 px tall when sent — this prevents logos from filling the entire email on the recipient's screen. Use a wide logo (e.g. 300 × 60 px) for best results.
Where to get your logo URL: Open your company website, right-click your logo image, and select Copy image address (Chrome / Edge) or Copy Image Link (Firefox / Safari). Paste that URL into the Image prompt.

No website? Upload the logo to Google Drive, right-click the file and select Get link, set sharing to Anyone with the link, then use this format to get a direct image URL: https://drive.google.com/uc?export=view&id=FILE_ID — replace FILE_ID with the long ID from your share link.
Step 5 — Set up email filters (optional)

Filters let you control which inbound emails the assistant should process. Any email that does not pass a filter is silently skipped and left unread in your inbox.

FilterTypeHow it worksExample
Folder / Label Which inbox folder or Gmail label to watch. Default is INBOX. INBOX, or a Gmail label like Orders
From domains Whitelist Only process emails from these sender domains. Leave empty to accept all. acme.com, partner.org
From addresses Whitelist Only process emails from these exact sender addresses. [email protected]
To addresses Whitelist Only process emails where at least one recipient matches. Useful when one mailbox receives mail for multiple addresses. [email protected]
Subject contains Whitelist (OR) Only process emails whose subject contains at least one of the listed keywords. order, quote, request
Subject excludes Blacklist (OR) Skip emails whose subject contains any of these keywords. Applied even when no other filters are set. noreply, unsubscribe, auto-reply

Enter values as comma-separated text. Click Save when done.

Good defaults: Set Subject excludes to noreply, unsubscribe, auto-reply, out of office to prevent the assistant from replying to automated system emails.
Step 6 — Deploy your project

Email polling only starts for deployed projects. Click ↑ Deploy in the top bar. The email poller will discover the project within 5 minutes and begin polling automatically — no server restart required.

Step 7 — Test with a real email

Once deployed, you can trigger a full test without waiting for the next poll cycle:

  1. Set Test sender email in Advanced → Text Context to the email address you'll send a test from.
  2. Send a test order email to your connected mailbox from that address.
  3. In the project editor header, click Test Email.
  4. Voicle will fetch the email, run it through the assistant, and send a real reply. The result is shown in the panel.
Note: Test Email sends a real reply to the test sender address and marks the email as read. Use a personal test address, not a customer address.

How the assistant handles email conversations

New emails — intent check

When a brand-new email arrives (not a reply to an existing thread), the assistant runs a quick check to decide whether it looks like an order or purchase request. Emails classified as not an order — spam, promotions, password resets — receive a polite redirect message and the transcript is forwarded to your Forward to address. Only emails with purchasing intent open a full conversation session.

Ongoing threads — reply matching

Once a session is open, every reply from the same customer email address in the same email thread is fed into the assistant as the next turn. Replies from a different address in the same thread are silently ignored — this prevents third parties from injecting messages into an existing conversation.

Reply format

All replies are sent as plain text with any markdown formatting stripped. If a signature is configured, it is appended as styled HTML (the email is sent as text + HTML so mail clients that don't support HTML still receive the plain version).

Max turns & handoff

When the conversation reaches Max turns, the customer receives a message saying the conversation has been forwarded to your team. The full transcript is sent to the Forward to address so a human agent can pick it up.

Tip: Check the Conversations tab in your project to see all active and past email sessions, the full message history, and the current session status.

Troubleshooting

SymptomLikely causeFix
Test Email returns "no email found" Fetch last N hours is too small, or the test email is older than that window. Increase Fetch last N hours to cover when you sent the test, then re-run.
Authentication failed on Test Connection Wrong password or App Password not created. For Gmail: re-generate the App Password at myaccount.google.com → Security → App Passwords. Paste it with no spaces.
Reply sent but no signature Signature saved but project not redeployed, or the Email dropdown was deselected and re-saved without a signature. Open Advanced → Activation, confirm a signature is in the editor, click Save, then redeploy.
Logo in signature is full-page width Email client ignoring CSS — inline styles required. This is handled automatically by Voicle. If still occurring, the image URL may be returning a very large image. Use an image that is at most 300 px wide at 2× resolution.
Poller not picking up emails after deploy Project poller discovery runs every 5 minutes. Wait up to 5 minutes after the first deploy. Use Test Email to trigger a one-off fetch immediately.
Email received but no reply sent Email was classified as non-order intent. Check the Conversations tab — if no session was created, the email was redirected. Set Subject contains or adjust your inbox filters to ensure only relevant emails reach the assistant.