| description | Build OpenPets SDK v3 plugins with the host-provided context object, voice and delivery APIs, testing harness, and scaffolded plugin packages. |
|---|
@open-pets/plugin-sdk (packages/sdk/) is the public, author-facing
contract for OpenPets plugins. It is a types-first package: it ships
TypeScript declarations describing the OpenPetsContext a plugin receives, plus
a deterministic, no-Electron test harness. The real behavior is injected at
runtime by the desktop host - the SDK is the shape both sides agree on.
For the platform that implements this contract (sandbox, permissions, install),
see Plugin platform. Source map: packages/sdk/src/codemap.md.
Current line: SDK 3.x, paired with plugin manifestVersion: 3. The exact
published package version is owned by packages/sdk/package.json.
There are three copies of "the SDK" and they must stay in lockstep:
- The published types -
packages/sdk/src/index.ts. What authors program against. - The host implementation -
apps/desktop/src/plugin-sdk-bridge.tsand itsplugin-sdk-*namespace modules. What actually runs. - The test harness -
packages/sdk/src/testing.ts. A mockctxfor unit tests.
A conformance check (packages/sdk/src/check-plugin-sdk.ts) compiles/runs a
representative plugin against the harness to detect drift. Rule: a change to
the SDK touches index.ts, testing.ts, the desktop bridge, and the
conformance check together. Updating one without the others is how the
contract silently breaks.
A plugin exports a registration hook; at runtime the host calls it with a
host-backed ctx (OpenPetsContext). Capabilities are grouped into namespaces,
each gated by a permission (Plugin platform):
| Namespace | What it does | Rough permission |
|---|---|---|
ctx.pet |
Drive the default pet: speak, react | pet:* |
ctx.pets |
Spawn/target multiple pets, motion | pets:* |
ctx.ui |
Host-rendered bubbles, alerts, menu items, panels | ui:* |
ctx.schedule |
Recurring / one-shot timers | schedule |
ctx.storage |
Quota-bound persistent plugin data + subscriptions | storage |
ctx.config |
Read config + listen for changes | (config schema) |
ctx.events |
Curated host events: clicks, drag/drop, display, power, idle | events |
ctx.bus |
Inter-plugin publish/subscribe | bus |
ctx.audio |
Play plugin/user sounds | audio |
ctx.voice |
TTS + one-shot listen | voice:* |
ctx.notify |
OS-style notifications/toasts | notify |
ctx.ai |
Host-mediated AI gateway | ai |
ctx.secrets |
Encrypted plugin-scoped secrets | secrets |
ctx.auth |
Host-mediated OAuth/PKCE | auth |
ctx.net |
Declared∩approved hosts; public HTTPS + optional local HTTP via network:local; non-GET via network:write |
network, network:write, network:local |
ctx.files |
Scoped file access | files |
ctx.system |
System info, aggregate CPU/memory and optional GPU/system-volume metrics, clipboard | system:*, clipboard |
ctx.assets |
Resolve declared asset refs (icons/images/sprites/sounds) | (declared assets) |
ctx.commands |
Register right-click commands | commands |
ctx.status |
Publish status text | (status surface) |
ctx.assistant |
Explicitly register structured capabilities for host assistant routing | No new permission |
ctx.t |
Localized strings via plugin locales | - |
ctx.log |
Plugin logging | - |
The exact signatures live in packages/sdk/src/index.ts - that file is the
contract, so program against it rather than any list copied into a doc.
ctx.system.metrics() always returns aggregate CPU and memory usage; its
GPU and system-volume fields are optional because host support varies by OS and
hardware. It never exposes process, application, file, or device identity data.
OpenPetsPermission in the SDK mirrors manifest validation so authors get
autocomplete for exactly the capabilities they can request.
ctx.assistant.registerCapability(...) is the explicit opt-in for making a
plugin operation callable by the host Pet Assistant. There is deliberately no
assistant manifest permission: registration grants no authority and does not
change the plugin's existing permission approvals. Every SDK effect performed
by a handler still goes through the normal bridge permission and quota checks.
A capability has a stable id, a plain-language description, and an
object-rooted inputSchema. The v1 schema subset supports type, properties,
required, boolean additionalProperties, description, enum, const,
string minLength/maxLength, numeric minimum/maximum, and array items,
minItems, and maxItems. The host rejects unsupported schema keywords and
malformed descriptors. Inputs are validated and cloned before the handler runs;
handlers return object-shaped JSON-safe results subject to host size and depth
limits. unregisterCapability(id) removes a registration owned by the current
plugin generation. Current v1 limits are 32 registrations per plugin, 16 KiB
per schema, depth six for schemas, 32 properties per object, 128 total schema
properties, 32 array items, 4,096 characters per string, 64 KiB per input or
result, and a five-second host execution wait.
If validated input is missing a required field, the host may return the
structured failure discriminator missingInformation: true. This tells the
assistant surface to request that value from the user; it is distinct from
generic rejected, unavailable, or indeterminate capability outcomes and does
not add provider-specific behavior.
The host owns capability discovery and execution routing. Plugin disable, reload, and stop revoke registrations, and an in-flight result from an old plugin generation is rejected rather than returned as current state. Capability registration does not persist transcripts or conversations. Sensitive-action confirmation UX and the Pet Assistant model loop are outside this contract; issue #137 only adds the plugin capability boundary and does not implement realtime tool calling.
ctx.net is the canonical network surface. Executable permissions are
manifest∩approved. Hosts match the intersection of network.hosts and approved
hosts; a bare hostname covers only the scheme default port. network:local adds
declared loopback/private HTTP endpoints alongside public HTTPS (public hosts
still use public-host checks). network:write unlocks non-GET on ctx.net only.
Legacy ctx.http.fetch stays GET-only public HTTPS and never inherits local or
write access.
ctx.voice.listen() is a single, host-owned push-to-talk capture. It is visibly
indicated only after microphone acquisition succeeds, never listens ambiently,
and rejects concurrent requests. The host bounds acquisition at 15 seconds and
transcription at 30 seconds, trims the returned text, and rejects empty output
with Voice transcription returned no text. The host cancellation path is used
when a plugin is stopped or OpenPets shuts down; plugin code does not receive raw
audio, credentials, or a renderer handle for the privacy surface.
Commands time out after five seconds by default. A command that deliberately
waits for user interaction, such as host-mediated OAuth, may declare a bounded
timeoutMs between one second and five minutes.
ctx.auth.oauth accepts an optional clientSecret alongside the provider,
client ID, and approved scopes. An installed-app credential may require that
secret at its token endpoint even when the host uses PKCE and a loopback
redirect. The host keeps OAuth session data, including a supplied client secret,
in encrypted plugin-scoped secret storage; plugins should not log it.
ctx.ui.delivery requests a generic, host-owned, display-level delivery and
requires the dedicated ui:delivery permission. Authors provide a stable
plugin-scoped key, a courier returned by ctx.assets.sprite() for a
manifest-declared sprite, plain-text title/detail, and a near-term expiry. The
host - not plugin code - selects the cursor display, renders the delivery, queues it
with other work, and controls its visual behavior. Coordinates, HTML, URLs,
arbitrary asset paths, and animation controls are intentionally outside this
contract.
The call returns an opaque handle. A plugin may dismiss its delivery or register
one dismissal handler; the reason is click, manual, expired, or
plugin-stopped. Re-registering the same key supersedes the prior handle.
Handlers are not invoked after the plugin host has stopped. Use the stable key
to make repeated sync or reminder work idempotent, and treat dismissal as a
host lifecycle signal rather than a durable acknowledgement.
- Describe, don't render. You hand the host descriptors (a bubble, an alert, a HUD, a command); the host validates, lays out, and owns lifecycle. You can't draw into a pet window directly. This is what keeps plugins safe and consistent.
- Everything is permission-gated and quota-bound. A namespace call without
the declared+approved permission is denied; storage and other namespaces have
quotas (
plugin-sdk-quotas). Design for graceful denial. - Assistant registration is not permission escalation. Only explicitly registered capabilities are discoverable, while their handlers retain the plugin's ordinary manifest and user-approved authority.
- State survives restarts.
ctx.storagepersists; schedules reconcile after restart/sleep. Stateful companions (reminders, virtual pet) rely on this. - Localize by reference. Use
$t:in the manifest andctx.t(key, vars)in code; shiplocales/en.json. See Internationalization. - Declare visual assets explicitly. A delivery courier must be a declared sprite asset, never an installed-pet ID or filesystem path. Sprite-grid config is a host-rendered select presentation whose previews must refer to declared sprites; the host honors reduced-motion preferences in that picker.
Plugin tests import createTestHarness(register, options). It builds a
deterministic mock ctx with fake time and runs the plugin's startup
without Electron, then exposes controls and assertions:
- Drive:
clock.advance(...),emit(event),runCommand(...),runCapability(...),fireBubbleAction(...). - Assert on recorded effects (descriptors, not pixels): helpers like
expectSpoke,expectBubble,expectScheduled, plus recorded storage/config/network/AI/sound/panel/pet actions, recorded deliveries, and capability registrations.
runCapability() directly invokes a recorded plugin handler so plugin behavior
tests stay deterministic. It assumes the descriptor and input are valid for the
host contract; it intentionally does not reproduce the desktop bridge's
security, schema, input, or result validation. The authoritative production
boundary remains plugin-sdk-preload.cjs -> PluginSdkBridge ->
plugin-sdk-assistant.ts, covered by desktop bridge/runtime tests.
This is why official plugins can have fast, deterministic test.js suites:
they assert that a scheduled job would fire and the pet would speak, by
advancing fake time - no rendering, no flake. See plugins/official/*/test.js
for real examples and Testing and validation for
how these run in CI.
openpets plugin new <name> --template <blank|reminder|ambient|ai-chat|tamagotchi|calendar>
scaffolds a working SDK v3 package wired to this contract and the testing
harness. The templates intentionally exercise current surfaces (ctx.ui.alert,
dynamic speech, events, schedules, storage, commands, AI, assets, and a
Tamagotchi-style state loop) so authors have a real starting point rather than an
empty file. See Plugin platform for the full authoring workflow.