Nodal-Agents
Guides

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 build

Copy 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:

  1. 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.
  2. Nodal answers a 6-digit code. The device shows it, or says it when it has no screen.
  3. The device waits in pairing.done().

From the owner's side:

  1. Open Settings → Devices in the dashboard. A request waits there, with its code and its name.
  2. 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

speaker.mjs
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 turn

play, 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 Tokyo

It 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

CallWhat 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):

codeWhen
refusedThe owner refused the pairing
expiredThe code expired before the owner confirmed it
revokedNodal refused the device's key (device_token_invalid, …): the device was removed, pair again
request_failedNodal answered an error: its status and serverCode, for example voice_key_missing when the workspace has no speech key
turn_failedA turn failed after it started (serverCode)
networkNodal could not be reached
stream_cutAn answer or a stream stopped before its end
abortedYour own signal aborted the call
protocolNodal answered something the client cannot read
closedThe 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.

EventWhenWhat it carries
bubbleA task launched from the device answered, or a message was sent to ittext, jobId, conversationId
cardA task asks the person: approve an action, or choose an optioncardId, kind (approval or question), text, choices
card.closedThe card was settled: here, on the dashboard, or by expirycardId, text (what the card says now), source (stream, or reread with text: null)
stateWhere a task launched from the device standsjobId, 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
announceA task launched by voice ended and its announcement is spokenjobId, audioId. Its voice follows as speech
speechThe agent's voice: the reply to say, or an announcementaudio pieces in seq order, sentence.end, then end
readyThe stream is live, after anything missed was replayedreconnected
resyncNodal could not replay what was missed; the waiting cards follow as cardreason
disconnectedThe stream dropped; the client retries by itselferror, retryInMs
closedThe session ended for gooderror (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.closed with source: 'reread' and text: 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.

GestureRequest
pairPOST /api/devices/pair, then POST /api/devices/pair/poll every intervalSeconds
conversationPOST /api/devices/:deviceId/conversation gives entityId, agentId and conversationId
listenGET /api/devices/:deviceId/events (Server-Sent Events, Last-Event-ID to resume)
cardsGET /api/devices/:deviceId/cards: the cards still waiting, read after a resync
sayPOST /api/voice/turns, the audio in POST /api/voice/turns/:turnId/chunks/:seq, then POST /api/voice/turns/:turnId/end (an event stream)
sendPOST /api/chat/stream
answerPOST /api/approve with approvalRequestId (the cardId), decision and, for a question, answer
cancelDELETE /api/voice/turns/:turnId during the upload, POST /api/chat/stop after
hearGET /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 Host header 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:404

    The 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 card is read aloud: its text, then its choices by label. The person answers by voice. Nodal does not resolve a spoken answer yet (#1018): answer() takes one of the card's choices, so the device recognises the speech itself, matches it to a label, and calls answer() 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;
  • bubble texts can be skipped: the reply to a spoken turn already comes as speech, and a task launched by voice ends with an announce.

On this page