This tutorial creates a complete local voice application. Its only business
operation is a read-only lookup of a demo shop's opening hours. You need a working
microphone/speakers and an OpenAI key with access to gpt-live-1. No TypeSafe key,
backend language model, database, or custom application modules are needed.
1. Create the host application
Create a fresh Mix application:
mix new voice_demo
cd voice_demo
mkdir -p config
In the new application's mix.exs, replace deps/0 with:
defp deps do
[
{:parlora, "~> 0.1.0"},
{:jido, "== 2.4.0"}
]
endUse a compatible Elixir/OTP pair from the requirements guide. This new host uses the runtime you select for it.
2. Configure the build before compiling
Install the native prerequisites for local audio only.
Create config/config.exs in voice_demo:
import Config
# This application uses local audio only: do not build the browser Opus binding.
config :parlora, native_audio: true, native_opus: false
if :os.type() == {:unix, :darwin} do
config :bundlex, :disable_precompiled_os_deps, apps: [:ex_libsrtp]
endThen fetch dependencies:
mix deps.get
3. Add the complete application code
Save the following as first_voice.exs in the voice_demo directory. It defines
all modules used below; keep the generated lib/voice_demo.ex unchanged.
defmodule FirstVoice.LookupHours do
use Jido.Action,
name: "lookup_hours",
schema: [
input: [type: :map, required: true],
request_id: [type: :string],
task_id: [type: :string],
task_revision: [type: :integer]
]
@impl true
def run(%{input: %{operation: :opening_hours}}, _context) do
{:ok,
%{
status: :completed,
last_answer: %{shop: "Demo shop", hours: "Monday to Friday, 09:00 to 17:00"}
}}
end
end
defmodule FirstVoice.Agent do
use Jido.Agent,
name: "opening_hours",
schema: [status: [type: :atom, default: :idle], last_answer: [type: :map, default: %{}]],
signal_routes: [{"parlora.request", FirstVoice.LookupHours}]
end
defmodule FirstVoice.Policy do
@behaviour Parlora.ContextPolicy
@impl true
def interpret(snapshot, _trigger, _options) do
decisions =
for {id, %{status: :pending}} <- snapshot.pending_delegations do
%Parlora.ContextDecision{
kind: :start_task,
delegation_id: id,
input: %{operation: :opening_hours}
}
end
{:ok, decisions}
end
end
defmodule FirstVoice do
def run(options \\ []) do
provider =
Keyword.get_lazy(options, :provider, fn ->
{Parlora.Providers.OpenAI, api_key: System.fetch_env!("OPENAI_API_KEY")}
end)
{:ok, supervisor} =
Supervisor.start_link([{Jido, name: FirstVoice.Jido}], strategy: :one_for_one)
try do
{:ok, session} =
Parlora.start_session(
channel: Keyword.get(options, :channel, {Parlora.Channels.LocalAudio, []}),
provider: provider,
backend:
{Parlora.Backends.Jido,
agent: FirstVoice.Agent,
agent_options: [jido: FirstVoice.Jido],
result: &map_result/1},
context_policy: {FirstVoice.Policy, []},
instructions: """
Help callers ask about the demo shop's opening hours. Delegate opening-hours
questions to the backend and answer only from its verified result.
Other business operations are unavailable. Keep spoken answers brief.
""",
event_sink: self()
)
ref = Process.monitor(session)
timer = Process.send_after(self(), {:stop_demo, session}, 120_000)
try do
receive_events(session, ref)
after
Process.cancel_timer(timer)
if Process.alive?(session), do: Parlora.close(session, :cancel)
end
after
Supervisor.stop(supervisor)
end
end
def map_result(%{status: status, result: facts}) do
%{status: status, facts: facts, summary: "#{facts.shop} is open #{facts.hours}."}
end
defp receive_events(session, ref) do
receive do
{:parlora_event, %Parlora.Event{source: :session, type: :ready}} ->
IO.puts("Ready. Ask: What are the shop's opening hours?")
receive_events(session, ref)
{:parlora_event, %Parlora.Event{type: :failed}} ->
IO.puts("A voice adapter failed; closing the session.")
Parlora.close(session, :cancel)
receive_events(session, ref)
{:parlora_event, %Parlora.Event{source: :session, type: :closed}} ->
IO.puts("Session closed; waiting for cleanup.")
receive_events(session, ref)
{:DOWN, ^ref, :process, ^session, reason} ->
IO.puts("Session process stopped.")
if reason == :normal, do: :ok, else: {:error, :session_stopped}
{:stop_demo, ^session} ->
Parlora.close(session, :cancel)
receive_events(session, ref)
_event ->
receive_events(session, ref)
end
end
endThe policy has exactly one allowed operation. Every pending delegation maps to that same lookup of opening hours; claimed or cancelled delegations do not start work again. This demo handles just this one operation; it does not interpret general requests. For orders, corrections, or multiple operations, implement a policy that checks caller context, task revisions, and permissions; see the policy contract and OrderDesk workflows.
The Jido instance belongs to the demo supervisor. The calling process stays alive
as the event receiver, waits for readiness, closes after two minutes, waits for
the session process to exit, and stops its Jido supervisor. map_result/1 maps
Jido completion data into verified Parlora facts and a spoken summary.
4. Start talking
Supply OPENAI_API_KEY in this shell using your secret management, then run:
mix run -r first_voice.exs -e 'FirstVoice.run()'
Allow microphone access if prompted. When the terminal prints Ready, ask “What are the shop's opening hours?” You should hear that the demo shop is open Monday to Friday, 09:00 to 17:00. There is no automatic greeting; speak after Ready. The session closes automatically after two minutes. Interrupt the Mix process to exit sooner; normal application shutdown releases its supervised sessions.
Use headphones to reduce feedback. LocalAudio uses your default input/output
devices. For device selection, call Parlora.Channels.LocalAudio.devices/0, then
pass the returned device IDs in a channel tuple:
FirstVoice.run(channel: {Parlora.Channels.LocalAudio,
input_device: input_id,
output_device: output_id})input_id and output_id must come from device discovery; the normal command
uses defaults and requires no IDs. To change provider, supply a provider: tuple
as described in the provider guides.
If startup fails
- A missing
OPENAI_API_KEYfails before opening a session. Set the key in the same shell that runs Mix; this script reads it at runtime. - If native compilation fails, check the platform prerequisites
and confirm
native_opus: falseis in the host configuration read at compile time. - If microphone access or device opening fails, check OS permissions and device discovery. Local audio on Linux needs a service compatible with PulseAudio.
- If the provider rejects startup, check model access and the credential. The example handles failure/closure; do not treat a returned session PID as Ready.
Once this works, use Getting started for session controls, application context, browser/phone channels, and session ownership in applications that stay running.