@smarthome.exchange/api
🔌 Typed API client for connecting tools, CLIs, automations, and apps to a smarthome.exchange hub.
This package wraps the hub's authenticated HTTP and Node WebSocket boundaries. Each client is bound to one home and returns the exact response shapes from @smarthome.exchange/interfaces.
The device contract consumes interfaces 2.1.0, including native tcp inventory. Hub 3.0 keeps these seven RPC envelopes unchanged. Discover current tools with listTools() before submitting a plan: write availability intersects the owner's enabled integration domains, adapter permissions and authenticated scopes. The client does not configure adapters or grant controls.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm add @smarthome.exchange/api
Basic Usage
import { SmarthomeExchangeApiClient } from '@smarthome.exchange/api';
const client = new SmarthomeExchangeApiClient({
hubUrl: 'http://localhost:8080',
homeId: 'home:primary',
authorizationHeaderProvider: async ({ abortSignal }) => {
const accessToken = await yourCredentialProvider.getAccessToken({ abortSignal });
return `Bearer ${accessToken}`;
},
});
const snapshotResponse = await client.getHomeSnapshot();
console.log(snapshotResponse.availability, snapshotResponse.snapshot.home.name);
const deviceResponse = await client.listDevices({
capability: 'light',
});
console.log(deviceResponse.devices.map((deviceArg) => deviceArg.name));
yourCredentialProvider is the application's configured credential service; the client does not perform sign-in or persist tokens. The asynchronous authorizationHeaderProvider is required. It runs once per HTTP request and before each explicitly started physical Node socket connection. It receives the immutable home, final transport endpoint, operation name, transport, and cancellation signal. A socket context uses method: 'connect' and transport: 'websocket', with the owning attempt and preparation deadline. Return the complete Authorization header value. Provider failure, an empty value, or invalid HTTP header syntax rejects the call without sending an anonymous request.
The constructor captures the home and provider from plain own data properties. Changing the original options or a filter while credentials are being obtained cannot change the prepared request. Every operation includes context: { homeId }; filter parameters cannot replace that context or supply trusted identity. Nested action inputs must contain finite, acyclic JSON data. Accessors, symbolic properties, and class instances are rejected without evaluating their getters, before credential preparation. Tool plans and calls cannot supply identity or grant fields. The home selects a resource. The server derives identity and checks home grants from the transport credentials.
Transport and Cancellation
HTTP calls use published TypedRequest 9.1 and ${hubUrl}/typedrequest. Credentials stay in HTTP headers outside RPC JSON. Header-bearing calls bypass the shared response cache, including when useCache: true, and reject redirects before credentials can be forwarded. Configure the final hub URL without embedded credentials, query parameters, or a fragment. HTTPS and server-managed browser CORS remain deployment responsibilities.
Every method accepts optional request options:
const abortController = new AbortController();
const snapshot = await client.getHomeSnapshot({
abortSignal: abortController.signal,
timeoutMs: 5000,
});
const devices = await client.listDevices({ room: 'kitchen' }, {
abortSignal: abortController.signal,
});
Cancellation is checked before and after credential preparation and passed to TypedRequest. Cancellation settles the API request even when a credential provider ignores its signal; late provider results or failures are observed and cannot open a request. The provider still owns its own resources and must cooperate with the supplied signal to release them. The client cannot terminate foreign provider work or synchronous code. timeoutMs is TypedRequest's HTTP deadline, beginning after credential preparation and covering transport and response decoding. API operations set the owning transport’s maxRetries to zero: a server-requested retry raises TypedRequestRetryLimitError after the first attempt. HTTP abort and timeout do not establish whether an already admitted device action completed. Inspect its durable approval or audit outcome before another attempt.
Authenticated Node Socket Mode
await client.start();
try {
const { agents, statuses } = await client.listAgents();
console.log(agents.length, statuses.length);
} finally {
await client.stop();
}
Node start() consumes published TypedSocket 9 and obtains an Authorization header before the WebSocket upgrade, including its transport compatibility handshake. Credentials stay outside URLs and RPC frames; upgrade redirects are disabled. Socket calls carry the captured home context, and the hub must enforce current subject grants on every message. The package-major handshake establishes compatibility only.
While started, methods use that authenticated socket and its bound subject; the header provider is not reevaluated for individual socket messages. To rotate credentials or change subjects, stop and explicitly start a new connection. Automatic reconnects are disabled, so denied upgrade admission settles immediately without repeatedly presenting stale credentials. Calls before startup and after successful stop use authenticated HTTP. An in-flight socket request that fails or is stopped is never resent over HTTP. In socket mode, timeoutMs configures both the TypedRequest deadline and TypedSocket’s per-call timeout, with the owning maximum of 300000 milliseconds; omitting it retains the socket’s 30000-millisecond default. Larger HTTP deadlines are unavailable in socket mode.
Browser and Deno WebSocket APIs cannot supply Authorization upgrade headers. Their start() fails with the owning TypedSocketConnectionPreparationError, code callback-rejected, whose cause is a TypedSocketConnectionPreparationError with code unsupported-headers. The capability check runs before obtaining credentials or opening a socket. Use the authenticated HTTP methods and snapshot polling in those runtimes. The hub has no published application cookie-ticket issuer/consumer contract; this client creates no ticket endpoint and sends no authentication RPC. Native Fetch CORS and credential policy remain server/application responsibilities.
TypedSocket requires TLS for remote Node sockets; insecure ws: is permitted only on loopback. Configure an HTTPS hub URL for remote socket use. Credential preparation has the owning ten-second default/maximum deadline; set constructor socketConnectionPreparationTimeoutMs to a positive integer no greater than 10000 to reduce it. Its signal aborts on timeout, cancellation, disconnect, and stop. A noncooperative provider cannot hold socket stop open or open a connection with a late credential result.
Concurrent identical start()/stop() calls coalesce. Mixed lifecycle calls run in order, with at most 64 active and queued operations. stop() supersedes earlier queued starts and cancels an in-flight startup using TypedSocket's exported public startup-aborted error. A late-created socket remains owned until its exact cleanup succeeds. A cleanup failure retains that socket for a later stop() and blocks new startup; failed startup is retryable through a later explicit start().
Client Methods
| Method | Typed operation | Returns |
|---|---|---|
listDevices({ room?, capability? }) |
listDevices |
{ devices } |
listAgents() |
listAgents |
{ agents, statuses } |
listTools(filter?) |
listTools |
{ tools } |
getHomeSnapshot() |
getHomeSnapshot |
{ availability, snapshot } |
watchHomeSnapshot(callback, options?) |
local polling helper | Async stop function |
executeToolPlan(plan) |
executeToolPlan |
IToolPlanResult |
listApprovals(filter?) |
listApprovals |
{ approvals } |
submitApproval(request) |
submitApproval |
Updated approval result |
Approval Decisions
Approval decisions bind to the exact action digest returned by listApprovals():
const { approvals } = await client.listApprovals({ status: 'pending' });
const approval = approvals[0];
if (approval) {
const result = await client.submitApproval({
approvalId: approval.id,
expectedActionDigest: approval.actionDigest,
decision: 'approved',
});
console.log(result.approval.execution.status);
}
The hub decides whether the authenticated subject may approve the snapshotted action. The shared contract distinguishes decision state from approval.execution; an approved decision alone is not proof of a physical effect. An interrupted dispatch may be indeterminate. The API client does not automatically resubmit approval decisions or device calls.
Snapshot Watching
watchHomeSnapshot(callback, options?) owns polling through getHomeSnapshot(). The cancellable watcher contract requires @smarthome.exchange/api 1.0.0 or newer. It captures and validates plain options once: abortSignal terminates the watcher lifetime, timeoutMs sets each request deadline, intervalMs defaults to 5000 and must be an integer from 1 through 2147483647, and immediate: false defers its first poll. Accessors and unsupported properties are rejected before credential preparation.
Every poll has an owned cancellation signal passed to the credential provider and transport. The async stop function clears the timer, aborts the active request, and drains API-owned request work. External lifetime cancellation is terminal, and a pre-aborted watcher starts no timer or request. No canceled or stopped poll publishes a later callback/error or schedules another poll. A normal request deadline is reported to onError; polling continues unless the watcher is stopped. HTTP deadlines begin after credential preparation, as for direct requests; providers must bound their own credential work.
User callbacks and onError handlers may await their own stop function. Stop never waits for active user handlers, so user code cannot hold external close open; a handler already running may finish later. Likewise, cancellation settles credential preparation without waiting for a provider that ignores its signal, but provider-owned resources require the provider's cooperative cleanup. An ignored provider's late result never dispatches a request. Watcher stop does not stop the client's independently owned socket; stop the client separately when its socket is also application-owned.
The callback receives the same { availability, snapshot } response returned by getHomeSnapshot(). availability is live for a successfully served live hub response and stale when the response carries stale snapshot data. A raw IHomeSnapshot without this response envelope is not automatically live. Polling does not add a server push contract. Explicit refreshes use the existing getHomeSnapshot({ abortSignal, timeoutMs }) request options.
const watchLifetime = new AbortController();
const stopWatching = client.watchHomeSnapshot(
async ({ availability, snapshot }) => {
console.log(availability, snapshot.home.name, snapshot.devices.length);
},
{
intervalMs: 5000,
immediate: true,
abortSignal: watchLifetime.signal,
timeoutMs: 5000,
onError: async (errorArg) => {
console.error('Snapshot refresh failed:', errorArg);
},
},
);
// Later, when the UI or process is shutting down:
watchLifetime.abort();
await stopWatching();
Tool Plan Example
const result = await client.executeToolPlan({
id: 'plan:dim-living-room',
callerId: 'app:console',
title: 'Dim living room lights',
reason: 'The user requested a 35% living-room brightness level.',
confidence: 1,
calls: [{
id: 'call:living-room-dim',
toolId: 'device:homeassistant:light.living_room:brightness:write',
callerId: 'app:console',
input: { value: 35 },
}],
});
for (const callResult of result.results) {
if (callResult.status === 'failed') {
throw new Error(callResult.errorMessage);
}
if (callResult.status === 'indeterminate') {
console.error('Device outcome requires reconciliation:', callResult.errorMessage);
}
if (callResult.status === 'queuedForApproval') {
console.log('Approval required:', callResult.approvalId);
}
}
Use a tool identifier returned by listTools() for a currently normalized integration-owned entity. Do not construct identifiers from seeded demo device IDs: a device without a live integration owner cannot dispatch a physical write and fails closed. Inspect every per-call result because a successful executeToolPlan() request may still contain failed, indeterminate, suggested, or queuedForApproval outcomes.
The plan and call callerId fields remain part of the authoring contract. They do not grant authority: the server resolves the subject from HTTP authentication. Reuse stable plan and call identifiers when inspecting an uncertain operation; do not generate fresh identifiers to retry a possibly dispatched effect.
Scripts
pnpm test
pnpm build
pnpm buildDocs
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the LICENSE file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.