Connect a device
Pair a speaker, a desktop companion or any piece of software with Nodal, and talk with your workspace from it.
A device is anything that talks with your Nodal workspace and is not the dashboard: a speaker, a desktop companion, a small board, a piece of software. Nodal knows no device by name. The device pairs from its own side, the owner confirms it, and from then on the device speaks, types and answers in its own conversation with the workspace's root agent. What a task it launched asks back (a question, an approval, the spoken announcement at the end) comes back to that device.
This page is for the person who writes the device side. You need no Nodal
code: one file, nodal-device.mjs, with no dependency.
Get the client
Build it once from a Nodal checkout:
pnpm --filter @nodal-agents/device-client buildCopy packages/device-client/dist/nodal-device.mjs (and nodal-device.d.ts
for the types) into your project. It runs in Node 20+, in Electron (main and
renderer) and in browsers.
Pair the device
From the device's side:
- The device calls
pair()with its name and what it can do: any of'microphone','speaker','screen'and'buttons'. The owner sees them in Settings → Devices. - Nodal answers a 6-digit code. The device shows it, or says it when it has no screen.
- The device waits in
pairing.done().
From the owner's side:
- Open Settings → Devices in the dashboard. A request waits there, with its code and its name.
- Check that the code is the one the device shows or says, then Confirm and pair. Or Refuse.
pairing.done() then resolves with the device's session: its id, its
token (ndv_…) and its conversation. Keep the session: a device that restarts
calls connect() with it and never pairs again. The token is a secret; store it
like a password. The code is valid 10 minutes; after that done() throws
expired, and a refused request throws refused.
Removing the device in Settings → Devices revokes its token at once: its
open stream closes and its next request is refused. The client then ends the
session with a revoked error, and never retries.
The client in 20 lines
import { pair, connect } from './nodal-device.mjs';
const url = 'http://127.0.0.1:3001';
const pairing = await pair(url, {
name: 'Living room speaker',
capabilities: ['microphone', 'speaker'],
});
console.log(`Pairing code: ${pairing.userCode}`); // show it, or say it
const session = await pairing.done(); // keep it to reconnect later
const nodal = connect(url, session);
nodal.on('speech', (s) => s.type === 'audio' && play(s.audio, s.mimeType, s.sampleRate));
nodal.on('bubble', (b) => console.log(b.text));
nodal.on('card', async (card) => {
const choice = await askPerson(card.text, card.choices); // show or say it; null: not now
if (choice) await nodal.answer(card.cardId, choice); // only ever the person's choice
});
nodal.on('closed', ({ error }) => console.error(error.message));
const turn = await nodal.say(recordedWav, 'audio/wav'); // push-to-talk released
console.log(turn.transcript, '→', turn.reply);
await nodal.send('And tomorrow?'); // a typed turnplay, recordedWav and askPerson are yours: Nodal does not touch your
screen or your audio hardware. A card is answered only with the person's
choice: show it, or say it, and answer with what the person chose. Never
answer with a default: an approval is the person's decision.
A complete, runnable version is examples/device/reference-client.mjs in the
repository:
node examples/device/reference-client.mjs http://127.0.0.1:3001 question.wav reply.audio TokyoIt pairs, says a recorded WAV file, writes the reply's voice to a file, and
prints the first card it receives. It answers that card with the choice given
on the command line (Tokyo here) when the card offers it, and otherwise
leaves it for the dashboard. CI runs it against a real runner on every change.
The API
| Call | What it does |
|---|---|
pair(url, { name, capabilities }) | Asks to pair. Returns { userCode, expiresInSeconds, done() }. |
pairing.done() | Waits for the owner. Resolves { deviceId, token, conversationId }, or throws refused / expired. |
connect(url, session) | Opens the device's event stream and returns nodal. |
nodal.on(event, handler) | Listens to an event below. Returns a function that stops listening. |
nodal.say(audio, 'audio/wav') | One spoken turn. audio/pcm needs { sampleRate } (16-bit signed little-endian, mono). Resolves { transcript, reply, spawnedJobId, stopped, speechError }; transcript is null when nothing was heard. |
nodal.send(text) | One typed turn. Resolves like say. |
nodal.answer(cardId, choice) | Answers a card with one of its choices. |
nodal.cancel() | Stops the current turn, spoken or typed. |
nodal.newConversation() | Starts a blank conversation, like /new on a channel. |
nodal.close() | Closes the connection. |
Every failure of the client is a NodalDeviceError with a code, the
network's included (the original error is its cause):
code | When |
|---|---|
refused | The owner refused the pairing |
expired | The code expired before the owner confirmed it |
revoked | Nodal refused the device's key (device_token_invalid, …): the device was removed, pair again |
request_failed | Nodal answered an error: its status and serverCode, for example voice_key_missing when the workspace has no speech key |
turn_failed | A turn failed after it started (serverCode) |
network | Nodal could not be reached |
stream_cut | An answer or a stream stopped before its end |
aborted | Your own signal aborted the call |
protocol | Nodal answered something the client cannot read |
closed | The session was closed by close() while the call ran |
Events
What Nodal sends to the device. A device only ever hears what its own requests produce and what is addressed to it.
| Event | When | What it carries |
|---|---|---|
bubble | A task launched from the device answered, or a message was sent to it | text, jobId, conversationId |
card | A task asks the person: approve an action, or choose an option | cardId, kind (approval or question), text, choices |
card.closed | The card was settled: here, on the dashboard, or by expiry | cardId, text (what the card says now), source (stream, or reread with text: null) |
state | Where a task launched from the device stands | jobId, state (working, waiting, done, cancelled, failed), waitingFor (the cards it waits on), deliverables (the files it addressed), reason, seq. A state with a seq not above the last one shown for its job is older and is dropped by the client; after a resync, or a stream opened without a resume id, the count starts again |
announce | A task launched by voice ended and its announcement is spoken | jobId, audioId. Its voice follows as speech |
speech | The agent's voice: the reply to say, or an announcement | audio pieces in seq order, sentence.end, then end |
ready | The stream is live, after anything missed was replayed | reconnected |
resync | Nodal could not replay what was missed; the waiting cards follow as card | reason |
disconnected | The stream dropped; the client retries by itself | error, retryInMs |
closed | The session ended for good | error (revoked, or closed by you) |
A card's text is the platform's fixed frame with the agent's words quoted in
it, the same as on Telegram. Play speech pieces end to end: audio/pcm plays
as it comes; any other type (audio/wav, audio/mpeg) decodes per whole
sentence, at its sentence.end, and is dropped when complete is false.
Reconnecting, and never losing a card
Every event carries a resume id. After a network drop, or a stream that stays
silent past its 25-second ping, the client reconnects by itself with a backoff
(0.5 s doubling to 30 s) and sends the last id it saw. Nodal replays what the
device missed, a card included, then says ready. A bubble can arrive
twice around a reconnect (bubbleId identifies it); a card, its card.closed
and a state never do.
When Nodal cannot replay (the runner restarted, or the device was away too long)
it says resync instead. The client then re-reads the cards still waiting
(GET /api/devices/:deviceId/cards) and reconciles them with the cards it has
delivered:
- a card it delivered that is no longer waiting was settled while the device
was away (on the dashboard, or by expiry): you get its
card.closedwithsource: 'reread'andtext: null, since how it was settled is not known. Stop showing or saying it; - a card still waiting that you already have is not delivered again;
- a card you did not have is delivered to
card.
A card is delivered once while it is open, and never again once settled, by a replay or a re-read. A bubble or a state missed is not given back: a device that shows a history re-reads it from its conversation.
The protocol under the client
The client wraps these gestures. A device in another language makes the same
requests, with Authorization: Bearer <token> on every one after pairing.
| Gesture | Request |
|---|---|
| pair | POST /api/devices/pair, then POST /api/devices/pair/poll every intervalSeconds |
| conversation | POST /api/devices/:deviceId/conversation gives entityId, agentId and conversationId |
| listen | GET /api/devices/:deviceId/events (Server-Sent Events, Last-Event-ID to resume) |
| cards | GET /api/devices/:deviceId/cards: the cards still waiting, read after a resync |
| say | POST /api/voice/turns, the audio in POST /api/voice/turns/:turnId/chunks/:seq, then POST /api/voice/turns/:turnId/end (an event stream) |
| send | POST /api/chat/stream |
| answer | POST /api/approve with approvalRequestId (the cardId), decision and, for a question, answer |
| cancel | DELETE /api/voice/turns/:turnId during the upload, POST /api/chat/stop after |
| hear | GET /api/voice/replies/:audioId?entityId=… (an event stream) |
Read the event stream with fetch, not EventSource: EventSource cannot send
the Authorization header.
Network access
A device reaches the runner (port 3001 by default; Settings → Devices shows the address that works in your current network mode).
Same machine
Nothing to change. The device uses http://127.0.0.1:3001.
Home network
Turn on network access: Settings → Access → Network access → LAN, then
restart (see Self-hosting). This also turns on
sign-in. The device then uses http://<the computer's local IP>:3001, for
example http://192.168.1.20:3001.
From outside your home
Never open a port on your router. Go through a tunnel, and keep network access on so that sign-in stays on:
-
Cloudflare Tunnel. Point a tunnel at the runner and rewrite the
Hostheader to the local address, which is the only name the runner answers to through a tunnel:~/.cloudflared/config.yml ingress: - hostname: nodal-device.example.com service: http://localhost:3001 originRequest: httpHostHeader: localhost:3001 - service: http_status:404The pairing request needs no token, so anyone who knows the tunnel's address can make one appear in Settings → Devices: only ever confirm a code you see or hear on your own device.
-
Tailscale. Not supported yet: the runner refuses requests addressed to a Tailscale name or address (
host_not_allowed), until the owner can name the hostnames Nodal answers to (#1008).
A device without a screen
Nothing in the protocol needs a screen:
- the device says its pairing code, and the owner types it in Settings → Devices;
- a
cardis read aloud: itstext, then itschoicesbylabel. The person answers by voice. Nodal does not resolve a spoken answer yet (#1018):answer()takes one of the card'schoices, so the device recognises the speech itself, matches it to alabel, and callsanswer()with that choice. A spoken answer that matches nothing is asked again, never guessed, and a card the person does not answer stays waiting in Approvals on the dashboard; bubbletexts can be skipped: the reply to a spoken turn already comes asspeech, and a task launched by voice ends with anannounce.