{"id":34899293,"url":"https://github.com/bcomnes/yoto-nodejs-client","last_synced_at":"2026-01-13T20:58:25.187Z","repository":{"id":329828479,"uuid":"1116490825","full_name":"bcomnes/yoto-nodejs-client","owner":"bcomnes","description":"A comprehensive Node.js client for the Yoto API with automatic token refresh, MQTT device communication, and full TypeScript support.","archived":false,"fork":false,"pushed_at":"2026-01-03T22:48:24.000Z","size":382,"stargazers_count":5,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-01-03T23:19:31.211Z","etag":null,"topics":["nodejs","yoto"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/yoto-nodejs-client","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bcomnes.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/funding.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null},"funding":{"github":["bcomnes"],"custom":["https://bret.io"]}},"created_at":"2025-12-15T00:21:40.000Z","updated_at":"2026-01-03T17:48:22.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/bcomnes/yoto-nodejs-client","commit_stats":null,"previous_names":["bcomnes/yoto-nodejs-client"],"tags_count":11,"template":false,"template_full_name":null,"purl":"pkg:github/bcomnes/yoto-nodejs-client","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bcomnes%2Fyoto-nodejs-client","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bcomnes%2Fyoto-nodejs-client/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bcomnes%2Fyoto-nodejs-client/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bcomnes%2Fyoto-nodejs-client/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bcomnes","download_url":"https://codeload.github.com/bcomnes/yoto-nodejs-client/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bcomnes%2Fyoto-nodejs-client/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28400344,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-13T14:36:09.778Z","status":"ssl_error","status_checked_at":"2026-01-13T14:35:19.697Z","response_time":56,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["nodejs","yoto"],"created_at":"2025-12-26T08:26:07.832Z","updated_at":"2026-01-13T20:58:25.181Z","avatar_url":"https://github.com/bcomnes.png","language":"JavaScript","funding_links":["https://github.com/sponsors/bcomnes","https://bret.io"],"categories":[],"sub_categories":[],"readme":"# yoto-nodejs-client\n[![latest version](https://img.shields.io/npm/v/yoto-nodejs-client.svg)](https://www.npmjs.com/package/yoto-nodejs-client)\n[![Actions Status](https://github.com/bcomnes/yoto-nodejs-client/workflows/tests/badge.svg)](https://github.com/bcomnes/yoto-nodejs-client/actions)\n\n[![downloads](https://img.shields.io/npm/dm/yoto-nodejs-client.svg)](https://npmtrends.com/yoto-nodejs-client)\n![Types in JS](https://img.shields.io/badge/types_in_js-yes-brightgreen)\n[![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-7fffff?style=flat\u0026labelColor=ff80ff)](https://github.com/neostandard/neostandard)\n[![Socket Badge](https://socket.dev/api/badge/npm/package/yoto-nodejs-client)](https://socket.dev/npm/package/yoto-nodejs-client)\n\nA comprehensive Node.js client for the [Yoto API][yoto-api] with automatic token refresh, MQTT device communication, stateful device management, and full TypeScript support.\n\n**Features:**\n- **YotoClient** - Low-level HTTP API client with automatic token refresh\n- **YotoDeviceModel** - Stateful device client combining HTTP + MQTT for unified, real time device state\n- **YotoAccount** - Multi-device account manager with automatic discovery and lifecycle management\n- Full TypeScript types\n- Real-time MQTT device control and monitoring\n- Debugging CLI tools for authentication and data inspection\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"yoto.png\" alt=\"Yoto\" width=\"200\"\u003e\n\u003c/p\u003e\n\n```console\nnpm install yoto-nodejs-client\n```\n\n## Usage\n\n### Basic API Client\n\n```js\nimport { YotoClient } from 'yoto-nodejs-client'\n\n// Authenticate using device flow (CLI/server applications)\nconst deviceCodeResponse = await YotoClient.requestDeviceCode({\n  clientId: 'your-client-id'\n})\n\nconsole.log(`Visit ${deviceCodeResponse.verification_uri_complete}`)\nconsole.log(`Enter code: ${deviceCodeResponse.user_code}`)\n\n// Wait for authorization (simplest approach - handles polling automatically)\nconst tokenResponse = await YotoClient.waitForDeviceAuthorization({\n  deviceCode: deviceCodeResponse.device_code,\n  clientId: 'your-client-id',\n  initialInterval: deviceCodeResponse.interval * 1000,\n  expiresIn: deviceCodeResponse.expires_in,\n  onPoll: (result) =\u003e {\n    if (result.status === 'pending') process.stdout.write('.')\n    if (result.status === 'slow_down') console.log('\\nSlowing down...')\n  }\n})\n\n// Create client with automatic token refresh\nconst client = new YotoClient({\n  clientId: 'your-client-id',\n  refreshToken: tokenResponse.refresh_token,\n  accessToken: tokenResponse.access_token,\n  onTokenRefresh: async (event) =\u003e {\n    // REQUIRED: Persist tokens when they refresh\n    await saveTokens({\n      accessToken: event.updatedAccessToken,\n      refreshToken: event.updatedRefreshToken,\n      expiresAt: event.updatedExpiresAt\n    })\n  }\n})\n\n// Get devices\nconst { devices } = await client.getDevices()\nconsole.log('Your devices:', devices)\n\n// Get device status\nconst status = await client.getDeviceStatus({ \n  deviceId: devices[0].deviceId \n})\nconsole.log('Battery:', status.batteryLevelPercentage, '%')\n\n// Get user's MYO content\nconst myoContent = await client.getUserMyoContent()\nconsole.log('Your content:', myoContent)\n\n// Connect to device via MQTT for real-time control\nconst mqtt = await client.createMqttClient({\n  deviceId: devices[0].deviceId\n})\n\nmqtt.on('events', (message) =\u003e {\n  console.log('Playing:', message.trackTitle)\n})\n\nmqtt.on('status', (message) =\u003e {\n  console.log('Volume:', message.volume, '%')\n})\n\nawait mqtt.connect()\nawait mqtt.setVolume(50)\nawait mqtt.setAmbientHex('#FF0000')\n```\n\n### YotoDeviceModel - Stateful Device Client\n\n```js\nimport { YotoClient, YotoDeviceModel } from 'yoto-nodejs-client'\n\n// Create API client\nconst client = new YotoClient({\n  clientId: 'your-client-id',\n  refreshToken: 'your-refresh-token',\n  accessToken: 'your-access-token',\n  onTokenRefresh: async (event) =\u003e {\n    await saveTokens(event)\n  }\n})\n\n// Get a device\nconst { devices } = await client.getDevices()\nconst device = devices[0]\n\n// Create stateful device client (manages HTTP + MQTT state)\nconst deviceClient = new YotoDeviceModel(client, device, {\n  httpPollIntervalMs: 600000  // Background polling every 10 minutes\n})\n\n// Listen for status updates (from MQTT or HTTP)\ndeviceClient.on('statusUpdate', (status, source, changedFields) =\u003e {\n  console.log(`Battery: ${status.batteryLevelPercentage}% (via ${source})`)\n  console.log(`Temperature: ${status.temperatureCelsius}°C`)\n  console.log(`Online: ${status.isOnline}`)\n  console.log('Changed fields:', changedFields)\n})\n\n// Listen for config changes\ndeviceClient.on('configUpdate', (config, changedFields) =\u003e {\n  console.log('Config updated:', config.maxVolumeLimit)\n  console.log('Changed fields:', changedFields)\n})\n\n// Listen for playback events\ndeviceClient.on('playbackUpdate', (playback, changedFields) =\u003e {\n  console.log(`Playing: ${playback.trackTitle}`)\n  console.log(`Position: ${playback.position}/${playback.trackLength}s`)\n  console.log('Changed fields:', changedFields)\n})\n\n// Listen for online/offline events\ndeviceClient.on('online', (metadata) =\u003e {\n  if (metadata.reason === 'startup') {\n    console.log(`Device powered on (uptime: ${metadata.upTime}s)`)\n  } else {\n    console.log('Device came online')\n  }\n})\n\ndeviceClient.on('offline', (metadata) =\u003e {\n  if (metadata.reason === 'shutdown') {\n    console.log(`Device shut down: ${metadata.shutDownReason}`)\n  }\n})\n\n// Start the device client (connects MQTT, starts background polling)\nawait deviceClient.start()\n\n// Access current state\nconsole.log('Current status:', deviceClient.status)\nconsole.log('Current config:', deviceClient.config)\nconsole.log('Current playback:', deviceClient.playback)\nconsole.log('Device capabilities:', deviceClient.capabilities)\n\n// Control the device\nawait deviceClient.updateConfig({ maxVolumeLimit: 14 })\nawait deviceClient.updateConfig({\n  dayDisplayBrightnessAuto: false,\n  dayDisplayBrightness: 80\n})\nawait deviceClient.updateConfig({\n  nightYotoRadioEnabled: true,\n  nightYotoRadio: 'favourites'\n})\nawait deviceClient.sendCommand({ volume: 50 })\n\n// Stop when done\nawait deviceClient.stop()\n```\n\n### Account Manager (Multiple Devices)\n\n```js\nimport { YotoAccount } from 'yoto-nodejs-client'\n\n// Create account manager\nconst account = new YotoAccount({\n  clientOptions: {\n    clientId: 'your-client-id',\n    refreshToken: 'your-refresh-token',\n    accessToken: 'your-access-token',\n    onTokenRefresh: async (event) =\u003e {\n      await saveTokens(event)\n    }\n  },\n  deviceOptions: {\n    httpPollIntervalMs: 600000  // Applied to all devices\n  }\n})\n\n// Listen for account events\naccount.on('started', (metadata) =\u003e {\n  console.log(`Managing ${metadata.deviceCount} devices`)\n})\n\n\n\n// Listen for device events across all devices via the unified bus\naccount.on('statusUpdate', ({ deviceId, status, source }) =\u003e {\n  console.log(`${deviceId} battery: ${status.batteryLevelPercentage}% (${source})`)\n})\n\naccount.on('online', ({ deviceId }) =\u003e {\n  console.log(`${deviceId} came online`)\n})\n\naccount.on('offline', ({ deviceId }) =\u003e {\n  console.log(`${deviceId} went offline`)\n})\n\n// Unified error handling\naccount.on('error', ({ error, context }) =\u003e {\n  console.error(`Error in ${context.source}:`, error.message)\n  if (context.deviceId) {\n    console.error(`Device: ${context.deviceId}`)\n  }\n})\n\n// Start managing all devices\nawait account.start()\n\n// Access individual devices\nconst device = account.getDevice('abc123')\nconsole.log('Device status:', device.status)\nconsole.log('Device config:', device.config)\n\n// Get all devices\nconst allDevices = account.devices  // Map\u003cdeviceId, YotoDeviceModel\u003e\nconsole.log('Total devices:', allDevices.size)\n\n// Refresh device list (add new, remove missing)\nawait account.refreshDevices()\n\n// Stop all devices\nawait account.stop()\n```\n\n## API\n\n### Authentication\n\n#### `YotoClient.requestDeviceCode({ clientId, [scope], [audience] })`\n\nStart the OAuth2 Device Authorization flow for CLI/server applications. Returns a device code and user verification URL.\n\n- **clientId** - Your OAuth client ID\n- **scope** - OAuth scopes (default: `'openid profile offline_access'`)\n- **audience** - Token audience (default: `'https://api.yotoplay.com'`)\n\nSee [Yoto API: Device Code][api-device-code]\n\n```js\nconst response = await YotoClient.requestDeviceCode({\n  clientId: 'your-client-id'\n})\n\nconsole.log(`Visit: ${response.verification_uri_complete}`)\nconsole.log(`Or go to ${response.verification_uri} and enter: ${response.user_code}`)\n```\n\n#### `YotoClient.exchangeToken({ grantType, ...params })`\n\nExchange authorization code, refresh token, or device code for access tokens.\n\n- **grantType** - `'authorization_code'`, `'refresh_token'`, or `'urn:ietf:params:oauth:grant-type:device_code'`\n- **code** - Authorization code (for `authorization_code` grant)\n- **refreshToken** - Refresh token (for `refresh_token` grant)\n- **deviceCode** - Device code (for device code grant)\n- **clientId** - OAuth client ID\n- **redirectUri** - Redirect URI (for `authorization_code` grant)\n- **codeVerifier** - PKCE code verifier (optional)\n\nSee [Yoto API: Token Exchange][api-token]\n\n```js\nimport { YotoClient, DEVICE_CODE_GRANT_TYPE } from 'yoto-nodejs-client'\n\n// Exchange device code\nconst tokens = await YotoClient.exchangeToken({\n  grantType: DEVICE_CODE_GRANT_TYPE,\n  deviceCode: response.device_code,\n  clientId: 'your-client-id'\n})\n\n// Refresh token\nconst refreshed = await YotoClient.exchangeToken({\n  grantType: 'refresh_token',\n  refreshToken: tokens.refresh_token,\n  clientId: 'your-client-id'\n})\n```\n\n#### `YotoClient.waitForDeviceAuthorization({ deviceCode, clientId, [initialInterval], [expiresIn], [onPoll] })`\n\nWait for device authorization to complete with automatic polling. This is the **simplest approach** - just call it and await the result. It handles all polling logic internally including interval adjustments and timeout detection.\n\nDesigned for CLI usage where you want to block until authorization completes. For UI implementations with custom progress feedback, use `pollForDeviceToken()` directly.\n\n- **deviceCode** - Device code from `requestDeviceCode()`\n- **clientId** - OAuth client ID\n- **initialInterval** - Initial polling interval in milliseconds (default: 5000)\n- **expiresIn** - Seconds until device code expires (for timeout detection)\n- **audience** - Audience for the token (default: 'https://api.yotoplay.com')\n- **onPoll** - Optional callback invoked after each poll attempt with the poll result\n\nReturns a promise that resolves to `YotoTokenResponse` on successful authorization.\n\nThrows `YotoAPIError` for unrecoverable errors (expired_token, access_denied, invalid_grant) or `Error` if device code expires.\n\nSee [Yoto API: Token Exchange][api-token]\n\n```js\nimport { YotoClient } from 'yoto-nodejs-client'\n\n// Simplest approach - just wait for tokens\nconst deviceAuth = await YotoClient.requestDeviceCode({\n  clientId: 'your-client-id'\n})\n\nconsole.log(`Visit: ${deviceAuth.verification_uri_complete}`)\nconsole.log(`Code: ${deviceAuth.user_code}`)\n\n// This blocks until authorization completes (or fails)\nconst tokens = await YotoClient.waitForDeviceAuthorization({\n  deviceCode: deviceAuth.device_code,\n  clientId: 'your-client-id',\n  initialInterval: deviceAuth.interval * 1000,\n  expiresIn: deviceAuth.expires_in,\n  onPoll: (result) =\u003e {\n    if (result.status === 'pending') process.stdout.write('.')\n    if (result.status === 'slow_down') console.log('\\nSlowing down...')\n  }\n})\n\nconsole.log('Got tokens:', tokens)\n```\n\n#### `YotoClient.pollForDeviceToken({ deviceCode, clientId, [currentInterval], [audience] })`\n\nPoll for device authorization completion with automatic error handling (single poll attempt). This is a lower-level method that gives you control over the polling loop. Use `waitForDeviceAuthorization()` for a simpler approach.\n\nNon-blocking - returns immediately with poll result. Suitable for:\n- Manual polling loops in CLI applications\n- Server-side endpoints that poll on behalf of clients (e.g., Homebridge UI server)\n- Custom UI implementations with specific polling behavior\n\n- **deviceCode** - Device code from `requestDeviceCode()`\n- **clientId** - OAuth client ID\n- **currentInterval** - Current polling interval in milliseconds (default: 5000)\n- **audience** - Audience for the token (default: 'https://api.yotoplay.com')\n\nReturns a promise that resolves to one of:\n- `{ status: 'success', tokens: YotoTokenResponse }` - Authorization successful\n- `{ status: 'pending', interval: number }` - Still waiting for user authorization\n- `{ status: 'slow_down', interval: number }` - Polling too fast, use new interval\n\nThrows `YotoAPIError` for unrecoverable errors (expired_token, access_denied, invalid_grant, etc).\n\nSee [Yoto API: Token Exchange][api-token]\n\n```js\nimport { YotoClient } from 'yoto-nodejs-client'\n\n// Manual polling loop with full control\nconst deviceAuth = await YotoClient.requestDeviceCode({\n  clientId: 'your-client-id'\n})\n\nlet interval = deviceAuth.interval * 1000\n\nwhile (true) {\n  const result = await YotoClient.pollForDeviceToken({\n    deviceCode: deviceAuth.device_code,\n    clientId: 'your-client-id',\n    currentInterval: interval\n  })\n\n  if (result.status === 'success') {\n    console.log('Tokens:', result.tokens)\n    break\n  } else if (result.status === 'slow_down') {\n    interval = result.interval\n  }\n  \n  // Sleep\n  await new Promise(resolve =\u003e setTimeout(resolve, interval))\n}\n```\n\n#### `YotoClient.getAuthorizeUrl({ clientId, redirectUri, responseType, state, ...params })`\n\nGet authorization URL for browser-based OAuth flow. Returns a URL string to redirect users to.\n\nSee [Yoto API: Authorization][api-authorize]\n\n### Client Instance\n\n#### `new YotoClient({ clientId, refreshToken, accessToken, onTokenRefresh, [options] })`\n\nCreate a new Yoto API client with automatic token refresh.\n\n- **clientId** - OAuth client ID\n- **refreshToken** - OAuth refresh token\n- **accessToken** - Initial access token (JWT)\n- **onTokenRefresh** - **REQUIRED** callback for token refresh events. You MUST persist tokens here.\n- **bufferSeconds** - Seconds before expiration to refresh (default: 30)\n- **userAgent** - Optional user agent string to identify your application\n- **defaultRequestOptions** - Optional default undici request options (dispatcher, timeouts, etc.) applied to all requests\n- **onRefreshStart** - Optional callback when refresh starts. Console.logs by default.\n- **onRefreshError** - Optional callback for transient refresh errors. Console.error's by default.\n- **onInvalid** - Optional callback when refresh token is permanently invalid. Console.errors by default.\n\n```js\nconst client = new YotoClient({\n  clientId: 'your-client-id',\n  refreshToken: 'stored-refresh-token',\n  accessToken: 'stored-access-token',\n  userAgent: 'MyApp/1.0.0',  // Optional - identifies your application\n  defaultRequestOptions: {    // Optional - undici request options for all requests\n    bodyTimeout: 30000,       // 30 second timeout\n    headersTimeout: 10000,    // 10 second header timeout\n    // dispatcher, signal, etc.\n  },\n  onTokenRefresh: async ({ updatedAccessToken, updatedRefreshToken, updatedExpiresAt }) =\u003e {\n    // Save to database, file, etc.\n    await db.saveTokens({ accessToken: updatedAccessToken, refreshToken: updatedRefreshToken, expiresAt: updatedExpiresAt })\n  }\n})\n```\n\n#### Request Options\n\nAll API methods accept an optional [undici][undici] `requestOptions` parameter that allows you to override the default undici request options for individual requests. This is useful for setting custom timeouts, using a specific dispatcher, or aborting requests.\n\n```js\n// Override timeout for a specific request\nconst content = await client.getContent({ \n  cardId: '5WsQg',\n  requestOptions: {\n    bodyTimeout: 60000,  // 60 second timeout for this request only\n    headersTimeout: 20000\n  }\n})\n\n// Use AbortSignal to cancel requests\nconst controller = new AbortController()\nsetTimeout(() =\u003e controller.abort(), 5000)\n\ntry {\n  const devices = await client.getDevices({\n    requestOptions: { signal: controller.signal }\n  })\n} catch (err) {\n  if (err.name === 'AbortError') {\n    console.log('Request was aborted')\n  }\n}\n\n// Use custom dispatcher for connection pooling\nimport { Agent } from 'undici'\nconst agent = new Agent({ connections: 10 })\n\nconst status = await client.getDeviceStatus({\n  deviceId: 'abc123',\n  requestOptions: { dispatcher: agent }\n})\n```\n\n**Available Request Options:**\n- `bodyTimeout` - Body timeout in milliseconds\n- `headersTimeout` - Headers timeout in milliseconds\n- `signal` - AbortSignal to cancel the request\n- `dispatcher` - Custom undici dispatcher (for connection pooling, proxies, etc.)\n- `reset` - Reset connection after request\n- `throwOnError` - Throw on HTTP error status codes\n- `idempotent` - Whether the requests can be safely retried\n- `blocking` - Whether the response is expected to take a long time\n- Other undici RequestOptions (see [undici documentation](https://undici.nodejs.org/))\n\n### Content API\n\n#### `await client.getContent({ cardId, [timezone], [signingType], [playable] })`\n\nGet content/card details including metadata, chapters, and optionally playback URLs.\n\nSee [Yoto API: Get Content][api-get-content]\n\n```js\nconst content = await client.getContent({ \n  cardId: '5WsQg',\n  playable: true  // Include signed playback URLs\n})\n\nconsole.log(content.title)\nconsole.log(content.chapters)\n```\n\n#### `await client.getUserMyoContent({ [showDeleted] })`\n\nGet user's MYO (Make Your Own) content library.\n\nSee [Yoto API: Get User's MYO Content][api-get-myo]\n\n```js\nconst myoContent = await client.getUserMyoContent({ \n  showDeleted: false \n})\n\nmyoContent.cards.forEach(card =\u003e {\n  console.log(`${card.metadata.title} - ${card.chapters.length} chapters`)\n})\n```\n\n#### `await client.createOrUpdateContent({ content })`\n\nCreate new content or update existing content by cardId.\n\nSee [Yoto API: Create or Update Content][api-create-content]\n\n```js\nconst newCard = await client.createOrUpdateContent({\n  content: {\n    title: 'My Story',\n    chapters: [\n      {\n        title: 'Chapter 1',\n        tracks: [\n          {\n            title: 'Part 1',\n            key: 'upload-id-from-audio-upload'\n          }\n        ]\n      }\n    ]\n  }\n})\n\nconsole.log('Created card:', newCard.cardId)\n```\n\n#### `await client.deleteContent({ cardId })`\n\nDelete content/card.\n\nSee [Yoto API: Delete Content][api-delete-content]\n\n```js\nawait client.deleteContent({ cardId: '5WsQg' })\n```\n\n### Devices API\n\n#### `await client.getDevices()`\n\nGet all devices for authenticated user.\n\nSee [Yoto API: Get Devices][api-get-devices]\n\n```js\nconst { devices } = await client.getDevices()\n\ndevices.forEach(device =\u003e {\n  console.log(`${device.name} (${device.deviceId}) - ${device.online ? 'online' : 'offline'}`)\n})\n```\n\n#### `await client.getDeviceStatus({ deviceId })`\n\nGet current status of a specific device including battery, volume, active card, etc.\n\nSee [Yoto API: Get Device Status][api-device-status]\n\n```js\nconst status = await client.getDeviceStatus({ \n  deviceId: 'abc123' \n})\n\nconsole.log('Battery:', status.batteryLevelPercentage, '%')\nconsole.log('Charging:', status.isCharging)\nconsole.log('Volume:', status.userVolumePercentage, '%')\nconsole.log('Active card:', status.activeCard)\n```\n\n#### `await client.getDeviceConfig({ deviceId })`\n\nGet device configuration including settings, timezone, shortcuts, etc.\n\nSee [Yoto API: Get Device Config][api-device-config]\n\n```js\nconst config = await client.getDeviceConfig({ \n  deviceId: 'abc123' \n})\n\nconsole.log('Name:', config.device.name)\nconsole.log('Day time:', config.device.config.dayTime)\nconsole.log('Night time:', config.device.config.nightTime)\nconsole.log('Max volume:', config.device.config.maxVolumeLimit)\n```\n\n#### `await client.updateDeviceConfig({ deviceId, configUpdate })`\n\nUpdate device configuration settings.\n\nSee [Yoto API: Update Device Config][api-update-config]\n\n```js\nawait client.updateDeviceConfig({\n  deviceId: 'abc123',\n  configUpdate: {\n    name: 'Bedroom Player',\n    config: {\n      dayTime: '07:00',\n      nightTime: '19:00',\n      maxVolumeLimit: '80'\n    }\n  }\n})\n```\n\n#### `await client.updateDeviceShortcuts({ deviceId, shortcutsUpdate })`\n\nUpdate device shortcuts configuration (beta feature).\n\nSee [Yoto API: Update Shortcuts][api-update-shortcuts]\n\n#### `await client.sendDeviceCommand({ deviceId, command })`\n\nSend MQTT command to device via HTTP API (alternative to MQTT client).\n\nSee [Yoto API: Send Device Command][api-send-command]\n\n```js\nawait client.sendDeviceCommand({\n  deviceId: 'abc123',\n  command: {\n    volume: 50\n  }\n})\n```\n\n### Family Library Groups API\n\n#### `await client.getGroups()`\n\nGet all family library groups.\n\nSee [Yoto API: Get Groups][api-get-groups]\n\n```js\nconst groups = await client.getGroups()\n\ngroups.forEach(group =\u003e {\n  console.log(`${group.name}: ${group.items.length} items`)\n})\n```\n\n#### `await client.createGroup({ group })`\n\nCreate a new family library group.\n\nSee [Yoto API: Create Group][api-create-group]\n\n```js\nconst group = await client.createGroup({\n  group: {\n    name: 'Bedtime Stories',\n    imageId: 'fp-cards',\n    items: [\n      { contentId: '5WsQg' },\n      { contentId: '7KpLq' }\n    ]\n  }\n})\n```\n\n#### `await client.getGroup({ groupId })`\n\nGet a specific group by ID.\n\nSee [Yoto API: Get a Group][api-get-group]\n\n#### `await client.updateGroup({ groupId, group })`\n\nUpdate an existing group.\n\nSee [Yoto API: Update Group][api-update-group]\n\n#### `await client.deleteGroup({ groupId })`\n\nDelete a group permanently.\n\nSee [Yoto API: Delete Group][api-delete-group]\n\n### Family API\n\n#### `await client.getFamilyImages()`\n\nGet list of uploaded family images.\n\nSee [Yoto API: Get Family Images][api-family-images]\n\n```js\nconst { images } = await client.getFamilyImages()\n\nimages.forEach(image =\u003e {\n  console.log(`${image.name || 'Unnamed'}: ${image.imageId}`)\n})\n```\n\n#### `await client.getAFamilyImage({ imageId, size })`\n\nGet signed URL for a family image.\n\nSee [Yoto API: Get a Family Image][api-get-family-image]\n\n```js\nconst { imageUrl } = await client.getAFamilyImage({\n  imageId: 'abc123hash',\n  size: '640x480'  // or '320x320'\n})\n\nconsole.log('Image URL:', imageUrl)\n```\n\n#### `await client.uploadAFamilyImage({ imageData })`\n\nUpload a family image for use across Yoto features.\n\nSee [Yoto API: Upload Family Image][api-upload-family-image]\n\n```js\nimport { readFile } from 'fs/promises'\n\nconst imageData = await readFile('./family-photo.jpg')\nconst result = await client.uploadAFamilyImage({ imageData })\n\nconsole.log('Image ID:', result.imageId)\n```\n\n### Icons API\n\n#### `await client.getPublicIcons()`\n\nGet list of public display icons available to all users.\n\nSee [Yoto API: Get Public Icons][api-public-icons]\n\n```js\nconst { displayIcons } = await client.getPublicIcons()\n\ndisplayIcons.forEach(icon =\u003e {\n  console.log(`${icon.title}: ${icon.displayIconId}`)\n})\n```\n\n#### `await client.getUserIcons()`\n\nGet user's custom uploaded icons.\n\nSee [Yoto API: Get User Icons][api-user-icons]\n\n#### `await client.uploadIcon({ imageData, [autoConvert], [filename] })`\n\nUpload a custom 16×16px display icon.\n\nSee [Yoto API: Upload Custom Icon][api-upload-icon]\n\n```js\nimport { readFile } from 'fs/promises'\n\nconst imageData = await readFile('./my-icon.png')\nconst result = await client.uploadIcon({\n  imageData,\n  autoConvert: true,  // Auto-resize and process\n  filename: 'my-custom-icon'\n})\n\nconsole.log('Icon ID:', result.displayIcon.displayIconId)\n```\n\n### Media API\n\n#### `await client.getAudioUploadUrl({ sha256, [filename] })`\n\nGet signed URL for uploading audio files. Files are deduplicated by SHA256 hash.\n\nSee [Yoto API: Get Audio Upload URL][api-audio-upload]\n\n```js\nimport { createHash } from 'crypto'\nimport { readFile } from 'fs/promises'\n\nconst audioData = await readFile('./story.mp3')\nconst sha256 = createHash('sha256').update(audioData).digest('hex')\n\nconst { upload } = await client.getAudioUploadUrl({ \n  sha256,\n  filename: 'story.mp3'\n})\n\nif (upload.uploadUrl) {\n  // File doesn't exist, upload it\n  await fetch(upload.uploadUrl, {\n    method: 'PUT',\n    body: audioData\n  })\n}\n\n// Use upload.uploadId in content creation\n```\n\n#### `await client.uploadCoverImage({ [imageData], [imageUrl], [coverType], [autoConvert], [filename] })`\n\nUpload a cover image for content cards.\n\nSee [Yoto API: Upload Cover Image][api-cover-image]\n\n```js\nimport { readFile } from 'fs/promises'\n\nconst imageData = await readFile('./cover.jpg')\nconst { coverImage } = await client.uploadCoverImage({\n  imageData,\n  coverType: 'default',  // 638×1011px\n  autoConvert: true\n})\n\nconsole.log('Cover image ID:', coverImage.mediaId)\n```\n\n### MQTT Client\n\n#### `await client.createMqttClient({ deviceId, [options] })`\n\nCreate an MQTT client for real-time device communication and control.\n\nSee [Yoto MQTT Documentation][mqtt-docs]\n\n```js\nconst mqtt = await client.createMqttClient({\n  deviceId: 'abc123',\n  autoResubscribe: true,\n  keepAliveSeconds: 1200\n})\n\n// Listen for real-time events\nmqtt.on('events', (message) =\u003e {\n  console.log('Track:', message.trackTitle)\n  console.log('Card:', message.cardTitle)\n  console.log('Status:', message.playbackStatus)\n})\n\n// Listen for status updates\nmqtt.on('status', (message) =\u003e {\n  console.log('Volume:', message.volume)\n  console.log('Battery:', message.batteryLevel)\n  console.log('Charging:', message.charging)\n})\n\n// Listen for command responses\nmqtt.on('response', (message) =\u003e {\n  console.log('Command response:', message)\n})\n\n// Connect to device\nawait mqtt.connect()\n\n// Control device\nawait mqtt.setVolume(50)\nawait mqtt.setAmbientHex('#FF0000')\nawait mqtt.setSleepTimer(30)  // 30 minutes\nawait mqtt.startCard({ cardId: '5WsQg' })\nawait mqtt.pauseCard()\nawait mqtt.resumeCard()\nawait mqtt.stopCard()\n\n// Disconnect when done\nawait mqtt.disconnect()\n```\n\n#### MQTT Events\n\nThe MQTT client emits three types of messages:\n\n- **`events`** - Real-time playback events (track changes, play/pause, volume adjustments)\n- **`status`** - Device status updates (battery, configuration, online state)\n- **`response`** - Command confirmation responses\n\n#### MQTT Methods\n\n- `await mqtt.connect()` - Connect to device MQTT broker\n- `await mqtt.disconnect()` - Disconnect from broker\n- `await mqtt.setVolume(volume)` - Set volume (0-100)\n- `await mqtt.setAmbientHex(hex)` - Set ambient light color (e.g., '#FF0000')\n- `await mqtt.setSleepTimer(minutes)` - Set sleep timer (0 to disable)\n- `await mqtt.startCard({ cardId, chapterKey, trackKey })` - Start playing a card\n- `await mqtt.pauseCard()` - Pause current playback\n- `await mqtt.resumeCard()` - Resume playback\n- `await mqtt.stopCard()` - Stop playback\n- `await mqtt.reboot()` - Reboot device\n\n### YotoDeviceModel - Stateful Device Client\n\n#### `new YotoDeviceModel(client, device, [options])`\n\nCreate a stateful device client that manages device state primarily from MQTT with HTTP background sync.\n\n**Philosophy:**\n- MQTT is the primary source for all real-time status updates\n- MQTT connection is always maintained and handles its own reconnection\n- Device online/offline state is tracked by MQTT activity and explicit shutdown messages\n- HTTP background polling runs every 10 minutes to sync config+status regardless of online state\n- HTTP status updates emit offline events if device state changes to offline\n\n**Parameters:**\n- `client` - YotoClient instance\n- `device` - Device object from `getDevices()`\n- `options.httpPollIntervalMs` - Background HTTP polling interval (default: 600000ms / 10 minutes)\n- `options.mqttOptions` - MQTT.js client options to pass through\n\n**Lifecycle:**\n- `await deviceClient.start()` - Start device client (connects MQTT, starts polling)\n- `await deviceClient.stop()` - Stop device client (disconnects MQTT, stops polling)\n- `await deviceClient.restart()` - Restart device client\n\n**State Accessors:**\n- `deviceClient.device` - Device information\n- `deviceClient.status` - Current device status (normalized from HTTP/MQTT)\n- `deviceClient.config` - Device configuration (normalized numbers/booleans; auto fields split into `\u003cfield\u003eAuto` + value)\n- `deviceClient.shortcuts` - Button shortcuts\n- `deviceClient.playback` - Current playback state\n- `deviceClient.capabilities` - Hardware capabilities (sensors, nightlight support, etc.)\n- `deviceClient.nightlight` - Nightlight info: { value, name, supported }\n- `deviceClient.initialized` - Whether device has been initialized\n- `deviceClient.running` - Whether device client is currently running\n- `deviceClient.mqttConnected` - MQTT connection status\n- `deviceClient.deviceOnline` - Device online status\n- `deviceClient.mqttClient` - Underlying MQTT client instance (or null)\n\n**Device Control:**\n- `await deviceClient.refreshConfig()` - Refresh config from HTTP API\n- `await deviceClient.updateConfig(configUpdate)` - Update device configuration\n- `await deviceClient.sendCommand(command)` - Send device command via HTTP\n- `await deviceClient.startCard({ cardId, [chapterKey], [trackKey] })` - Start playing a card\n\n**Events:**\n- `started(metadata)` - Device client started, passes metadata object with device, config, shortcuts, status, playback, initialized, running\n- `stopped()` - Device client stopped\n- `statusUpdate(status, source, changedFields)` - Status changed, passes (status, source, changedFields). Source is 'http', 'mqtt', or 'mqtt-event'\n- `configUpdate(config, changedFields)` - Configuration changed, passes (config, changedFields)\n- `playbackUpdate(playback, changedFields)` - Playback state changed, passes (playback, changedFields)\n- `online(metadata)` - Device came online, passes metadata with reason and optional upTime\n- `offline(metadata)` - Device went offline, passes metadata with reason and optional shutDownReason or timeSinceLastSeen\n- `mqttConnect(metadata)` - MQTT client connected, passes CONNACK metadata\n- `mqttDisconnect(metadata)` - MQTT disconnect packet received, passes metadata with disconnect packet\n- `mqttClose(metadata)` - MQTT connection closed, passes metadata with close reason\n- `mqttReconnect()` - MQTT client is reconnecting\n- `mqttOffline()` - MQTT client goes offline\n- `mqttEnd()` - MQTT client end is called\n- `mqttStatus(topic, message)` - Raw MQTT status messages (documented status topic)\n- `mqttEvents(topic, message)` - Raw MQTT events messages\n- `mqttStatusLegacy(topic, message)` - Raw legacy MQTT status messages (undocumented status topic)\n- `mqttResponse(topic, message)` - Raw MQTT response messages\n- `mqttUnknown(topic, message)` - Raw MQTT messages that do not match known types\n- `error(error)` - Error occurred, passes error\n\n**Static Properties \u0026 Methods:**\n- `YotoDeviceModel.NIGHTLIGHT_COLORS` - Map of nightlight color hex codes to official color names\n- `YotoDeviceModel.getNightlightColorName(colorValue)` - Get official color name for a nightlight value\n\n```js\nimport { YotoClient, YotoDeviceModel } from 'yoto-nodejs-client'\n\nconst client = new YotoClient({ /* ... */ })\nconst { devices } = await client.getDevices()\n\nconst deviceClient = new YotoDeviceModel(client, devices[0], {\n  httpPollIntervalMs: 300000  // Poll every 5 minutes\n})\n\ndeviceClient.on('statusUpdate', (status, source, changedFields) =\u003e {\n  console.log(`Battery: ${status.batteryLevelPercentage}% (${source})`)\n  console.log('Changed fields:', changedFields)\n})\n\ndeviceClient.on('online', (metadata) =\u003e {\n  console.log('Device online:', metadata.reason)\n})\n\nawait deviceClient.start()\n\n// Access state\nconsole.log('Temperature:', deviceClient.status.temperatureCelsius)\nconsole.log('Has temp sensor:', deviceClient.capabilities.hasTemperatureSensor)\nconsole.log('Nightlight:', deviceClient.nightlight)  // { value, name, supported }\n\n// Use static nightlight utilities\nconsole.log('Available colors:', YotoDeviceModel.NIGHTLIGHT_COLORS)\nconsole.log('Color name:', YotoDeviceModel.getNightlightColorName('0x643600'))\n\n// Control device\nawait deviceClient.updateConfig({ maxVolumeLimit: 14 })\nawait deviceClient.updateConfig({\n  nightDisplayBrightnessAuto: true,\n  nightDisplayBrightness: null\n})\nawait deviceClient.updateConfig({\n  nightYotoRadioEnabled: false,\n  nightYotoRadio: null\n})\n\nawait deviceClient.stop()\n```\n\n### Account Manager\n\n#### `new YotoAccount({ clientOptions, deviceOptions })`\n\nCreate an account manager that automatically discovers and manages all devices for a Yoto account.\n\n**Parameters:**\n- `clientOptions` - YotoClient constructor options (clientId, refreshToken, accessToken, onTokenRefresh, etc.)\n- `deviceOptions` - YotoDeviceModel options applied to all devices (httpPollIntervalMs, mqttOptions)\n\n**Lifecycle:**\n- `await account.start()` - Start account (creates client, discovers devices, starts all device clients)\n- `await account.stop()` - Stop account (stops all device clients gracefully)\n- `await account.restart()` - Restart account\n- `await account.refreshDevices()` - Refresh device list (add new, remove missing)\n\n**State Accessors:**\n- `account.client` - Underlying YotoClient instance\n- `account.devices` - Map of all device models (Map\u003cdeviceId, YotoDeviceModel\u003e)\n- `account.getDevice(deviceId)` - Get specific device model\n- `account.getDeviceIds()` - Get array of all device IDs\n- `account.running` - Whether account is currently running\n- `account.initialized` - Whether account has been initialized\n\n**Events:**\n- `started(metadata)` - Account started (metadata: { deviceCount, devices })\n- `stopped()` - Account stopped\n- `deviceAdded({ deviceId })` - Device was added\n- `deviceRemoved({ deviceId })` - Device was removed\n- `statusUpdate({ deviceId, status, source, changedFields })` - Re-emitted device status update\n- `configUpdate({ deviceId, config, changedFields })` - Re-emitted config update\n- `playbackUpdate({ deviceId, playback, changedFields })` - Re-emitted playback update\n- `online({ deviceId, metadata })` - Re-emitted online event\n- `offline({ deviceId, metadata })` - Re-emitted offline event\n- `mqttConnect({ deviceId, metadata })` - Re-emitted MQTT connect\n- `mqttDisconnect({ deviceId, metadata })` - Re-emitted MQTT disconnect\n- `mqttClose({ deviceId, metadata })` - Re-emitted MQTT close\n- `mqttReconnect({ deviceId })` - Re-emitted MQTT reconnect\n- `mqttOffline({ deviceId })` - Re-emitted MQTT offline\n- `mqttEnd({ deviceId })` - Re-emitted MQTT end\n- `mqttStatus({ deviceId, topic, message })` - Re-emitted raw MQTT status\n- `mqttEvents({ deviceId, topic, message })` - Re-emitted raw MQTT events\n- `mqttStatusLegacy({ deviceId, topic, message })` - Re-emitted raw MQTT legacy status\n- `mqttResponse({ deviceId, topic, message })` - Re-emitted raw MQTT response\n- `mqttUnknown({ deviceId, topic, message })` - Re-emitted raw MQTT unknown message\n- `error({ error, context })` - Error occurred (context: { source, deviceId, operation })\n\n**Note:** You can still listen to individual device events by attaching listeners to each `YotoDeviceModel`, but the account now re-emits device and MQTT events with device context for unified handling.\n\n```js\nimport { YotoAccount } from 'yoto-nodejs-client'\n\nconst account = new YotoAccount({\n  clientOptions: {\n    clientId: 'your-client-id',\n    refreshToken: 'your-refresh-token',\n    accessToken: 'your-access-token',\n    onTokenRefresh: async (event) =\u003e {\n      await saveTokens(event)\n    }\n  },\n  deviceOptions: {\n    httpPollIntervalMs: 600000  // 10 minutes\n  }\n})\n\n// Account-level error handling\naccount.on('error', ({ error, context }) =\u003e {\n  console.error(`Error in ${context.source}:`, error.message)\n})\n\n// Unified device events across all devices\naccount.on('statusUpdate', ({ deviceId, status, source }) =\u003e {\n  console.log(`${deviceId} battery: ${status.batteryLevelPercentage}% (${source})`)\n})\n\naccount.on('online', ({ deviceId, metadata }) =\u003e {\n  console.log(`${deviceId} came online (${metadata.reason})`)\n})\n\naccount.on('offline', ({ deviceId, metadata }) =\u003e {\n  console.log(`${deviceId} went offline (${metadata.reason})`)\n})\n\nawait account.start()\n\n// Access individual devices and attach listeners\nconst device = account.getDevice('abc123')\nconsole.log('Device battery:', device.status.batteryLevelPercentage)\n\n// Listen to specific device events\ndevice.on('playbackUpdate', (playback) =\u003e {\n  console.log('Now playing:', playback.currentCardTitle)\n})\n\n// Iterate all devices and attach listeners\nfor (const [deviceId, deviceModel] of account.devices) {\n  console.log(`${deviceId}: ${deviceModel.status.batteryLevelPercentage}%`)\n  \n  deviceModel.on('configUpdate', (config) =\u003e {\n    console.log(`${deviceId} config updated:`, config.name)\n  })\n}\n\nawait account.stop()\n```\n\n## CLI Tools\n\nThe library includes CLI tools for authentication and data inspection. After installing the package, these commands are available globally:\n\n### Authentication\n\n```bash\n# Get initial tokens (device flow)\nyoto-auth --output .env\n# or: node bin/auth.js --output .env\n\n# Refresh existing tokens\nyoto-refresh-token\n# or: node bin/refresh-token.js\n\n# Show token info and inspect JWT contents\nyoto-token-info\n# or: node bin/token-info.js\n```\n\n### Devices\n\n```bash\n# List all devices\nyoto-devices\n# or: node bin/devices.js\n\n# Get device details with config\nyoto-devices --device-id abc123\n\n# Get device status only\nyoto-devices --device-id abc123 --status\n\n# Connect to MQTT and listen for messages\nyoto-devices --device-id abc123 --mqtt\n\n# Sample MQTT messages for 10 seconds\nyoto-devices --device-id abc123 --mqtt --mqtt-timeout 10\n\n# Use YotoDeviceModel to monitor device (HTTP + MQTT)\nyoto-device-model --device-id abc123\n\n# Interactive TUI for device control (Prototype/WIP/Unpublished)\nyoto-device-tui --device-id abc123\n```\n\n### Content\n\n```bash\n# List all MYO content\nyoto-content\n# or: node bin/content.js\n\n# Get specific card details\nyoto-content --card-id 5WsQg\n\n# Get card with playable URLs\nyoto-content --card-id 5WsQg --playable\n```\n\n### Family Library Groups\n\n```bash\n# List all family library groups\nyoto-groups\n# or: node bin/groups.js\n\n# Get specific group details\nyoto-groups --group-id abc123\n```\n\n### Icons\n\n```bash\n# List both public and user icons\nyoto-icons\n# or: node bin/icons.js\n\n# List only public Yoto icons\nyoto-icons --public\n\n# List only user custom icons\nyoto-icons --user\n```\n\n## See also\n\n- [Yoto API Documentation][yoto-api]\n- [Yoto MQTT Documentation][mqtt-docs]\n- [Yoto Developer Portal][yoto-dev]\n\n## License\n\nMIT\n\n[yoto-api]: https://yoto.dev/api/\n[yoto-dev]: https://yoto.dev/\n[mqtt-docs]: https://yoto.dev/players-mqtt/mqtt-docs/\n[api-device-code]: https://yoto.dev/api/post-oauth-device-code/\n[api-token]: https://yoto.dev/api/post-oauth-token/\n[api-authorize]: https://yoto.dev/api/get-authorize/\n[api-get-content]: https://yoto.dev/api/getcontent/\n[api-get-myo]: https://yoto.dev/api/getusersmyocontent/\n[api-create-content]: https://yoto.dev/api/createorupdatecontent/\n[api-delete-content]: https://yoto.dev/api/deletecontent/\n[api-get-devices]: https://yoto.dev/api/getdevices/\n[api-device-status]: https://yoto.dev/api/getdevicestatus/\n[api-device-config]: https://yoto.dev/api/getdeviceconfig/\n[api-update-config]: https://yoto.dev/api/updatedeviceconfig/\n[api-update-shortcuts]: https://yoto.dev/api/updateshortcutsbeta/\n[api-send-command]: https://yoto.dev/api/senddevicecommand/\n[api-get-groups]: https://yoto.dev/api/getgroups/\n[api-create-group]: https://yoto.dev/api/createagroup/\n[api-get-group]: https://yoto.dev/api/getagroup/\n[api-update-group]: https://yoto.dev/api/updateagroup/\n[api-delete-group]: https://yoto.dev/api/deleteagroup/\n[api-family-images]: https://yoto.dev/api/getfamilyimages/\n[api-get-family-image]: https://yoto.dev/api/getafamilyimage/\n[api-upload-family-image]: https://yoto.dev/api/uploadafamilyimage/\n[api-public-icons]: https://yoto.dev/api/getpublicicons/\n[api-user-icons]: https://yoto.dev/api/getusericons/\n[api-upload-icon]: https://yoto.dev/api/uploadcustomicon/\n[api-audio-upload]: https://yoto.dev/api/getanuploadurl/\n[api-cover-image]: https://yoto.dev/api/uploadcoverimage/\n[undici]: https://undici.nodejs.org/#/docs/api/Client.md\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbcomnes%2Fyoto-nodejs-client","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbcomnes%2Fyoto-nodejs-client","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbcomnes%2Fyoto-nodejs-client/lists"}