Pebble Plugins Preview

Pebble Developers

31 min read Original article ↗

Version: this guide describes mobile app version 1.18.0.1. Anything marked Next release is in the next release after it. See the changelog for what changed in each version.

Plugins are nearly here (finally). A plugin is a small piece of JavaScript that runs on the phone and:

  • provides data: (sources) that any Pebble watchface/app (and Index) can read
  • takes actions: turn a light off, add a task, that a watchface/app (and Index) can trigger

It has a generic API, designed to allow any type of data to be exposed, in multiple formats.

They're supported in the Pebble mobile app (1.16 and later), but are in preview, and disabled by default.

Why?

  • A watchface that shows the weather shouldn't need its own weather API and key. The Pebble app already fetches weather for the user's saved locations; as a plugin, any app can display that.
  • If you have an interesting data source (say, a blood sugar monitor, or next-bus data), users can show it on whichever watchface they like instead of the one design you made.
  • Plugins let us expose data the Pebble app has but developers couldn't get at before very easily: music, notifications, phone state, and more.
  • Actions let a watch (and Index) control things like lights or tasks through a plugin, without every app integrating with every service.
  • Index uses plugins too, so adding a to-do list service (like the Todoist, TickTick and Notion demos) is just a plugin, with no change to the Pebble app.

What can I try now?

  • Try the example plugins, watchface and app.
  • Build a watchface or app that uses plugin data.
  • Build a plugin.

First, enable plugins in the mobile app (1.16 or later): Settings → Plugins → Plugins / permissions preview. A Plugins tab appears in the Apps screen listing plugins and their settings pages. The demo apps below are on the app store.

Note: apps/plugins built for versions before 1.16 may not work, as there have been many changes to both in 1.16.

Try the demo apps and plugins

Source for each is in coredevices/pebble-apps:

  • weather-face ("Weather Face (Plugin Demo)"): a watchface built entirely from a weather plugin: any installed one (the built-in one, met-weather, or yours), picked on its settings page.
  • complication-face ("Complication Face (Plugin Demo)"): the time and date with two slots, a number and a bitmap, filled from the user's preferred readings (preferences/complications): each takes the first entry offering its shape that the other hasn't taken.
  • met-weather: weather for your weather locations from MET Norway (api.met.no), in your units, an alternative to the built-in weather plugin. It can also be the watch's weather provider (Settings → Weather → Weather provider). Shows Pebble.subscribeSource, navigator.geolocation and drawing condition images in JS.
  • plugin-test ("Dashboard (Plugin Demo)"): a watchapp with eight tiles, each set from its settings page to any reading from any installed plugin, which shows what data the example and built-in plugins offer. Taps can take actions, and a tile can be a button for any action that takes no arguments (e.g. "Back", the default, goes to the previous app). A tile can also show the date, from the watch's own clock.
  • stocks: share prices for tickers you pick on its settings page.
  • hue: Control Philips Hue lights on your local bridge (pair from its settings page).
  • notion, ticktick, todoist: examples of plugins that sign in with OAuth.

Concepts

Sources and actions

A source is something an app can read from a plugin, addressed in layers:

category            home
  item              room_lights        the kind of thing
    instance        "3"                one particular room (its name property: "Kitchen")
      property      brightness         one reading of it
        shape       numericValue -> { value: 64, unit: "%", min: 0, max: 100 }

You can subscribe to an item and get every property of every instance of it, and updates when they change. Instances have a stable instanceId; name is the property to display for one.

An action is something a plugin can do (set_on, add_task). It takes JSON arguments described by a JSON Schema in the manifest, and can say which sources it affects (targets), so a tile showing a light knows which actions apply to it.

Multiple instances

Updates carry instances: [...], one per thing (a room, a saved location), in an order that means something: instances[0] is the first (example). supportsMultiple in a manifest just tells a settings page to offer a picker. Many readings of one thing are a series instead.

Categories

Apps ask for a category and item, not a plugin (weather/location, not "AcmeWeather"). The host serves it from whichever installed plugin provides it.

Category, item and property names are free-form, so a plugin can offer any kind of source or action. But swapping one plugin for another only works if plugins serving the same category use the same names. So if an existing category fits, like weather, match its item and property names rather than inventing your own. We'll publish the names/shapes for common categories.

Examples now:

Category Items From
weather location built-in
calendar event built-in
phone phone_state, find_my_phone built-in
music track built-in (Android)
notifications notification built-in (Android)
watch switch_setting, number_setting, text_setting, app_setting, app built-in
home home_lights, room_lights, light Hue demo
finance stock Stocks demo
notes destination, configured Notion demo
reminders list, configured Todoist, TickTick demos
agent chats, configured
preferences units, complications, weather_location built-in

Phone. On iOS, phone/phone_state's battery_level only changes in steps of 5%, because iOS doesn't report it any more finely.

Weather. weather/location serves one instance per place, with the user's weather locations (preferences/weather_location) under the same instanceIds. Temperatures are numericValues in °C or °F (say which in unit), with shortText/longText in the user's units. Series start at the place's local midnight: stepSec 3600 for hourly_*, 86400 for daily_*.

Property Shapes
name, temperature, feels_like, high, low shortText, longText, numericValue high/low for the local day
condition, condition_code shortText, longText (+ icon, image) codes: sun, partly_cloudy, cloudy, light_rain, heavy_rain, light_snow, heavy_snow, rain_and_snow, unknown
uv_index, precipitation (chance, %), humidity (%) numericValue, shortText
wind_speed (m/s, km/h, mph or knots), wind_direction (degrees it's from) numericValue
utc_offset (minutes east of UTC) numericValue
sunrise, sunset timestamp today's
hourly_temperature, hourly_condition, hourly_condition_code series
daily_high, daily_low, daily_condition, daily_condition_code, daily_precipitation, daily_sunrise, daily_sunset series (timestamp for the sun)

The built-in weather plugin serves all of these, from whichever provider the user chose.

A weather plugin the user picks as their weather provider (Settings → Weather) feeds the watch's weather app and timeline pins, as well as apps. For that it needs at least temperature, condition_code, utc_offset, and today's and tomorrow's daily_high, daily_low, daily_condition_code, daily_sunrise and daily_sunset; the rest fills in what the watch shows if it's there. A place it doesn't serve, or a read that fails, falls back to Pebble's own.

A plugin is a notes / reminders / agent destination for Index when it declares a create_note / create_reminder / send_to_agent action and serves notes/configured / reminders/configured / agent/configured: one instance whose configured boolean is true once the user has set everything the action needs. While it's false, the destination shows a Connect button that opens the plugin's settings page.

send_to_agent takes { message, chat_id? } and forwards the message to an external agent. To offer a choice of chats, serve agent/chats (one instance per chat: its id as instanceId, its nickname as name); the chosen one is passed as chat_id. The Index agent can also call send_to_agent as a tool, so the user can ask for something to be sent to the agent by name.

The built-in Watch State plugin has two actions, both needing LaunchApps: launch_app ({ uuid }) opens an app or watchface in the user's locker, and launch_previous_app goes back to whatever ran before the current app. It also has a watch/app source, needing ReadApps: one instance per app or watchface in the locker that can be opened on the watch (plugin-only apps are left out), with the app's uuid as its id, its name, and its type (watchapp or watchface). launch_app targets it, taking the app's uuid as instanceId as well as uuid, so a tile showing an app can open it.

Shapes

A property isn't typed as "a number" or "a string". It offers one or more shapes: fixed types that a consuming app can be confident of being able to render. The plugin sends every shape it can, and you pick one.

Shape For Payload
shortText up to ~7 characters { text: "72°F" }
longText a line of text { text: "Partly cloudy" }
numericValue a number, with a range if it has one { value: 84, unit: "%", min: 0, max: 100 }
timestamp a moment, in epoch seconds UTC { value: 1716940800 }
boolean on/off state { value: true }
icon a monochrome glyph { pixels, palette, width, height }
image a colour bitmap { pixels, palette, width, height }
  • Every shape of a property is the same reading, so an app can fill a slot from whichever shape fits. A temperature property is { shortText: { text: "72°F" }, longText: { text: "72°F" }, numericValue: { value: 72, unit: "°F" } }. Its longText is never "Partly cloudy, 72°F": the condition is its own property.
  • Some shapes carry optional metadata (unit, min, max) so an app can render the data in context.
  • icon and image are 4-bpp palettised bitmaps (base64), only produced if you ask for them with iconPixelSize.

A missing property or shape means the plugin can't serve it right now. Check before drawing.

Series

A property can also be a series: many readings of one thing along an axis, like the temperature for each hour ahead. Its value is a series wrapping the shapes it can be drawn as, each with values in place of its single reading:

hourly_temperature: {
  series: {
    start: 1729468800,   // epoch seconds UTC of values[0] (optional, with stepSec)
    stepSec: 3600,
    numericValue: { values: [14, 15, null, 16], unit: "°C" },   // unit, min, max are shared
    shortText:    { values: ["14°C", "15°C", null, "16°C"] },
  },
},
  • Every values array is the same length, and null is a missing point. icon and image series carry one bitmap per point.
  • Use start and stepSec to find now in the series, rather than assuming values[0] is.
  • A series is declared as series.<shape>, e.g. "hourly_temperature": ["series.numericValue", "series.shortText"]. Any shape can be one, timestamps included: "daily_sunrise": ["series.timestamp"] is { "series": { "start": …, "stepSec": 86400, "timestamp": { "values": [1798354800, …] } } }.
  • The built-in weather plugin offers hourly_* and daily_* series on weather/location.

Shapes are designed so you can build for a slot in your design rather than for specific data. A face with a gauge widget can offer the user anything that has a numericValue with a range: battery, UV index, a light's brightness. A small text slot can take any shortText, whatever it came from. An icon suits a small monochrome mark beside a reading, and an image suits a full-colour one.

Permissions

Plugins introduce a fine-grained permission system, covering your plugin and your app's PKJS. It's only in place when plugins are enabled.

Declaring

Declare what your app and its plugin need once, in the pebble block of package.json:

"pebble": {
  "usesPermissions": [
    {
      "name": "Location",
      "reason": "To show the weather where you are",
      "required": true
    },
    {
      "name": "LocalNetwork",
      "reason": "To find your lights on your home network",
      "required": false
    },
    {
      "name": "Internet",
      "parameters": { "domains": ["api.example.com"] },
      "reason": "To get the forecast",
      "required": true
    },
    {
      "name": "Internet",
      "parameters": { "domains": ["radar.example.com"] },
      "reason": "To show the rain radar",
      "required": false
    }
  ]
}

1.18.0.1: give every permission a reason and say whether it's required:

  • reason: what your app does with it, in a short sentence: "To show the weather where you are", not "Accesses your location" (the permission's name already says that). Users see it wherever they decide; past two lines it's cut off.
  • required: whether your app works without it. Installing waits for an answer on required ones; optional ones can wait until your app first uses them.

If no entry says required, installing asks about all of them. Older app versions ignore both.

Each entry's reason and required cover all its parameters, so give a domain (or a Notifications package) used for something different its own entry, as with the radar above.

The built-in permissions are Internet, LocalNetwork, Location, WatchMicrophone, Notifications, Calendar, Contacts, Timeline, ReadWatchSettings/WriteWatchSettings, ReadMusic/ControlMusic, LaunchApps, ReadApps, FindMyPhone, ReadFiles/WriteFiles, LegacyPebbleKit, Health and ShowNotifications (showing notifications on the watch, including Pebble.showSimpleNotificationOnPebble and a pin's notifications).

  • Each Internet domain is its own permission and covers its subdomains. LocalNetwork covers devices on the LAN.
  • Notifications is always asked about. It can be narrowed to some apps (Android): { "name": "Notifications", "parameters": { "packages": ["com.google.android.apps.maps"] } }. Each package is its own permission, and you only see those apps' notifications (by their package property). Users are far more likely to allow one app's notifications than all of them. A notification holding a one-time code reaches you as "Sensitive content".
  • ReadFiles and WriteFiles let your settings page ask the user for a file or folder. They take no parameters: you can't name paths in the manifest. Each entry the user picks is its own permission (ReadFiles/<path>, or WriteFiles/<path> for read-write), listed on the app's page like any other, and nothing is granted without a pick. WriteFiles includes reading: declare both to let the user allow reading but not writing, and allowing WriteFiles allows ReadFiles with it. See File access.
  • LegacyPebbleKit lets your watchapp talk to a companion app over PebbleKit Classic. A PebbleKit 2 companion doesn't need declaring: each package your appinfo lists is asked about on its own.
  • WatchMicrophone allows watch microphone streaming to PebbleKit JS and local mic_capture_* capture (SDK revision 111). See {% guide_link events-and-services/microphone %}.
  • Health isn't enforced yet: health data is read on the watch, so enforcing it needs permissions synced to the watch, which isn't built yet. Declare it anyway if you use health data.

The SDK marks the pbw as using permissions (manifestVersion: 2 in its appinfo). Without that it's a legacy app, which keeps what apps had before permissions: internet access, location and timeline pins if it has PebbleKit JS, and LegacyPebbleKit if it has an Android companion app. Users can have legacy apps ask too, with Ask before older apps use permissions in settings.

Sources and actions guard themselves with caller permissions: what an app must hold to use them (calendar/event needs Calendar, weather/* needs Location). They take the same parameters as usesPermissions. A caller holding only some apps' Notifications is refused by a source that needs all of them, unless its instances have a package property to filter by.

A plugin can also define its own permissions, to guard its sources and actions:

"plugin": {
  "definesPermissions": [{ "name": "HomeControl", "label": "Control your lights", "kind": "write" }],
  "actions": [{ "name": "set_on", "callerPermissions": ["HomeControl"], ... }]
}

kind is read, write, exfiltrating or dangerous, and decides when users are asked, as for a built-in permission. Users are always asked about a dangerous one, whatever their settings. The name is scoped to your plugin, and users see your label with where it came from ("Control your lights (via Hue)").

Actions get one more check: if your plugin can send data off the phone, an app invoking its actions needs the user's permission for that too (InvokeAction), since the arguments go wherever your plugin can send them.

To use another plugin, declare its permissions in your usesPermissions with its uuid, so users can approve them when installing your app (if that plugin is already installed):

"usesPermissions": [
  { "name": "HomeControl", "plugin": "<Hue's uuid>", "reason": "To turn your lights on at sunset", "required": true },
  { "name": "InvokeAction", "plugin": "<Hue's uuid>", "reason": "To turn your lights on at sunset", "required": true }
]

Approval

Network access and companion apps let data leave the phone, so they're what make reading someone's data risky. Those, and permissions that change things (Timeline, ShowNotifications, WriteWatchSettings, ControlMusic, LaunchApps), need the user's approval unless they've set apps to never ask. Notifications always needs it. Reading is up to a setting; by default:

  • An app that can send data off the phone has the user approve each permission: the ones it declares when it's installed, and anything else (an undeclared permission, or a new domain) when it first uses it.
  • Any other app can read without asking, since the data can't go anywhere.

While your app is running (or, for a plugin, while something reads from it), a request shows the user a notification and a card in the Pebble app (1.18.0.1: an optional permission only once your app uses it). Until they answer, it fails with PERMISSION_DENIED (a blocked fetch rejects with a NotAllowedError), possibly after a few seconds. Try again later rather than treating it as permanent.

Users can change any permission on the app's page (or its settings page's title bar), and choose in settings when apps have to ask. Their answers follow their account to other phones, and apps added from the web store are reviewed there.

Declare everything you need up front, with a reason, and keep Internet to the domains you use: fewer prompts, and users can see what you'll do. An app holding Location whose only network access is api.openweathermap.org can't send the location anywhere else. An undeclared permission is still asked about when used, but with nothing but its name to explain it.

Checking a permission

Your plugin or PKJS can check one of its own permissions with Pebble.permissions.query, which resolves to:

  • "granted": using it will work, because the user allowed it or their settings allow it.
  • "prompt": the user has to allow it first, so tell them to, in the Pebble app.
  • "denied": the user turned it off for your app.
const state = await Pebble.permissions.query("Internet/api.example.com");

It takes a permission as you'd declare it (Location, Internet/<domain>, Notifications/<package>, ...) and doesn't count as using it. The answer is only true right now: it can change without the user doing anything, e.g. when they change their settings.

Writing a watchface or app that uses plugins

Your app uses plugins from its PKJS (pebble-js-app.js), and must use permissions (manifestVersion: 2). For a legacy app, Pebble.enumeratePlugins() returns nothing and every request fails with PERMISSION_DENIED.

Three ways to use plugins

  1. You know exactly what you want. Hard-code the category, item and properties, as the weather-face demo does with weather/location. Whichever weather plugin the user has, the face works. It uses the condition image the plugin provides - but a face that wants its own artwork could read the condition_code property instead (sun, partly_cloudy, heavy_rain, ...) and draw its own.
  2. Let the user choose. Use Pebble.enumeratePlugins() to see everything available, and a settings page for the user to pick what goes where, as the plugin-test dashboard does. Filter by the shapes your design can draw (see Shapes).
  3. Use what the user already prefers. Users pick their favourite readings in the Pebble app (Settings → Plugins → Complications, up to five), e.g. phone battery first, then the temperature for their first weather location, then their next calendar event. preferences/complications lists them in order, so a face with N slots can show the first N with no configuration (see User preferences).

Reading a source:

const subscription = Pebble.subscribeToSource({
  category: "weather",
  item: "location",
  properties: ["name", "temperature", "condition"],  // optional hint; omit for everything
  iconPixelSize: { w: 48, h: 48 },                   // optional; ask only if you want bitmaps
  // names the source doesn't declare are dropped, and the size is capped at 260×260.
  onData: ({ instances }) => {
    // the location picked on your settings page, else the user's first
    const here = instances.find((i) => i.instanceId === savedId) ?? instances[0];
    if (!here) return;
    const p = here.properties;
    send(p.name?.shortText?.text, p.temperature?.shortText?.text ?? "--");   // any property can be missing
  },
  onError: (err) => console.log(err.code, err.message),
});

subscription.unsubscribe();   // or let it end with the app

Every update is an envelope:

{
  pluginUuid: "...",
  validUntilMs: 1716940000000,
  instances: [                                   // one per room
    {
      instanceId: "3",                           // stable: store this to remember a choice
      properties: {
        name:       { shortText: { text: "Kitchen" } },
        on:         { boolean: { value: true }, shortText: { text: "On" } },
        brightness: { numericValue: { value: 64, unit: "%", min: 0, max: 100 },
                      shortText:    { text: "64%" } },
      },
    },
    {
      instanceId: "5",
      properties: {
        name:       { shortText: { text: "Living room" } },
        on:         { boolean: { value: false }, shortText: { text: "Off" } },
      },                                         // off, so no brightness: draw what is there
    },
  ],
}

Which plugin answers: pass plugin: "<uuid>" only if you depend on that plugin, for example your own pbw's plugin: you always get it while it's installed and serves the item, whatever the user prefers. Otherwise (or if it isn't installed) you get the plugin the user chose for that item in the Pebble app (Settings → Plugins → Preferred sources), else a built-in, else the first by name. Check pluginUuid on the envelope if you need to know which one answered.

Invoking an action: plugin is required here, and never falls back to another plugin:

const result = await Pebble.invokeAction({
  plugin: "<plugin's app uuid>",
  action: "set_on",
  args: { item: "light", instanceId: "3", on: false },
});
// { ok: true, text: "Kitchen off.", refreshed: ["home/light"] }
// { ok: false, code: "AUTH_REQUIRED", message: "..." }

Discovering what's installed: Pebble.enumeratePlugins() returns every installed plugin with its sources (items, properties and shapes) and actions.

Error codes: PLUGIN_UNAVAILABLE, PERMISSION_DENIED, RATE_LIMITED, TIMEOUT, INVALID_REQUEST, UNKNOWN, plus AUTH_REQUIRED and INVALID_ARGS for actions. An error doesn't end a subscription: it carries on, and the next update may succeed.

User preferences

The built-in Preferences plugin tells apps how the user wants things shown and what they want to see. Each source updates when the user changes the setting.

preferences/units (no permission): one instance, every property a shortText.

Property Values
locale e.g. en-GB, de-DE: the watch's language, else the phone's. Use it with Intl for decimal separators, dates and so on.
temperature °C, °F
distance km, mi
wind_speed km/h, mph, m/s, knots
pressure hPa, inHg
precipitation mm, in
clock 12h, 24h

preferences/complications (no permission): the user's readings, one instance per filled slot, in order (instanceId is the slot number). Each has category, item, property (shortText) and index (numericValue): read category/item as usual and use instance index's property. Reading it may need a permission like any other read; skip to the next entry when one can't be read.

Pebble.subscribeToSource({
  category: "preferences",
  item: "complications",
  onData: ({ instances }) => {
    const slot = instances[0]?.properties;
    if (!slot) return;
    const property = slot.property.shortText.text;
    Pebble.subscribeToSource({
      category: slot.category.shortText.text,
      item: slot.item.shortText.text,
      properties: [property],
      onData: ({ instances }) => show(instances[slot.index.numericValue.value]?.properties[property]),
      onError: () => show(null),
    });
  },
});

Because slots refer to instances by position, list your instances in a meaningful, stable order, most important first.

preferences/weather_location (needs Location): the weather locations the user set up in the Pebble app, in their order. instanceId is the location's key; properties are name, latitude and longitude (absent for the current-location entry) and current (boolean). A weather plugin that serves one instance per location under the same instanceIds means the same place as the built-in one.

Writing a plugin

A plugin ships in a .pbw: either alongside a watchapp/watchface, or on its own as a plugin-only pbw (no watch binary). It uses the pbw's uuid and name. The simplest example to copy is stocks.

If your app fetches data from a service and displays it, consider splitting it into a plugin plus the app, in one pbw. The app reads from its own plugin (pass your pbw's uuid as plugin), and behaves the same as it does now. But the data is also available to every other watchface and app, and to Index - and users can show it in a design other than yours.

The manifest

Add a plugin block to the pebble section of package.json:

"pebble": {
  "displayName": "Stocks",
  "uuid": "2c4b7f10-9a3d-4e6b-8f21-5d0c7e9a1b34",
  "sdkVersion": "3",
  "configPage": "config.html",            // optional settings page (a file in the pbw, or a URL)
  "usesPermissions": [
    {
      "name": "Internet",
      "parameters": { "domains": ["query1.finance.yahoo.com"] },
      "reason": "To get share prices",     // 1.18.0.1
      "required": true                    // 1.18.0.1
    }
  ],
  "plugin": {
    "description": "Share prices for the tickers the user picked.",   // also read by index MCP
    "script": "plugin.js",
    "sources": [
      {
        "category": "finance",
        "items": ["stock"],
        "properties": {
          "name":  ["shortText"],
          "price": ["shortText", "longText", "numericValue"]
        },
        "supportsMultiple": true,
        "suggestedRefreshIntervalSec": 60
      }
    ],
    "actions": [
      {
        "name": "add_stock",                 // illustrative; the real Stocks plugin has no actions
        "description": "Start tracking a ticker symbol.",
        "parameters": {                    // JSON Schema
          "type": "object",
          "properties": { "symbol": { "type": "string" } },
          "required": ["symbol"]
        },
        "targets": ["finance/stock"]
      }
    ]
  }
}

The script

A plugin only runs when it's needed: for a subscription, an action, or while its settings page is open. It doesn't stay running in the background. While an app is subscribed, the plugin is called regularly for fresh data (currently every suggestedRefreshIntervalSec, but this may change).

// plugin.js
Pebble.registerSourceHandler(async (request, respond) => {
  // request: { category, item, properties?, iconPixelSize? }
  if (request.category === "finance" && request.item === "stock") {
    const quotes = await loadQuotes();
    respond.data({
      validUntilMs: Date.now() + 60_000,
      instances: quotes.map((q) => ({
        instanceId: q.symbol,
        properties: {
          name:  { shortText: { text: q.name } },
          price: { shortText: { text: `$${q.price}` }, numericValue: { value: q.price, unit: "$" } },
        },
      })),
    });
    return;
  }
  respond.error("PLUGIN_UNAVAILABLE");
});

Pebble.registerActionHandler(async (request, respond) => {
  // request: { action, args }
  if (request.action !== "add_stock") return respond.error("INVALID_REQUEST");
  try {
    await addStock(request.args.symbol);
    // Sources this changed: subscriptions to them (the caller's included) are re-read now, so
    // they get the new data without waiting for the next refresh.
    respond.ok({ text: "Added.", refreshed: ["finance/stock"] });
  } catch (e) {
    respond.error("UNKNOWN", e.message);
  }
});

What a plugin has to work with:

  • fetch for HTTP, to hosts the user has allowed (see Approval). A blocked host rejects with an error named NotAllowedError. Plain-HTTP LAN devices are reachable with LocalNetwork.
  • WebSocket under the same rules: a blocked host fires error then close. Binary frames arrive as ArrayBuffer. Sockets are closed when the session ends, so one only lives for the request that opened it. These aren't intended for long-running connections.
  • navigator.geolocation (getCurrentPosition, watchPosition, clearWatch), as in PKJS. Needs Location; refused, it calls your error callback. Watches end with the session.
  • localStorage for state, shared with your app's PKJS. A plugin is started fresh for each request, so don't keep state in variables.
  • Pebble.secureStorage for credentials (tokens, API keys): await getItem(key), setItem(key, value) and removeItem(key), encrypted by the phone's keystore. Private to your plugin (PKJS can't read it) and deleted when the app is removed.
  • await Pebble.readSource({ category, item, plugin?, properties?, iconPixelSize? }) to read another plugin's source (built-in or not), e.g. preferences/units (see User preferences). It rejects with PLUGIN_UNAVAILABLE, PERMISSION_DENIED, INVALID_REQUEST or TIMEOUT.
  • Pebble.subscribeSource(sameRequest) in your source handler, so the handler runs again whenever that source changes (request.trigger says which), while anything is subscribed to yours. Call it on every run: whatever a run doesn't subscribe to is dropped. While subscribed, readSource answers from the subscription. At most 8; changes are debounced by a second.
  Pebble.registerSourceHandler(async function (request, respond) {
    var units = { category: 'preferences', item: 'units' };
    Pebble.subscribeSource(units);
    var unit = (await Pebble.readSource(units)).instances[0].properties.temperature.shortText.text;
    // ...
  });

Either way you read with your plugin's permissions. What you read can reach your callers, so each of your sources and actions must require of its callers at least what you declared to read it (declared Maps' Notifications? Require Maps' notifications of your callers). A plugin can't read itself, or one that already reads it. - Pebble.refreshSources(["finance/stock"]) when data changes outside a request (for example, the user added a ticker on the settings page). - Pebble.oauth for sign-in with OAuth providers, without the plugin ever holding a client secret. Declare the connector in the manifest ("oauth": { "todoist": {} }), then await Pebble.oauth.authorize("todoist") for a token and Pebble.oauth.refresh("todoist", broker_refresh) to renew it; keep both in Pebble.secureStorage. The connector has to be registered with the Pebble app store (see the todoist example). - Pebble.fileSystem for files and folders on the phone the user has handed your plugin from its settings page (see File access); needs ReadFiles / WriteFiles. await Pebble.fileSystem.getHandle(id) gives a FileSystemFileHandle / FileSystemDirectoryHandle with the browser File System Access API surface: getFile(), createWritable(), getFileHandle(), getDirectoryHandle(), removeEntry(), entries(), queryPermission(). - Pebble.insertTimelinePin(pin) and Pebble.deleteTimelinePin(id), as in PKJS, with the pin JSON. A pin's createNotification is shown when it's first inserted, and its updateNotification when a later insert changes it. Pins need Timeline; their notifications need ShowNotifications too. - Pebble.showNotification({ layout, actions? }) to show a notification on the watch, with the same layout as a pin's (type is genericNotification or commNotification). Needs ShowNotifications. - pluginAction actions on a pin or notification (including a pin's createNotification/updateNotification): { title: "Done", type: "pluginAction", action: "complete", args: { id: "42" } }. Picking it on the watch calls your action handler with that action and args; the watch shows your respond.ok text (or "Done"), or "Failed" on an error. Add inputArg: "text" (and optionally replies: ["Yes", "No"]) to ask the user for a reply by voice, emoji or a canned reply; it arrives in args.text. - console.log shows up in pebble logs, beside your app's PKJS logs. - Nothing else: unlike PKJS, a plugin has no WebView APIs.

Return every property and shape you can; text shapes cost nothing once you have the data, and the app picks. Only produce icon/image when the request has iconPixelSize.

The Pebble app checks every update before an app sees it: a shape your manifest doesn't declare, or one that doesn't match its format (above), is dropped. Why shows up in pebble logs.

Settings pages

Until now, a PKJS settings page had to be hosted somewhere (or generated with Clay), and could only pass data through its URL + when it closed. Settings pages can now be bundled in the pbw, and can message your plugin/PKJS code in real-time while it's open.

Set configPage in package.json to an HTML file in the project (bundled into the pbw) or a URL. If it's set, it's used instead of the legacy showConfiguration flow.

A settings page has the same network access as your app: everything it loads or connects to (scripts, styles, fonts, images, fetch, WebSockets) must be on a domain you declared in Internet and the user allowed. Bundle what the page needs rather than loading it from a CDN.

// in the page
Pebble.addEventListener("ready", (e) => { /* e.target ('plugin' or 'pkjs') is up */ });
const reply = await Pebble.sendMessage("plugin", { type: "listTickers" });

// in plugin.js
Pebble.registerConfigHandler((message, respond) => respond({ tickers: tickers() }));

// in pebble-js-app.js
Pebble.addEventListener("configmessage", (e) => e.respond({ theme: theme() }));

Wait for ready before sending: the plugin is started when the page opens.

Because a page can only reach your declared domains, links to anywhere else (your source code, a help page) won't open in it. Use Pebble.openUrl instead: the user sees the address and can open it in their browser. It takes an http(s) url and resolves to whether they opened it.

const opened = await Pebble.openUrl("https://github.com/you/your-plugin");

A link to an app on the app store, followed or passed to Pebble.openUrl, opens that app's page in the Pebble app straight away, without asking. That's an app's store page (https://apps.repebble.com/91-dub-v4-0_52b231c2b70e1c159500009b, or just the id after the /) or pebble://appstore/<id>. Use it to point users at a companion watchface or plugin.

For an example, see the plugin-test dashboard: its settings page fills its dropdowns from PKJS (built from enumeratePlugins) and sends each change straight back, so the watch updates as the user edits, with no save button.

File access

A plugin can read and write files and folders the user hands it. Declare ReadFiles (and/or WriteFiles to change files; allowing it allows reading too) in usesPermissions.

With that permission, your settings page can open a file or folder picker in the matching mode, and each pick grants access to that entry. Keep its id: your plugin can use the entry outside the settings page too, e.g. to write a file in response to an action, or to read data for a source.

// in the settings page
const folder = await Pebble.showDirectoryPicker({ mode: "readwrite" });         // or
const [file] = await Pebble.showOpenFilePicker({ types: [{ accept: { "text/markdown": [".md"] } }] });
await Pebble.sendMessage("plugin", { type: "setFolder", id: folder.id });
const held = await Pebble.fileSystem.grants();     // [{ id, kind, name, mode }]
await Pebble.fileSystem.revoke(held[0].id);

// in plugin.js
Pebble.registerConfigHandler((message, respond) => {
  if (message.type === "setFolder") localStorage.setItem("folder", message.id);
  respond({ ok: true });
});

Pebble.registerActionHandler(async (request, respond) => {
  const dir = await Pebble.fileSystem.getHandle(localStorage.getItem("folder"));
  const file = await dir.getFileHandle("inbox.md", { create: true });
  const existing = await (await file.getFile()).text();
  const writable = await file.createWritable();
  await writable.write(existing + "\n- " + request.args.text);
  await writable.close();
  for await (const [name, entry] of dir.entries()) console.log(name, entry.kind);
  respond.ok({ text: "Added." });
});

Cancelling the picker rejects with an AbortError; a plugin the user hasn't allowed ReadFiles (or WriteFiles, for a read-write pick) gets NotAllowedError, as does writing through a read-only pick, or any use of an entry the user has since withdrawn.

Give up access you no longer need (e.g. when the user picks a different folder) with Pebble.fileSystem.revoke(id).

Packaging and sideloading

Build with pebble build, using SDK 4.44 or later and pebble-tool 5.1.0 or later. The build bundles the plugin script and a local configPage into the pbw, and checks the plugin block when the project is configured.

A plugin-only project (no src/c) sets "projectType": "plugin" in package.json. The pbw is marked as using permissions (manifestVersion 2). The plugins in coredevices/pebble-apps are worked examples.

Future

We're planning:

  • Likely: passing parameters in subscription requests (e.g. to ask for the weather at an arbitrary location)
  • Likely: A watch-side (C / Alloy) API, so apps can use plugins without any phone-side JS.
  • Likely: an API to receive push messages to a plugin, to trigger some work
  • A way to cache images on the watch (the Weather and Complication demo faces need a phone connection to display images after restarting, right now)
  • PebbleKit-Android 2.0 plugin support.
  • More built-in plugins exposing more things.

Changelog

Changes to plugins, permissions and settings pages in each version of the mobile app.

1.18.0.1

  • reason and required on each entry in usesPermissions (Declaring). Installing and store listings split permissions into Required and Optional, installing waits only for the required ones, and an optional one is asked about when the app first uses it.

1.18.0

  • Pebble.insertTimelinePin, Pebble.deleteTimelinePin and Pebble.showNotification for plugins, and pluginAction actions on pins and notifications, which call your action handler.
  • New permissions: ShowNotifications and ReadApps.
  • The Watch State plugin's watch/app source, targeted by launch_app.
  • A settings page can link to an app on the app store, opening its page in the Pebble app.
  • A settings page's title bar has the app's permissions, and a request made while the page is open shows there instead of as a notification.
  • Settings pages can only reach declared, allowed Internet domains, for everything they load or connect to, and other ways around network permissions were closed.
  • Fixed image series being rejected.
  • Fixed a bundled configPage not opening for an app from the store that has no plugin.
  • An app from a store that doesn't send its manifest (permissions, settings page) gets them from its pbw.

1.17.0.1

  • A notification holding a one-time code reaches plugins with its text replaced by "Sensitive content".

1.17.0

  • Pebble.permissions.query (Checking a permission).
  • A fetch blocked by permissions rejects with a NotAllowedError, distinct from a network error.
  • Each permission on an app's page shows when it was last used.

1.16.0.5

  • Pebble.openUrl for settings pages, to open links in the browser.

1.16.0.4

  • The dangerous permission kind: always asked about, whatever the user's settings.
  • Legacy apps (without manifestVersion 2) can't use plugins.
  • Permission decisions follow the user's account to other phones, and apps added from the web store are reviewed there.

1.16.0.3

  • Notifications can be narrowed to some apps' packages, each its own permission.
  • Plugins can read other plugins' sources: Pebble.readSource and Pebble.subscribeSource.
  • The built-in Preferences plugin (User preferences).
  • A weather plugin can be the user's weather provider, feeding the watch's weather app and timeline.
  • An app updating to manifestVersion 2 loses the permissions it had as a legacy app.
  • XHR permission checks use the URL and headers actually sent.
  • Fixed sideloading plugin-only pbws.

1.16.0.1

  • Series.
  • Shapes are checked against the manifest, and anything undeclared or malformed is dropped (and logged).
  • Plugin logs in pebble logs.
  • File access: ReadFiles/WriteFiles, the pickers and Pebble.fileSystem (File access).
  • WatchMicrophone.
  • agent destinations (send_to_agent) for Index.
  • WebSockets can send handshake headers; network permission checks also look at the Host header.
  • Settings pages are limited by network permissions.

1.16.0

  • Installing from the store asks about every declared permission.
  • Legacy apps' permissions are collapsed on the app's page unless the user has them ask.

1.15.0.1

  • Pebble.secureStorage.
  • Permissions can be left undecided.

1.15.0

  • The permission system: usesPermissions, definesPermissions, InvokeAction and LegacyPebbleKit, with permissions on each app's page.
  • Plugins on the app store.
  • The Watch State plugin (launch_app, launch_previous_app).
  • navigator.geolocation and WebSocket for plugins.

Earlier versions (1.11 to 1.14) had an early preview of plugins, including Pebble.oauth. Much changed in 1.16, so apps and plugins built for them may not work.

You need JavaScript enabled to read and post comments.