npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gladysassistant/integration-sdk

v0.14.0

Published

Official Node.js SDK to build external integrations for Gladys Assistant

Readme

@gladysassistant/integration-sdk

Official Node.js SDK to build external integrations for Gladys Assistant.

An external integration is a program running in an isolated Docker container, supervised by Gladys. It talks to Gladys through the host API (REST) and an outgoing WebSocket — this SDK wraps both, so an integration usually fits in a few dozen lines.

  • Node.js >= 20, a single runtime dependency (ws)
  • CommonJS + ESM, TypeScript typings included
  • Automatic reconnection with exponential backoff, automatic state resynchronization, automatic command acks

Getting started

The fastest way to start is the official template repository: GladysAssistant/integration-template-js ("Use this template" → edit the manifest → tag your repo with the gladys-assistant-integration topic → your integration appears in the store of every Gladys). The complete developer documentation (manifest reference, host API, container contract, publication guide) lives on gladysassistant.com.

Install

npm install @gladysassistant/integration-sdk

Usage

import {
  GladysIntegration,
  DEVICE_FEATURE_CATEGORIES,
  DEVICE_FEATURE_TYPES,
  logger,
} from '@gladysassistant/integration-sdk';
// CommonJS works too: const { GladysIntegration } = require('@gladysassistant/integration-sdk');
// (then wrap the `await` calls in an async function — CJS has no top-level await)

// Every option is read from the container env vars by default
// (GLADYS_HOST_API_URL, GLADYS_INTEGRATION_TOKEN, GLADYS_INTEGRATION_SELECTOR);
// override them for development outside Docker.
const gladys = new GladysIntegration();

gladys.onScanRequest(async () => {
  // External ids must be unique and stable per device: build them from an
  // identifier that comes from the brand/hardware (serial, MAC, Zigbee address…),
  // never from a generic word like "switch" alone.
  const ids = gladys.externalIds('switch', '0x00158d0001a2b3c4');
  await gladys.publishDiscoveredDevices([
    {
      name: 'Virtual switch',
      external_id: ids.device,
      features: [
        {
          name: 'On/Off',
          external_id: ids.feature('binary'),
          category: DEVICE_FEATURE_CATEGORIES.SWITCH,
          type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
          min: 0,
          max: 1,
          read_only: false,
          has_feedback: true,
          keep_history: true,
        },
      ],
    },
  ]);
});

gladys.onSetValue(async (device, feature, value) => {
  // resolving acks the command with success; throwing acks it as failed
  await gladys.publishState(feature.external_id, value);
});

gladys.onConfigUpdated(async (config) => {
  logger.info('New config', config); // stdout → docker logs, level set by LOG_LEVEL
});

gladys.handleShutdown(); // SIGTERM/SIGINT → clean disconnect → exit(0)

await gladys.connect(); // resolves once authenticated

API

new GladysIntegration(options?)

| Option | Default | Description | | ------------ | ------------------------------------- | ------------------------------- | | hostApiUrl | GLADYS_HOST_API_URL env var | Base URL of the Gladys host API | | token | GLADYS_INTEGRATION_TOKEN env var | Integration JWT | | selector | GLADYS_INTEGRATION_SELECTOR env var | Integration selector |

Throws immediately when a value is missing (neither option nor env var).

Advanced options: reconnectBaseDelay (default 1000 ms), reconnectMaxDelay (default 60000 ms), requestTimeout (default 15000 ms — host API requests are aborted past this delay) and logger (the logger used for the connection lifecycle logs, default createLogger({ name: 'gladys-sdk' }) — pass createLogger({ level: 'silent' }) to silence the SDK entirely).

Methods

All methods return Promises; host API errors are thrown as GladysApiError { status, code, message }.

| Method | Contract | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | connect() | Opens the WebSocket, authenticates, resynchronizes (GET /device + GET /config), then resolves. Reconnects automatically for life with min(1s * 2^n, 60s) backoff; every reconnection re-authenticates and resynchronizes. A token refused by Gladys (close code 4000) keeps the loop armed but jumps straight to the max delay — the refusal may be transient, and the integration must never go zombie | | disconnect() | Closes cleanly (no more reconnection) | | externalId(suffix) | → `ext:${selector}:${suffix}` — the only documented way to build an external_id | | externalIds(type, platformId) | → { device, feature(key) } — the ids of ONE physical device. platformId must come from the external platform (serial, MAC, Zigbee address…) so the ids stay unique and stable | | handleShutdown(cleanup?) | Exits gracefully on SIGTERM/SIGINT: runs the optional (signal) => Promise cleanup, disconnects cleanly, then process.exit(0) | | publishDiscoveredDevices(devices) | Publishes the complete list of discovered devices (replaces the previous one). Re-publishing a device the user already created silently upserts its params and its features' supported_options in Gladys (a LAN IP that changed in DHCP, a camera preset renamed…) without touching its name/features and without a device-updated echo; a structure change (features) shows an "Update" button in the Discovery screen instead | | getHouses() | Houses configured in Gladys with their coordinates ([{ id, name, selector, latitude, longitude }], sorted by name) — for integrations that own their own geo-dependent logic (water restrictions, pollen, air quality…). Requires location: true in the manifest (403 otherwise); latitude/longitude are null for an unlocated house, several houses may exist. Fetch at startup and on reconnection, there is no update event. A weather integration needs neither this nor location: true: the coordinates arrive in every onWeatherGet call | | getDevices() | Devices created by the user; also refreshes gladys.devices | | publishState(featureExternalId, value) | value is a number, or { text }, or { state, created_at } for a past state | | publishStates(states) | Batch (max 100 states per request) | | publishCameraImage(externalId, image) | New image of a camera device (image/jpg;base64,..., ≤ 150 KB, 12 images/minute per device) — the dashboard camera widget updates in real time. Dedicated channel: images never go through publishState | | publishTransports(transports) | Per-device transport status badge ([{ external_id, transport: 'local' \| 'cloud' \| 'unreachable', degraded?, message? }], max 100 per request) — the lightweight path for live cloud/local switches, no need to re-publish the discovered devices. degraded: true + an optional multi-language message flag the "works, but not nominal" state (orange dot on the badge) | | publishSceneEvent(key, data?) | Fires a scene trigger declared in the manifest scene_triggers: something HAPPENED (plate recognized, object detected, doorbell pressed). data is flat — at most 30 keys, one primitive per key (string ≤ 1000 characters, finite number, boolean, null), validated before any request. The core matches it against the filters of the scenes and starts the matching ones; a resolved call means "accepted and evaluated once", never "a scene ran". 404 on an undeclared key, 429 past 300 events/minute per integration | | publishMessage(contactId, text, opts?) | Communication integrations: a message received in the external channel. Gladys resolves the contact to the linked user and routes the message to the brain and the chat history; an unknown (not linked) contact is a 404 — answer "account not linked, code required" in the channel. opts.createdAt timestamps a message received offline. Bidirectional channels only: a send-only channel (messaging.receive: false) is a 403 | | linkContact(code, contactId, name?) | Communication integrations: link an external contact to the Gladys user who generated the code from the UI (single use, 15 min TTL). Resolves with the linked user ({ selector, first_name, language }); an invalid or expired code is a 404 | | getContacts() | Communication integrations: the linked contacts, each with its linked Gladys user | | requestWeatherRefresh() | Weather integrations: fire-and-forget freshness nudge — asks the core to re-pull the weather NOW (through onWeatherGet) and re-evaluate the weather-alert scene triggers, instead of waiting for the 30-minute scheduled check. Carries no data, expects no answer; rate-limited core-side (1/min per integration, silently dropped beyond), dropped silently while disconnected | | requestWidgetRefresh(key) | Dashboard widgets: fire-and-forget freshness nudge for ONE widget — the core drops its cached content and every open instance re-pulls it through onWidgetGet, instead of waiting for the content ttl_seconds. Carries no data; rate-limited core-side (1 per 10 s per widget, silently dropped beyond), dropped silently while disconnected. Live device-bound tiles and charts need no nudge | | getWebhooks() | Gladys Plus webhook state: { available, webhooks: [{ key, mode, url }] } — the ready-to-register public URL of each webhook declared in the manifest. available: false (no Gladys Plus linked) → degrade to poll only | | getConfig() / setConfig(partialConfig) | Configuration values; getConfig also refreshes gladys.config | | getStatus() | Gladys version + integration service status | | setConnectionStatus(connected, message?) | Application-level connection status shown in the Configuration screen (message is an optional multi-language object, e.g. { en: 'Token expired' }). Distinct from the container state machine: a cloud integration can be RUNNING and still disconnected from its third-party service | | getContainers() | Sub-containers declared in the manifest: Docker status, desired state, published ports ({ container_port, protocol, host_port, label, name, browsable }, host_port: null while none is assigned yet), granted/available hardware classes | | startContainer(name, { env }?) | Creates (if needed) and starts a declared sub-container — typically after generating its config files in /data; env carries runtime-computed values (secrets never go through the public manifest) | | stopContainer(name) | Stops a sub-container; the supervisor will not restart it | | restartContainer(name) | Restarts a sub-container, e.g. after rewriting its config through /data | | scanNetwork(type, options?) | On-demand mediated network scan of a capture declared in the manifest network_discovery field (udp-broadcast | udp-active-broadcast | mdns | ssdp); returns the RAW results — parsing them is the integration's job. udp-active-broadcast (query/response, TP-Link Kasa style) additionally takes { port, payload }: the integration forges the request, the core broadcasts it and relays the raw unicast replies | | wakeOnLan(mac, options?) | Sends a standard Wake-on-LAN magic packet from the Gladys core network namespace (bridge containers cannot reach the LAN in broadcast). Requires network_wake: true in the manifest (403 otherwise); the core builds the fixed magic packet itself (never integration-provided bytes) and bounds the rate to 1 wake per 2 s per integration (429 beyond). Options: { address, port, sourcePort } |

Handlers

Register handlers before connect(). Commands are acked automatically: the handler resolves → command-result success:true — and when the resolved value is not undefined, it is sent back in data (for commands that expect an answer) —, it throws → success:false with the error message, no handler registered → success:false "not implemented".

| Handler | Callback signature | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | onSetValue(cb) | (device, deviceFeature, value) => Promise — value is a number, except on the text category features whose commands are strings (the free text of text/text, the selected option value of a text/select dynamic select) | | onPoll(cb) | (device) => Promise — respond by publishing states | | onGetImage(cb) | (device) => Promise<string> — capture and resolve a FRESH camera image (image/jpg;base64,..., ≤ 150 KB); acked back as data.image, awaited under 15 s (not 5 s) so an ffmpeg-style capture fits | | onScanRequest(cb) | () => Promise — respond through publishDiscoveredDevices | | onDeviceCreated(cb) / onDeviceUpdated(cb) / onDeviceDeleted(cb) | (device) => Promise | | onConfigUpdated(cb) | (config) => Promise — complete new values | | onHardwareUpdated(cb) | (containers) => Promise — the hardware grants changed: regenerate the affected configs, then startContainer/restartContainer | | onOAuthAuthorizeUrl(cb) | (key, redirectUri) => Promise<string> — build the provider authorization URL (client_id from the config, scopes, a state you generate and remember). Also called for an account_link field (a provider that never redirects back), with redirectUri undefined and no callback to expect | | onOAuthCallback(cb) | (key, { code, state, redirectUri }) => Promise — verify state, exchange the tokens, store them via setConfig, then setConnectionStatus(true) | | onAction(key, cb) | (fields) => Promise<string \| object> — handler of ONE action declared in the manifest, registered per key; the resolved message is shown under the button (ack awaited under the action's timeout_seconds, not 5 s) | | onSendMessage(cb) | (contact, message) => Promise — communication integrations: deliver message ({ text, file }) in the external channel. contact is the identity resolved by Gladys: { id } for a channel linked by code (messaging.receive: true), or the target user's contact_schema values for a send-only channel (receive: false) | | onWeatherGet(cb) | (options) => Promise<object> — weather integrations (manifest type: "weather"): options = { latitude, longitude, language, units }; resolve the pivot weather format with values in the requested unit system ('metric' or 'us'), it is acked back as data.weather (awaited under 15 s, not 5 s, so a fresh third-party API call fits) | | onWeatherGetImage(cb) | (key) => Promise<string> — weather integrations: resolve the RAW base64 (no data: URI prefix) of a provider image declared in the pivot's images metadata (vigilance map, rain radar…); PNG or JPEG, ≤ 500 KB decoded, acked back as data.image (awaited under 15 s), validated and cached 10 minutes by the core | | onWebhook(key, cb) | ({ method, query, body, contentType }) => Promise — handler of ONE webhook declared in the manifest, registered per key. fire_and_forget: the resolved value is ignored; sync: resolve { status?, contentType?, body? } and it is returned to the third party through Gladys Plus | | onSceneAction(key, cb) | (fields) => Promise<object \| void> — handler of ONE scene action declared in the manifest scene_actions, registered per key, run when a scene reaches it; fields are the RESOLVED values (scene variables substituted, defaults applied, validated by the core). Resolve an object of the declared outputs (scalars only) for the following actions of the scene, or undefined; throwing fails that action only, the scene continues. Ack awaited under the action's timeout_seconds (default 30 s) | | onWidgetGet(key, cb) | ({ settings, language, units }) => Promise<content> — handler of ONE dashboard widget declared in the manifest widgets, registered per key: resolve the content { version?, ttl_seconds?, components } in the core vocabulary, localized from language and units; acked back as data.content (awaited under 15 s), normalized and trimmed to the content budget by the core | | onWidgetGetImage(cb) | (imageKey) => Promise<string> — dashboard widgets: resolve the RAW base64 (no data: URI prefix) of an image key declared in a content; PNG, JPEG or WebP, ≤ 300 KB decoded, ≤ 4096 × 4096 px, cached one hour by key (a changing image needs a changing key). One handler for all keys, awaited under 15 s | | onWidgetAction(key, cb) | (actionKey, params, { settings }) => Promise<string \| object \| void> — dashboard widgets: the user tapped a button carrying an action in the widget's content; params are the ones declared in that content (never user input). Resolve an optional toast message (string, multi-language object or { message }, ≤ 200 characters); the core then drops the cached content so every open instance refetches. Ack awaited under the widget's action_timeout_seconds (default 30 s) | | onWebhookUpdated(cb) | ({ available, webhooks }) => Promise — the Gladys Plus webhook availability changed (Plus linked/unlinked, key changed): re-register the fresh URLs at the third party, or degrade to poll only |

Manifest actions

For on-demand operations with a visible result — connection test, identify, re-pairing, protocol detection… — declare actions in the manifest: each one is rendered as a button (with an optional mini-form, fields) in the Configuration screen. The Tuya-style example: detect the protocol version of a device whose IP was typed by hand because the UDP scan did not find it — a long operation, hence the per-action timeout_seconds (5–120 s, default 30) replacing the standard 5 s ack delay:

"actions": [
  {
    "key": "detect_protocol",
    "label": { "en": "Detect protocol version", "fr": "Détecter la version de protocole" },
    "timeout_seconds": 30,
    "fields": [
      { "key": "ip", "type": "string", "label": { "en": "Device IP" }, "required": true }
    ]
  }
]
gladys.onAction('detect_protocol', async (fields) => {
  const version = await tryProtocolVersions(fields.ip); // your protocol code, can take ~15 s
  return { en: `Protocol ${version} detected`, fr: `Protocole ${version} détecté` };
});

The resolved value — a string or a multi-language object — is displayed under the button; throwing displays the error message instead.

Acting on a specific device: dynamic selects (source: "devices")

A select/multi_select field — in an action's fields or in the manifest config_schema — can replace its static options with "source", a core-defined enum (never a URL nor an expression). V1's only value is "devices": the Configuration screen populates the options with the integration's own created devices (label = device name, value = external_id). This is the answer to "act on THIS device" without asking the user to copy an identifier — the handler receives the chosen external_id like any other field value. Declaring source and options together, or an unknown source value, rejects the manifest.

"actions": [
  {
    "key": "identify",
    "label": { "en": "Identify device", "fr": "Identifier l'appareil" },
    "fields": [
      { "key": "device", "type": "select", "source": "devices", "label": { "en": "Device", "fr": "Appareil" }, "required": true }
    ]
  }
]
gladys.onAction('identify', async (fields) => {
  await blinkDevice(fields.device); // fields.device is the chosen device external_id
  return { en: 'Device identified', fr: 'Appareil identifié' };
});

Onboarding guidance: section intro blocks and the Documentation link

A generated form is compact, but it gives no room for onboarding guidance — the Netatmo-style case: in front of "Client ID", the user must first know they have to create an app on the manufacturer's developer platform. Declare fields of type section in the manifest config_schema (and in an action's fields, which share the format): purely presentational intro blocks that split the form into chapters. Since config_schema is an ordered list, sections naturally structure large forms.

"config_schema": [
  {
    "key": "intro",
    "type": "section",
    "label": { "en": "Getting started", "fr": "Pour commencer" },
    "description": { "en": "Create a developer account to get your API key.", "fr": "Créez un compte développeur pour obtenir votre clé d'API." },
    "links": [ { "url": "https://open-meteo.com/en/docs", "label": { "en": "Open-Meteo docs", "fr": "Doc Open-Meteo" } } ]
  },
  { "key": "api_key", "type": "secret", "label": { "en": "API key" }, "required": true }
]

A section carries a label (multi-language, en mandatory — the chapter title), a plain-text description (multi-language, ≤ 1000 characters per language) and optional links (≤ 5 entries [{ url, label }], https mandatory). The core renders a visual separator + text + links opened in a new tab with the target domain displayed next to the label — no markdown, no HTML (declarative UI principle). Declaring required, default or placeholder on a section, or a non-https url, rejects the manifest.

A section stores no value: its key never appears in gladys.config, getConfig(), onConfigUpdated values or an action handler's fields, and sending it through setConfig is rejected by the host API.

Placeholders in section texts: {{gladys_host}} and {{port:<name>}}

Some integrations have to show the user a URL pointing at Gladys itself — the OCPP case: "configure your charge point to ws://<gladys>:<port>". The server cannot build that address reliably (it does not know which LAN address the user reaches Gladys by: several interfaces, reverse proxy, VPN), but the browser knows it by construction. So the label and description of a section may embed two plain-text tokens, substituted by the Gladys frontend at render time — exact syntax, no space inside the braces, no expression and no injected code (declarative UI principle):

| Token | Substituted with | | ----------------- | ------------------------------------------------------------------------------------------------- | | {{gladys_host}} | The hostname of the address the browser currently uses to reach Gladys | | {{port:<name>}} | The host port Gladys assigned to the declared sub-container port carrying that name (see below) |

"containers": [
  {
    "name": "ocpp",
    "docker_image": "ghcr.io/acme/ocpp:1.2.0",
    "ports": [{ "container_port": 9000, "name": "ocpp", "label": { "en": "OCPP endpoint" }, "browsable": false }]
  }
],
"config_schema": [
  {
    "key": "charge_point",
    "type": "section",
    "label": { "en": "Connect your charge point" },
    "description": {
      "en": "Point your charge point to ws://{{gladys_host}}:{{port:ocpp}}/",
      "fr": "Pointez votre borne vers ws://{{gladys_host}}:{{port:ocpp}}/"
    }
  }
]

Rules to know when writing the manifest:

  • a {{port:<name>}} that references a name declared nowhere in the manifest rejects the manifest (indexer and server, like any structural error) — an unknown reference would sit unresolved on screen forever;
  • {{gladys_host}} works in every section the engine renders (config_schema, action fields, contact_schema), since the browser resolves it whatever the user's role; {{port:<name>}} is refused in contact_schema: that per-user block is the one screen a non-admin reaches, and their reduced view carries no container state, so the token would resolve for an admin and stay raw for everyone else;
  • a valid {{port:<name>}} whose port has no assigned host port yet (sub-container never started) is left as-is on screen — honest and debuggable, it resolves the next time the screen is loaded after the allocation. Start the sub-container that publishes the port before pointing the user at the sentence;
  • browsing through Gladys Plus or a reverse proxy, {{gladys_host}} resolves to the tunnel/proxy hostname, not to the instance's LAN address — if the device must reach Gladys over the LAN, say so in the repo documentation.

For the long step-by-step (screenshots…), the right medium stays the mandatory repo documentation (docs/en.md + docs/fr.md): the Configuration screen now shows a permanent "Documentation" link to it (re-hosted, user language with en fallback) — it is when configuring that the user needs it most.

OAuth2 cloud services

For cloud services that need a browser authorization (Netatmo-style), declare a field of type oauth2 in the manifest config_schema: the Configuration screen renders a "Connect" button, and Gladys relays the whole flow to the integration — the Gladys server knows no provider.

let state;

gladys.onOAuthAuthorizeUrl(async (key, redirectUri) => {
  // Build the URL yourself: client_id from your config, your scopes, and an
  // anti-CSRF `state` you generate and remember for the callback.
  state = crypto.randomUUID();
  return `https://api.netatmo.com/oauth2/authorize?client_id=${gladys.config.client_id}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read_station&state=${state}`;
});

gladys.onOAuthCallback(async (key, { code, state: returnedState, redirectUri }) => {
  if (returnedState !== state) throw new Error('state mismatch');
  const tokens = await exchangeCodeForTokens(code, redirectUri); // your provider call
  // Store the tokens as config keys OUTSIDE the config_schema: free internal
  // storage, never shown in the UI, never sent through the front.
  await gladys.setConfig({ access_token: tokens.access_token, refresh_token: tokens.refresh_token });
  await gladys.setConnectionStatus(true);
});

Token refresh stays the integration's job; when the token expires beyond repair, report it so the user sees it in the UI instead of a silently broken integration:

await gladys.setConnectionStatus(false, { en: 'Token expired, please reconnect.', fr: 'Token expiré.' });

Some providers link an account without ever redirecting back to Gladys — a QR sign-in approved in the vendor app (Xiaomi Home style), a pairing confirmed on a device. Declare the field as account_link instead of oauth2: the Configuration screen renders the same "Connect" button and onOAuthAuthorizeUrl is called the same way, but redirectUri is undefined (there is none), no anti-CSRF state is needed (there is no round trip to protect) and onOAuthCallback is never called. Return the provider sign-in URL, watch for the approval yourself (long-poll the provider), then report it through setConnectionStatus(true) — that is what drives the connection badge.

Incoming webhooks through Gladys Plus

Some cloud services push their events by webhook (Netatmo-style: a setpoint change arrives in ~2-3 s instead of the next poll) — but a local Gladys is not reachable from the Internet. Declare the webhooks in the manifest (≤ 3 entries) and Gladys Plus relays them to the integration, without knowing anything about it:

"webhooks": [
  { "key": "events", "label": { "en": "Netatmo events" }, "mode": "fire_and_forget" },
  { "key": "callback", "label": { "en": "Subscription callback" }, "mode": "sync" }
]

The user pastes their Gladys Plus Open API key in the "Gladys Plus webhooks" block of the Configuration screen (rendered by the core when the manifest declares webhooks), and Gladys builds the public URLs. The integration registers them at the third party — the Netatmo pattern: re-register on every successful connection, best effort:

const registerWebhooks = async () => {
  const { available, webhooks } = await gladys.getWebhooks();
  if (!available) return; // no Gladys Plus linked: poll only
  const events = webhooks.find((w) => w.key === 'events');
  await thirdPartyApi.addWebhook(events.url); // your provider call
};

gladys.on('connected', registerWebhooks);
gladys.onWebhookUpdated(registerWebhooks); // Plus linked/unlinked, key changed

gladys.onWebhook('events', async ({ body }) => {
  // Doctrine "trigger, not data": events arrive duplicated, late or out of
  // order, and their payloads are partial — use them to TRIGGER a refresh
  // through the manufacturer API, never apply the payload as a state. That is
  // also what makes lost events painless: the poll stays the source of truth.
  await refreshFromApi();
});

Two modes, matching what exists in the field. fire_and_forget (default, the Netatmo-style event stream): the third party only awaits an acknowledgment — Gladys answers immediately and relays asynchronously; the handler's resolved value is ignored and its errors are swallowed. sync (challenge/response registrations, Strava/Microsoft Graph style): the caller awaits the integration response — resolve with { status?, contentType?, body? } (status 200-499, body ≤ 64 KB) and it is returned verbatim to the third party; resolving undefined or throwing lets Gladys answer its default empty 200:

gladys.onWebhook('callback', async ({ query }) => ({
  status: 200,
  contentType: 'application/json',
  body: JSON.stringify({ 'hub.challenge': query['hub.challenge'] }),
}));

Security, stated honestly: the URL is the secret (payloads are not authenticated — verifying the provider signature, when one exists, is the integration's job), and requires a Gladys with webhook-relay support (check the gladys_version range of your manifest).

Communication channels

Messaging channels are integrations of manifest type: "communication": no Devices/Discovery screens, and the integration exchanges messages through the host API. The manifest declares which of the two families the channel belongs to — sending is always present, receiving is not:

"messaging": { "receive": true }
  • Bidirectional chat channels (receive: true — Telegram-like bots: Matrix, Signal, WhatsApp…): the user links their account by code from the Configuration screen, then speaks to the brain from the channel.
  • Send-only notification channels (receive: false — Free Mobile SMS, CallMeBot…): no incoming path exists. Each user enters their own credentials in the "My account" block of the Configuration screen, described by the manifest contact_schema (same flat format as config_schema); Gladys passes them to the integration with every outgoing message. No linking code — there is no channel to send it through, and no user authority to protect (the 403 on publishMessage guarantees a notification channel never talks to the brain).

The identity handling follows: onSendMessage(contact, message) receives the identity resolved by Gladys — { id } (the linked contact id) for a bidirectional channel, or the target user's contact_schema values for a send-only one. Users without a linked account or configured credentials are skipped by Gladys and never reach the handler.

A send-only channel is just the outgoing block (the Free Mobile-style case):

"messaging": { "receive": false },
"contact_schema": [
  { "key": "username", "type": "string", "label": { "en": "Free Mobile login" }, "required": true },
  { "key": "access_token", "type": "secret", "label": { "en": "SMS API key" }, "required": true }
]
gladys.onSendMessage(async (contact, message) => {
  // contact = the target user's contact_schema values.
  await sendFreeMobileSms(contact.username, contact.access_token, message.text);
});

A bidirectional channel adds the linking and incoming blocks:

  • Linking — the consent step. The user clicks "Link my account" in the Gladys UI, which shows a short code (single use, 15 minutes TTL); they send it to the bot in the external channel, and the integration relays it with linkContact(code, contactId, contactName?). From then on the contact speaks with the authority of the linked user (trigger scenes, ask about the house…) — which is exactly why the code flow exists. The user can revoke the link from the same screen at any time.
  • Incoming — publishMessage(contactId, text): Gladys resolves the contact to the linked user and routes the message to the brain and the chat history; the reply comes back through onSendMessage. An unknown contact is rejected with a 404: catch it and answer "account not linked" with the linking instructions.
gladys.onSendMessage(async (contact, message) => {
  await bot.sendMessage(contact.id, message.text); // message.file: attached image (base64) or null
});

bot.on('message', async (chatId, text) => {
  if (looksLikeLinkCode(text)) {
    const user = await gladys.linkContact(text.trim(), chatId, await bot.getChatName(chatId));
    await bot.sendMessage(chatId, `Linked to ${user.first_name}!`);
    return;
  }
  try {
    await gladys.publishMessage(chatId, text);
  } catch (e) {
    if (e.status === 404) {
      await bot.sendMessage(chatId, 'Account not linked: get a code from the Gladys UI and send it to me.');
    } else {
      throw e;
    }
  }
});

Texts are limited to 4096 characters. getContacts() lists the linked contacts (with their linked Gladys user), e.g. to resynchronize the channel-side state after a restart. Requires a Gladys with communication-integrations support (check the gladys_version range of your manifest).

Weather providers

Weather providers (Météo France, Open-Meteo, AccuWeather…) are integrations of manifest type: "weather": no Devices/Discovery screens (like communication channels), no devices and no states — a dedicated provider API. The integration answers the core's weather requests, and Gladys feeds the dashboard weather widget and the chat assistant with them. Installing a weather integration takes precedence over the built-in OpenWeather service with zero configuration; stopping or uninstalling it falls back automatically.

Everything goes through one handler:

gladys.onWeatherGet(async ({ latitude, longitude, language, units }) => {
  const data = await fetchProviderForecast(latitude, longitude, language, units); // your provider code
  return {
    // Required: temperature, weather (condition), datetime.
    temperature: data.current.temperature,
    weather: WEATHER_CONDITIONS.RAIN,
    datetime: new Date().toISOString(),
    // Optional current fields, dropped when your provider lacks them:
    apparent_temperature: data.current.feelsLike,
    humidity: 80, // percentages are 0-100
    wind_speed: 4.2,
    uv_index: 3,
    sunrise: data.current.sunrise,
    sunset: data.current.sunset,
    is_day: data.current.isDay, // strict boolean; drives the day/night icon variant
    // Forecasts (≤ 24 hours, ≤ 8 days kept by Gladys):
    hours: data.hours.map((h) => ({ temperature: h.temp, weather: toCondition(h), datetime: h.time })),
    days: data.days.map((d) => ({ temperature_min: d.min, temperature_max: d.max, datetime: d.date })),
    // CAP-style alerts (≤ 10; Météo France vigilance: yellow → moderate, orange → severe, red → extreme):
    alerts: [
      { severity: WEATHER_ALERT_SEVERITIES.SEVERE, event: 'Orages violents', type: WEATHER_ALERT_TYPES.THUNDERSTORM },
    ],
  };
});

The contract, point by point:

  • units is the requesting user's preference — 'metric' (°C, m/s, hPa, mm, km) or 'us' (°F, mph, in, mi): return values in that unit system. Percentages (humidity, cloud_cover, precipitation_probability) are always 0-100, never fractional.
  • weather is a condition of the pivot enum (WEATHER_CONDITIONS): clear | partly-cloudy | cloud | fog | drizzle | rain | pouring | sleet | hail | snow | thunderstorm | wind | night | unknown — map your provider's codes to it; anything else is coerced to unknown by the core (neutral icon).
  • is_day carries the day/night signal (optional strict boolean on the current conditions and each hours entry — anything else is dropped, never coerced; absent → rendered as day): weather keeps the meteorology, is_day drives the day/night rendering variant. The night condition stays accepted for compatibility but is deprecated for providers — a rainy night is weather: 'rain', is_day: false, not 'night'.
  • Alerts can carry a phenomenon type (WEATHER_ALERT_TYPES): wind | rain | flood | thunderstorm | snow | heat | cold | avalanche | coastal | fog — so the core can translate and iconify the alert where the free-text event cannot. Optional metadata: an invalid type is dropped by the core, the alert is kept and rendered from its event text alone.
  • The ack is awaited under 15 s (not the standard 5 s), so a fresh third-party API call fits. Throwing — provider not configured, API down — acks the command as failed, and the Gladys provider loop falls through to the next available provider.
  • The payload is normalized and bounded by the core: unknown fields are dropped, numbers must be finite, dates must parse, arrays are capped (24 hours, 8 days, 10 alerts, 3 images), alert strings are truncated (event ≤ 100 characters, description ≤ 5000 — CAP descriptions run long). days may or may not include the current day — consumers filter by calendar date, a provider never has to lead with today.

Two optional extensions complete the type:

  • Provider images (vigilance map, rain radar, satellite view…) — the payload only ever declares metadata: images (≤ 3 entries of { key, label? }, key matching ^[a-z0-9][a-z0-9-]{0,31}$, label a multi-language object with values ≤ 50 characters). The bytes travel on demand through onWeatherGetImage: resolve the RAW base64 (no data: URI prefix) of a PNG or JPEG of at most 500 KB decoded — the core checks the magic numbers and the size, caches the validated image 10 minutes per key, and serves it to the browser from its own origin (the browser never loads a third-party URL).

    gladys.onWeatherGetImage(async (key) => {
      const png = await fetchVigilanceMap(); // your provider code, returns a Buffer
      return png.toString('base64');
    });
  • The freshness nudge — Gladys evaluates its weather-alert scene triggers on a 30-minute scheduled check (pulled through onWeatherGet, diffed on the normalized alerts). A provider that KNOWS something changed upstream can do better — never by pushing data: requestWeatherRefresh() only means "re-pull me now". The data re-enters through the audited onWeatherGet path; the nudge itself carries nothing (fire-and-forget, rate-limited core-side to 1/min per integration, silently dropped beyond). The Météo France pattern: poll the vigilance upstream, nudge on change — the scene fires seconds later instead of within 30 minutes.

    onUpstreamVigilanceChange(() => gladys.requestWeatherRefresh());

Requires a Gladys with weather-integrations support (check the gladys_version range of your manifest).

House location

An integration that owns its own geo-dependent logic (water restrictions, pollen, air quality…) polls a third party at its own pace and publishes devices and states through the generic path — so it pulls the location itself instead of re-asking it in its config. Declare "location": true in the manifest (shown on the install screen: the home location is sensitive personal data) and fetch the houses at startup and on reconnection — coordinates change rarely, there is no update event:

gladys.on('connected', async () => {
  const houses = await gladys.getHouses(); // [{ id, name, selector, latitude, longitude }], sorted by name
  const located = houses.filter((house) => house.latitude !== null); // several houses, some maybe unlocated
  await refreshForecasts(located);
});

Only these five fields are ever returned — never the alarm mode or code. Without location: true the call is a 403. A type: "weather" integration needs neither: the core owns that use case and passes the coordinates of the house in the options of every onWeatherGet call.

Scene triggers and actions

An integration can extend the scene editor without a core update: declare scene_triggers (what happens — a licence plate recognized, an object detected, a doorbell pressed, a mail received) and scene_actions (an operation with parameters and a result — take a snapshot, clean these rooms, announce a text) in the manifest, with the same flat field format as the config_schema. The core renders the cards in the scene editor, matches the events against the filters the user configured, and relays the actions; the integration never learns which scenes exist.

"scene_triggers": [
  {
    "key": "object_detected",
    "label": { "en": "Object detected", "fr": "Objet détecté" },
    "fields": [
      { "key": "camera", "type": "select", "source": "devices", "label": { "en": "Camera" }, "required": true },
      { "key": "label", "type": "multi_select", "label": { "en": "Object types" },
        "options": [{ "value": "person", "label": { "en": "Person" } }, { "value": "car", "label": { "en": "Car" } }] }
    ],
    "variables": [
      { "key": "label", "type": "string", "label": { "en": "Object type" } },
      { "key": "score", "type": "number", "label": { "en": "Confidence" } }
    ]
  }
],
"scene_actions": [
  {
    "key": "create_snapshot",
    "label": { "en": "Take a snapshot", "fr": "Prendre un instantané" },
    "timeout_seconds": 20,
    "fields": [
      { "key": "camera", "type": "select", "source": "devices", "label": { "en": "Camera" }, "required": true },
      { "key": "caption", "type": "string", "label": { "en": "Caption" } }
    ],
    "outputs": [{ "key": "clip_id", "type": "string", "label": { "en": "Clip identifier" } }]
  }
]
// Something happened → fire the trigger. `data` is flat: the core builds the
// matcher's filters from the declared `fields` and the scene variables from
// the declared `variables` ({{triggerEvent.data.label}}); every other key is
// dropped, a declared key absent from `data` is null.
frigate.on('object', async (event) => {
  await gladys.publishSceneEvent('object_detected', {
    camera: gladys.externalId(`cam:${event.camera}`),
    label: event.label,
    score: event.score,
  });
});

// A scene reached the action → run it with the RESOLVED fields (scene
// variables substituted, defaults applied, validated by the core), and return
// the declared outputs for the following actions of the scene.
gladys.onSceneAction('create_snapshot', async (fields) => {
  const clipId = await frigate.snapshot(fields.camera, fields.caption); // your code
  return { clip_id: clipId };
});

The doctrines to know, in numbers:

  • State vs event — a value (a temperature, a switch, a presence) is a device feature published with publishState; an event says "this happened, with these details" and never sets a state. Needing > on an event value is the sign the value is a state. data is flat and bounded: ≤ 30 keys, one primitive per key (string ≤ 1000 characters, finite number, boolean, null) — a snapshot goes through publishCameraImage, never here.
  • One event per transition — "object entered", debounced upstream, never one event per frame: the core admits 300 events per minute per integration (429 beyond, a counter separate from the states'), sized for a fleet of cameras, not for a stream. A resolved call means "accepted and evaluated once", never "a scene ran".
  • Outputs are scalars — an identifier, a count, a short text (strings capped at 10 000 characters). A picture produced by an action is published on a camera device with publishCameraImage and consumed by the core's "send camera image" scene action. Resolving undefined means no outputs; throwing fails that action only — the scene logs it and continues, a scene action is never a condition (declare an output and let the scene author gate on it).
  • Keys are forever — a published key is never renamed (a renamed key is a removed key for every scene using it); a declaration grows in the normal case; removing a key, or adding a required action field without a default, is a breaking update (orphan cards in the editor, 404 on the event, failing action).
  • No loops — never fire an event as a consequence of a received action: a scene bound to that event would loop through the integration.

The ack of a scene action is awaited under its declared timeout_seconds (5–120, default 30) — a deadline that starts when the scene reaches the action, connection wait included. Requires a Gladys with scene-declarations support (check the gladys_version range of your manifest).

Dashboard widgets

An integration can put its own data on the dashboard without a dedicated core widget: declare up to 5 widgets in the manifest (identity: key, label, icon, per-instance settings in the config_schema grammar), and produce the content at runtime in a declarative vocabulary the core renders — no HTML, no iframe, no CSS: the core guarantees theme, dark mode, responsiveness and translations for every widget, third-party ones included.

"widgets": [
  {
    "key": "vacuum",
    "label": { "en": "Robot vacuum", "fr": "Aspirateur robot" },
    "icon": "wind",
    "settings": [
      { "key": "vacuum", "type": "select", "source": "devices", "label": { "en": "Vacuum" }, "required": true }
    ],
    "action_timeout_seconds": 30
  }
]
import { WIDGET_COLORS } from '@gladysassistant/integration-sdk';

gladys.onWidgetGet('vacuum', async ({ settings, language, units }) => {
  const state = await robot.getState(settings.vacuum); // your code; settings.vacuum = the chosen device external_id
  return {
    ttl_seconds: 30, // how fast the data moves (10-3600, default 60)
    components: [
      { type: 'value', label: { en: 'Battery' }, device_feature: `${settings.vacuum}:battery` }, // live tile
      {
        type: 'status',
        items: [{ label: { en: 'State', fr: 'État' }, value: state.label[language], color: WIDGET_COLORS.SUCCESS }],
      },
      { type: 'image', key: `cleaning-map-${state.mapHash}`, alt: { en: 'Last cleaning map' } },
      { type: 'button', label: { en: 'Start' }, style: 'primary', action: { key: 'start', params: { mode: 'full' } } },
      { type: 'button', label: { en: 'Dock' }, device_feature: `${settings.vacuum}:dock`, value: 1 },
    ],
  };
});

gladys.onWidgetGetImage(async (imageKey) => {
  const png = await robot.getMap(imageKey); // your code, returns a Buffer
  // The core refuses (never recompresses) anything over 300 KB or 4096 px: resize integration-side.
  return (await sharp(png).resize({ width: 800 }).webp().toBuffer()).toString('base64');
});

gladys.onWidgetAction('vacuum', async (actionKey, params, { settings }) => {
  await robot.send(settings.vacuum, actionKey, params); // your code
  return { en: 'Cleaning started', fr: 'Nettoyage lancé' }; // optional toast
});

robot.on('state', () => gladys.requestWidgetRefresh('vacuum')); // "re-pull me now", rate-limited 1 per 10 s

The vocabulary (eight component types, every text a plain string or a multi-language object with en):

| type | Fields | Renders as | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | text | text, variant (heading ≤ 40 | body ≤ 300, default | caption ≤ 80) | escaped plain text; body honors line breaks | | value | value (number, or string ≤ 12), unit ≤ 6, label ≤ 24, icon, color — or