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.
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.
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.
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.
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.
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
| Variable | Description |
|---|---|
{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.
| Variable | Type | Description |
|---|---|---|
{name} | text | Customer's full name. Empty string if not found. |
{email} | text | Customer's email address. Empty string if not found. |
{phone} | text | The phone number used for the lookup. |
{customer_id} | number | Odoo customer record ID. Absent when not found — tools can use this directly without searching again. |
{found} | True / False | Whether 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:
- Open your project and go to the Advanced tab.
- Find the Test Caller Phone field.
- Enter a phone number in E.164 format (e.g.
+12025551234) that exists as a customer phone in your Odoo database. - 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
{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.
How it works
- When a call connects, your greeting message begins playing to the caller.
- At the same moment, all Greeting Preload tools run in parallel in the background.
- By the time the caller finishes listening to the greeting and starts speaking, the lookup results are already available.
- The results are automatically injected into your assistant's system prompt — scalar values become
{variable}placeholders, larger objects appear as a structured data block. - 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
| Key | Description |
|---|---|
caller_phone | Caller's phone number in E.164 format, e.g. +12025551234. |
to_number | The phone number that was dialled. |
call_sid | The 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:
| Key | Description |
|---|---|
customer_id | Odoo partner record ID. Present only when a matching customer was found. |
name | Customer's full name. |
email | Customer's email address. |
found | True or False. |
How to mark a tool as Greeting Preload
- Open your project and go to the Stages tab.
- Find the tool chip you want to convert — for example Get Customer by Phone in your greeting stage.
- 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.
- 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.
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 fromargs(= 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_iddirectly — it is already inargsfrom 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.
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 rights —
create,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.
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.
Adding a resource
- Go to the Resources tab of your project.
- Click + Add Resource. A new card appears.
- Enter the model name (e.g.
product.template) and a brief description. - Toggle the access rights that apply to this resource.
- Add the fields the tool will need to read or write.
- Optionally add a Base domain if searches should always be scoped (e.g. only active records).
- Optionally add Create extra if new records always need certain fields set (e.g. a type flag or rank field).
- 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.
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'"
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.
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
- Open your project and go to the Advanced tab.
- Find the Integrations card.
- Click Attach next to the integration you want. It will be highlighted once attached.
- Click Save. The integration is now available to all tools in this project.
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
| Key | Type | What it does |
|---|---|---|
email | EmailIntegration | Send transactional emails (Gmail OAuth or IMAP/SMTP). |
calendar | CalendarIntegration | Check availability and book appointments (Google Calendar / Outlook). |
whatsapp | MessagingIntegration | Send WhatsApp messages via Twilio. |
- Always guard with
if email:before calling — the integration isNoneif 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.
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
POSTand 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.
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.
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.
- Open your project in the manager.
- Set up your stages, prompts, and actions as described in the other guides.
- Click ↑ Deploy in the top bar. The button shows ↑ Redeploy with a timestamp once complete.
- Every time you make changes you want live, click ↑ Redeploy again.
Step 2 — Create an embed token
In the manager, click Embed in the left sidebar, then + New Token.
| Field | What 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