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). ShowsPebble.subscribeSource,navigator.geolocationand 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
temperatureproperty is{ shortText: { text: "72°F" }, longText: { text: "72°F" }, numericValue: { value: 72, unit: "°F" } }. ItslongTextis 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.
iconandimageare 4-bpp palettised bitmaps (base64), only produced if you ask for them withiconPixelSize.
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
valuesarray is the same length, andnullis a missing point.iconandimageseries carry one bitmap per point. - Use
startandstepSecto find now in the series, rather than assumingvalues[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_*anddaily_*series onweather/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
Internetdomain is its own permission and covers its subdomains.LocalNetworkcovers devices on the LAN. Notificationsis 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 theirpackageproperty). 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".ReadFilesandWriteFileslet 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>, orWriteFiles/<path>for read-write), listed on the app's page like any other, and nothing is granted without a pick.WriteFilesincludes reading: declare both to let the user allow reading but not writing, and allowingWriteFilesallowsReadFileswith it. See File access.LegacyPebbleKitlets 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.WatchMicrophoneallows watch microphone streaming to PebbleKit JS and localmic_capture_*capture (SDK revision 111). See {% guide_link events-and-services/microphone %}.Healthisn'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
- You know exactly what you want. Hard-code the category, item and properties, as the
weather-facedemo does withweather/location. Whichever weather plugin the user has, the face works. It uses the conditionimagethe plugin provides - but a face that wants its own artwork could read thecondition_codeproperty instead (sun,partly_cloudy,heavy_rain, ...) and draw its own. - Let the user choose. Use
Pebble.enumeratePlugins()to see everything available, and a settings page for the user to pick what goes where, as theplugin-testdashboard does. Filter by the shapes your design can draw (see Shapes). - 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/complicationslists 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:
fetchfor HTTP, to hosts the user has allowed (see Approval). A blocked host rejects with an error namedNotAllowedError. Plain-HTTP LAN devices are reachable withLocalNetwork.WebSocketunder the same rules: a blocked host fireserrorthenclose. Binary frames arrive asArrayBuffer. 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. NeedsLocation; refused, it calls your error callback. Watches end with the session.localStoragefor state, shared with your app's PKJS. A plugin is started fresh for each request, so don't keep state in variables.Pebble.secureStoragefor credentials (tokens, API keys):await getItem(key),setItem(key, value)andremoveItem(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 withPLUGIN_UNAVAILABLE,PERMISSION_DENIED,INVALID_REQUESTorTIMEOUT.Pebble.subscribeSource(sameRequest)in your source handler, so the handler runs again whenever that source changes (request.triggersays which), while anything is subscribed to yours. Call it on every run: whatever a run doesn't subscribe to is dropped. While subscribed,readSourceanswers 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
reasonandrequiredon each entry inusesPermissions(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.deleteTimelinePinandPebble.showNotificationfor plugins, andpluginActionactions on pins and notifications, which call your action handler.- New permissions:
ShowNotificationsandReadApps. - The Watch State plugin's
watch/appsource, targeted bylaunch_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
Internetdomains, for everything they load or connect to, and other ways around network permissions were closed. - Fixed
imageseries being rejected. - Fixed a bundled
configPagenot 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
fetchblocked by permissions rejects with aNotAllowedError, distinct from a network error. - Each permission on an app's page shows when it was last used.
1.16.0.5
Pebble.openUrlfor settings pages, to open links in the browser.
1.16.0.4
- The
dangerouspermission kind: always asked about, whatever the user's settings. - Legacy apps (without
manifestVersion2) 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
Notificationscan be narrowed to some apps' packages, each its own permission.- Plugins can read other plugins' sources:
Pebble.readSourceandPebble.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
manifestVersion2 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 andPebble.fileSystem(File access). WatchMicrophone.agentdestinations (send_to_agent) for Index.- WebSockets can send handshake headers; network permission checks also look at the
Hostheader. - 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,InvokeActionandLegacyPebbleKit, with permissions on each app's page. - Plugins on the app store.
- The Watch State plugin (
launch_app,launch_previous_app). navigator.geolocationandWebSocketfor 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.