> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Node device client

> The @lua-ai-global/device-client package: DeviceClient options, methods, events, MqttTransport, CDN, and the lua-device runner

`@lua-ai-global/device-client` connects a Node process to an agent as a [device](/concepts/devices) over Socket.IO or MQTT, runs command handlers, sends triggers, and uploads files. Version 1.1.0; Node 18 or later.

*Verified against @lua-ai-global/device-client 1.1.0.*

```bash theme={null}
npm install @lua-ai-global/device-client
```

```ts theme={null}
import { DeviceClient } from '@lua-ai-global/device-client';
```

## Quick example

```ts device.ts theme={null}
import { DeviceClient } from '@lua-ai-global/device-client';

const device = new DeviceClient({
  agentId: process.env.LUA_AGENT_ID!,
  deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!,
  deviceName: 'bench-sensor',
  commands: [{ name: 'read_temperature', description: 'Read the bench temperature in celsius' }],
});

device.onCommand('read_temperature', async () => ({ temperature: 22.5 }));

async function main() {
  await device.connect();
  await device.trigger('sensor_ready', { firmware: '1.4.2' });
}

main().catch((err: Error) => console.error(err.message));
```

`trigger()` needs the `triggers` operation on the credential: without it the platform answers with a `trigger_error` event, `OPERATION_FORBIDDEN`, and the call rejects after the 10-second acknowledgment wait.

## DeviceClient

`new DeviceClient(config)` creates a client; it doesn't connect until `connect()` is called. The constructor throws `deviceCredential or apiKey is required` when neither credential field is set and `deviceCredential and apiKey disagree` when both are set to different values.

<ParamField path="agentId" type="string" required>
  The agent the device connects to. Must equal the agent in the credential's binding.
</ParamField>

<ParamField path="deviceName" type="string" required>
  The device name. Must equal the name in the credential's binding; hyphens become underscores in tool names.
</ParamField>

<ParamField path="deviceCredential" type="string">
  The [device credential](/devices/credentials). Required unless `apiKey` is set.
</ParamField>

<ParamField path="apiKey" type="string" deprecated>
  A legacy API key. Still accepted; use `deviceCredential` for new devices.
</ParamField>

<ParamField path="commands" type="DeviceCommandDefinition[]" default="[]">
  Commands sent at connect time; each becomes a tool. Omit when the device is declared with `defineDevice`.
</ParamField>

<ParamField path="transport" type="'socketio' | 'mqtt'" default="socketio">
  `mqtt` routes everything through `MqttTransport`.
</ParamField>

<ParamField path="serverUrl" type="string" default="https://api.heylua.ai">
  Socket.IO server. The client connects to the `/devices` namespace over WebSocket only.
</ParamField>

<ParamField path="mqttUrl" type="string" default="wss://mqtt.heylua.ai/mqtt">
  Broker URL for the MQTT transport.
</ParamField>

<ParamField path="cdnUrl" type="string" default="https://cdn.heylua.ai">
  Base URL for `cdn`.
</ParamField>

<ParamField path="group" type="string">
  Group name reported at connect; enables group fan-out for devices declared with `defineDevice`.
</ParamField>

<ParamField path="deviceKind" type="string" default="device-node">
  Client family reported in the Socket.IO handshake for diagnostics. Never used for authorization.
</ParamField>

<ParamField path="clientVersion" type="string" default="1.1.0">
  Your application's version, reported for diagnostics alongside the package's `device-node` family.
</ParamField>

**`DeviceCommandDefinition`**

<ParamField path="name" type="string" required>
  Command name; must match `^[a-z][a-z0-9_]{0,63}$` or the platform drops it.
</ParamField>

<ParamField path="description" type="string" required>
  The tool description the model reads. Cut at 500 characters.
</ParamField>

<ParamField path="inputSchema" type="Record<string, any>">
  JSON Schema for the payload; at most 4 KB when serialized.
</ParamField>

<ParamField path="timeoutMs" type="number" default={30000}>
  How long the platform waits for the response.
</ParamField>

<ParamField path="retry" type="{ maxAttempts: number; backoffMs: number }">
  Platform-side retries after a timeout or transport failure, waiting `backoffMs × attempt` between attempts. Both fields are typed as required; on the wire a missing `maxAttempts` counts as 1 attempt and a missing `backoffMs` as 1,000 ms.
</ParamField>

## Methods

### connect()

Opens the connection and resolves once the platform has accepted the credential.

```ts theme={null}
device.connect(): Promise<void>
```

**Returns** — resolves after the `connected` event.

**Errors** — rejects with an error whose `code` is `AUTH_FAILED` or `MISSING_AUTH` when the platform refuses the credential; reconnection is disabled for the client's lifetime. Over MQTT it rejects with the broker's `Not authorized` error. Other failures are retried, not thrown.

### onCommand()

Registers the handler for one command name. Registering the same name again replaces the handler.

```ts theme={null}
device.onCommand(name: string, handler: (payload: any) => Promise<any>): void
```

<ParamField path="name" type="string" required>Command name, as declared in `commands` or in `defineDevice`.</ParamField>
<ParamField path="handler" type="CommandHandler" required>Receives the payload; its resolved value is sent as `data`.</ParamField>

**Returns** — nothing. The handler's result is sent as `{ commandId, success: true, data }`; a thrown error is sent as `{ commandId, success: false, error: message }`; an unregistered command answers `Unknown command: <name>`.

### trigger()

Sends a trigger and waits for the platform's acknowledgment. Triggers are sent one at a time in call order.

```ts theme={null}
device.trigger(name: string, payload: any): Promise<DeviceTriggerAckMessage>
```

<ParamField path="name" type="string" required>Trigger name, matched exactly against the handlers on the agent.</ParamField>
<ParamField path="payload" type="any" required>JSON payload; at most 1 MB serialized.</ParamField>

**Returns** — `{ triggerId, received }`; `received` is `true` when the event was queued.

**Errors** — `Not connected to device gateway` (or `Not connected to MQTT broker`) before `connect()` resolves; `Trigger '<name>' ACK timeout (10s)` when no acknowledgment arrives within 10 seconds. Over Socket.IO a trigger the platform rejects arrives as a `trigger_error` event, not as a rejection; the MQTT transport doesn't subscribe to `trigger_error`, so a rejected trigger times out instead.

### onTriggerResult()

Registers a handler for a `trigger_result` message. The platform doesn't send that message, so the handler never runs.

```ts theme={null}
device.onTriggerResult(name: string, handler: (result: any) => void): void
```

### disconnect()

Closes the connection and stops reconnection. Over MQTT it first publishes a retained `offline` status.

```ts theme={null}
device.disconnect(): Promise<void>
```

### isConnected()

```ts theme={null}
device.isConnected(): boolean
```

**Returns** — `true` between `connected` and the next `disconnected`.

## Events

`DeviceClient` extends `EventEmitter`.

| Event | Argument | When |
| - | - | - |
| `connected` | — | The first successful handshake |
| `reconnected` | — | Every later successful handshake |
| `disconnected` | `reason: string` | The connection closed; reconnection starts unless terminal |
| `error` | `{ code, message, retryable?, retryAfterMs? }` | The platform reported an error on the connection |
| `trigger_ack` | `{ triggerId, received, error? }` | A trigger was acknowledged |
| `trigger_error` | `{ code, message }` | A trigger was rejected (Socket.IO only) |

## Connection behavior

* Heartbeat every 30 seconds while connected.
* Socket.IO reconnects with a jittered delay from 1 to 30 seconds, without limit; after a disconnect the platform initiated, for example `AGENT_FORBIDDEN` or `DEVICE_DISABLED`, it retries every second, without limit. `AUTH_FAILED` and `MISSING_AUTH` stop reconnection for good. `RATE_LIMITED` pauses for the platform's `retryAfterMs`, clamped between 1 second and 15 minutes, or 30 seconds when none is given.
* MQTT reconnects every 5 seconds and uses a persistent session, so commands published while the client was away are delivered on reconnect.
* While a self-describing device is disconnected its command tools are removed from the agent on the next turn and return with the next handshake. The platform keeps the connect-time command list for 24 hours only, so a device connected longer than that loses its command tools until you call `disconnect()` and then `connect()`; the client has no timer for this.
* Command IDs are cached for 5 minutes (1,000 entries); a redelivered command gets the cached response without running the handler.
* `SIGTERM` and `SIGINT` call `disconnect()` and log `[lua-device] Shutting down device '<name>'...`; the process exits when nothing else keeps it alive.

## MqttTransport

The MQTT implementation `DeviceClient` uses when `transport` is `'mqtt'`; usable on its own with the same `DeviceClientConfig`.

```ts theme={null}
import { MqttTransport } from '@lua-ai-global/device-client';

const transport = new MqttTransport({
  agentId: process.env.LUA_AGENT_ID!,
  deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!,
  deviceName: 'bench-sensor',
});
transport.connect().catch((err: Error) => console.error(err.message));
```

It connects with client ID `lua-<agentId>-<deviceName>`, username `<agentId>:<deviceName>`, the credential as password, keep-alive 60 seconds, a persistent session, and a retained last-will `offline` status. Methods `connect()`, `onCommand()`, `trigger()`, `onTriggerResult()`, `disconnect()`, and `isConnected()` behave as on `DeviceClient`; it emits the same events except `trigger_error`, whose topic it doesn't subscribe to. Topics are listed in the [MQTT protocol](/devices/mqtt-protocol).

## CDN

`device.cdn` is a `CDN` instance authenticated with the device's credential, which must grant `assets.upload`.

### upload()

```ts theme={null}
device.cdn.upload(data: Buffer | Blob, filename: string, contentType?: string): Promise<CdnUploadResult>
```

<ParamField path="data" type="Buffer | Blob" required>File contents; at most 100 MB.</ParamField>
<ParamField path="filename" type="string" required>Name sent with the multipart upload.</ParamField>
<ParamField path="contentType" type="string" default="application/octet-stream">MIME type for a `Buffer`; a `Blob` keeps its own type.</ParamField>

**Returns** — `{ fileId, mediaType, extension, url }`; `url` is `<cdnUrl>/<fileId>`.

**Errors** — `Error` with the platform's message, or `CDN upload failed: <status>`.

### getUrl()

```ts theme={null}
device.cdn.getUrl(fileId: string): string
```

### download()

```ts theme={null}
device.cdn.download(fileId: string): Promise<Buffer>
```

**Errors** — `CDN download failed: <status>`.

## lua-device runner

The package installs a `lua-device` binary that connects a device without a script. It sends no command list, so pair it with a `defineDevice` declaration pushed to the agent.

```bash theme={null}
lua-device connect --agent-id <agent-id> --device-credential <secret> --device-name <name> --handler ./handlers.cjs
```

| Option | Description | Default |
| - | - | - |
| `--agent-id <id>` | Agent ID. Required. | — |
| `--device-credential <key>` | Device credential. One of this or `--api-key` is required. | — |
| `--api-key <key>` | Legacy API key. | — |
| `--device-name <name>` | Device name. Required. | — |
| `--server-url <url>` | Socket.IO server. | `https://api.heylua.ai` |
| `--group <name>` | Group name. | — |
| `--handler <path>` | CommonJS module exporting `{ commands: { <name>: async (payload) => result } }`. | — |

It logs `Registered command handler: <name>` per handler, `Connecting to <url>...`, then `Connected as '<name>' to agent '<id>'` and `Listening for commands... (Ctrl+C to stop)`. A refused connection logs `Connection failed: <message>` and exits `1`.

```js handlers.cjs theme={null}
module.exports = {
  commands: {
    paper_level: async () => ({ percent: 63 }),
  },
};
```

## Types

All exported from the package.

<ResponseField name="DeviceClientConfig" type="interface">The constructor options listed under DeviceClient.</ResponseField>
<ResponseField name="DeviceCommandDefinition" type="interface">`{ name; description; inputSchema?; timeoutMs?; retry? }`.</ResponseField>
<ResponseField name="DeviceCommandMessage" type="interface">`{ commandId; command; payload; timeout? }` as delivered to the device. `timeout` is not sent by the platform.</ResponseField>
<ResponseField name="DeviceResponseMessage" type="interface">`{ commandId; success; data?; error? }` as sent back.</ResponseField>
<ResponseField name="DeviceTriggerAckMessage" type="interface">`{ triggerId; received; error? }`.</ResponseField>
<ResponseField name="CdnUploadResult" type="interface">`{ fileId; mediaType; extension; url }`.</ResponseField>
<ResponseField name="CommandHandler" type="type">`(payload: any) => Promise<any>`.</ResponseField>
<ResponseField name="TriggerResultHandler" type="type">`(result: any) => void`.</ResponseField>

## See also

* [Python device client](/devices/python-client) — the MQTT-only asyncio client
* [MQTT protocol](/devices/mqtt-protocol) — topics and payloads the MQTT transport uses
* [Expose device commands as tools](/devices/commands-and-tools)
* [Reliability and limits](/devices/reliability-and-limits)
* [`defineDevice`](/reference/sdk/device-definition) — the agent-side declaration the runner needs


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.