Plugin SDK OpenPets 2.1.0+ · SDK v1

Build plugins for your desktop pet

This is the full developer guide. It walks the whole journey — from the runtime model and prerequisites, through writing your first plugin, to running, debugging, and publishing it. No prior OpenPets internals required.

Example plugins

What the SDK is

OpenPets is the runtime that draws an animated pet on your desktop, manages its windows, and keeps it alive across sessions. The Plugin SDK is how you teach that pet new tricks without touching the app's internals. A plugin is a small, optional behavior — a break nudge, a focus timer, an ambient greeting, a notification from a web endpoint — that the runtime loads, permissions, and supervises on your behalf.

The whole contract is deliberately tiny. A plugin is just two files: a manifest (openpets.plugin.json) that declares who you are and what you need, and a single JavaScript entry file that registers a start and stop handler. Inside start you receive one object — ctx — and everything your plugin is allowed to do hangs off of it.

There is no build step, no framework, and no bundle to ship. You write plain browser-style JavaScript, declare the capabilities you use, and OpenPets does the rest: it sandboxes your code, renders your settings UI, validates every call, and tears everything down cleanly when the plugin is disabled. The mental model is one line:

OpenPets is the pet runtime. Plugins are optional, permissioned behaviors that make the pet feel useful and alive.

How a plugin runs

Understanding the runtime up front explains every rule you'll meet later. When you enable a plugin, OpenPets spins up a dedicated, hidden, locked-down window just for it — nodeIntegration off, contextIsolation on, sandbox on, with its own throwaway session. Your code runs there, not in the app's main process, and never gets direct access to Node, the shell, the filesystem, or the network.

So how does your plugin actually do anything? Through a thin preload bridge. OpenPets injects a global called OpenPetsPlugin and hands your start handler the ctx object. Every method on ctx is a message that crosses a validated IPC channel into the main process, where the real work — and every safety check — happens. Your plugin asks; the runtime decides.

That request-and-supervise model is what the four pillars below describe. When the plugin is reloaded, disabled, uninstalled, or crashes, OpenPets cancels your schedules, clears your commands and listeners, calls stop() if it can, and destroys the window — so you rarely have to clean up manually.

Sandboxed runtime

Each plugin runs in its own hidden, locked-down renderer — no Node, no shell, no filesystem, no raw network.

Validated IPC

Every ctx call crosses a validated main-process handler. Messages, ids, schedules, and hosts are all checked.

Explicit permissions

Plugins declare exactly what they need. Permission or network-host changes require user re-approval.

Host-rendered config

You declare a config schema; OpenPets renders the settings UI safely. Plugins never draw their own UI.

Prerequisites

You can write a plugin in any text editor — it's just JavaScript and JSON. To run one during development you need the OpenPets desktop app, and the smoothest path is to run it from the repository so you get the local-plugin loader and the catalog tooling. Here's the short list:

  • Node.js 20+ and pnpm 11 — the workspace toolchain.
  • git — to clone the OpenPets repository.
  • The OpenPets desktop app — either built from the repo (for development) or installed from Releases (for trying published plugins).
  • A basic grasp of async/await JavaScript — every ctx method returns a Promise.

That's everything. You do not need TypeScript, a bundler, or any plugin scaffolding tool. The next section explains why there's nothing to install from npm.

Do I npm install the SDK?

There's nothing to install at runtime — and this trips people up, so it's worth a paragraph. The desktop app injects the OpenPetsPlugin global into your plugin's sandbox and passes ctx into start(), so your entry file simply assumes both exist and ships as one dependency-free file:

index.jsjs
// No imports. OpenPetsPlugin is provided by the runtime.
OpenPetsPlugin.register({
  async start(ctx) {
    // ctx is the entire SDK surface, ready to use.
  },
})

Keeping plugins import-free is intentional: it makes them tiny, trivial to review, and safe to sandbox. The one thing you can install is the types-only dev package, for editor autocomplete and type-checking. It ships zero runtime and never ends up in your plugin — exactly like @types/vscode or @figma/plugin-typings:

Terminalbash
npm i -D @open-pets/plugin-sdk

Then reference it from a JavaScript plugin for IntelliSense, or import the contract directly in TypeScript:

index.jsjs
/// <reference types="@open-pets/plugin-sdk" />

OpenPetsPlugin.register({
  async start(ctx) {
    await ctx.pet.speak("Hello!") // ctx is now fully typed
  },
})
TypeScriptts
import type { OpenPetsContext, OpenPetsPluginDefinition } from "@open-pets/plugin-sdk"

const plugin: OpenPetsPluginDefinition = {
  async start(ctx: OpenPetsContext) {
    await ctx.pet.react("waving")
  },
}

Set up your workspace

For day-to-day development you'll run the desktop app from the repo so it can load your plugin from disk and reload it as you edit. Clone the project, install dependencies, and build the workspace packages once:

Terminalbash
git clone https://github.com/alvinunreal/openpets.git
cd openpets

pnpm install     # install all workspace dependencies
pnpm build       # build the packages the desktop app depends on

Now create a home for your plugin. It can live anywhere on disk — it doesn't need to be inside the repo. A folder with two files is all you need:

Terminalbash
mkdir -p ~/openpets-plugins/hello-pet
cd ~/openpets-plugins/hello-pet

touch openpets.plugin.json   # the manifest
touch index.js               # the entry file

We'll fill those two files in the next sections and load the folder into the running app in Run & iterate locally.

Anatomy of a plugin

Every plugin has the same shape. The manifest is the part OpenPets reads first — it's the source of truth for your identity, versioning, the permissions you're requesting, and the settings UI the app should render for you.

openpets.plugin.jsonjson
{
  "manifestVersion": 2,
  "id": "your-name.hello-pet",
  "name": "Hello Pet",
  "version": "1.0.0",
  "description": "Greets you when it starts and adds a hello command.",
  "author": "Your Name",
  "runtime": "javascript",
  "entry": "index.js",
  "sdkVersion": "1.0.0",
  "permissions": ["pet:speak", "pet:reaction", "commands", "status"],
  "configSchema": {}
}

The fields that matter most:

FieldWhy it matters
manifestVersionAlways 2 for JavaScript plugins. (Version 1 is a legacy declarative format.)
idYour stable, unique identifier. Use reverse-DNS style (your-name.hello-pet). It never changes.
versionSemver. The catalog and updater compare it; bump it on every release.
runtime"javascript" — the modern, full-featured runtime.
entryRelative path to your single JS file, e.g. index.js.
permissionsThe capabilities you intend to use. Request the minimum — users approve this list.
network.hostsRequired only if you use ctx.http: the exact HTTPS hostnames you may reach.
configSchemaDeclares the settings fields OpenPets renders for you. Empty {} if you have none.

The entry file is the behavior. It calls OpenPetsPlugin.register() exactly once with a start handler (required) and a stop handler (optional). start runs when the plugin is enabled or the app launches; this is where you register schedules, commands, and listeners. stop is your chance to clean up — though the runtime already tears down anything you registered through ctx.

index.jsjs
OpenPetsPlugin.register({
  async start(ctx) {
    await ctx.status.set({ text: "Ready to say hello", tone: "info" })

    await ctx.pet.speak("Hello! I am your new plugin.")
    await ctx.pet.react("waving")

    await ctx.commands.register(
      { id: "say-hello", title: "Say hello", description: "Get a friendly greeting." },
      async () => {
        await ctx.pet.speak("Hello again!")
        await ctx.pet.react("waving")
      },
    )
  },

  async stop() {
    // Optional. Schedules and commands are torn down for you.
  },
})

The ctx API

ctx is the entire SDK. Each area below is a small, focused namespace, and each method returns a Promise — always await them. A namespace is only present when the matching permission is granted in your manifest, so requesting storage is what makes ctx.storage appear.

ctx.pet

Make the pet speak, react, or take small bounded walks across the desktop.

pet:speak · pet:reaction · pet:move
ctx.schedule

Run callbacks once, on an interval, or daily at HH:mm on chosen weekdays.

schedule
ctx.storage

Persist small per-plugin state across restarts with get / set / delete.

storage
ctx.config

Read user settings from your schema and react live with onChange.

built-in
ctx.commands

Add right-click pet actions, optionally with a host-rendered input form.

commands
ctx.status

Show a short status line and tone in the Plugins window.

status
ctx.http

GET approved HTTPS hosts through the OpenPets proxy. No wildcards.

network
ctx.log

Write debug / info / warn / error lines into the desktop app log.

built-in

Pet, schedule, and storage in practice

The three you'll reach for most are ctx.pet (the visible payoff), ctx.schedule (when things happen), and ctx.storage (remembering across restarts). Speaking and reacting are one-liners; messages are capped at 140 characters and rejected if they look like code, URLs, paths, or secrets, so keep them short and human. Common reactions are waving, waiting, success, and celebrating.

Using ctxjs
// Speak + react
await ctx.pet.speak("Time to stretch.")
await ctx.pet.react("waving")

// Schedule: once, on an interval, or daily at HH:mm
await ctx.schedule.once("welcome", 5_000, () => ctx.pet.speak("Settling in..."))
await ctx.schedule.every("hydrate", 60 * 60_000, () => ctx.pet.speak("Water break!"))
await ctx.schedule.daily("standup", { time: "09:30", days: [1, 2, 3, 4, 5] }, () => ctx.pet.speak("Standup soon."))

// Storage: remember a little state
await ctx.storage.set("lastNudge", new Date().toISOString())
const last = await ctx.storage.get("lastNudge")

Schedule ids must be short, safe identifiers; you use the same id to cancel a timer later. Intervals have a minimum delay, daily schedules take HH:mm with optional weekdays (06, Sunday is 0), and storage values must be clone-safe JSON (no functions or DOM nodes) and live under a per-plugin size quota.

Configuration, commands, and status

Read user settings with ctx.config.get() and stay in sync with ctx.config.onChange(), which fires whenever the user saves new settings and returns an unsubscribe function. Register right-click pet actions with ctx.commands — optionally attaching a small form so the command can collect input — and surface a one-line summary in the Plugins window with ctx.status.

Using ctxjs
const config = await ctx.config.get()
ctx.config.onChange((next) => reschedule(next))

await ctx.commands.register(
  {
    id: "remind-me",
    title: "Remind me in...",
    form: { submitLabel: "Set", fields: [{ id: "minutes", type: "number", label: "Minutes", default: 10, min: 1, max: 120 }] },
  },
  async (values) => {
    const minutes = Number(values?.minutes) || 10
    await ctx.schedule.once(`r-${Date.now()}`, minutes * 60_000, () => ctx.pet.speak("Here's your reminder."))
    await ctx.status.set({ text: `Reminder set for ${minutes} min`, tone: "success" })
  },
)

Talking to the network

If your plugin needs data from the web, request the network permission and list the exact hosts in network.hosts. Then ctx.http.fetch proxies the request through OpenPets. It is intentionally narrow: GET only, HTTPS only, redirects blocked, response size capped, and the hostname DNS-checked so it can't be pointed at private or loopback addresses.

Using ctxjs
// Manifest: "permissions": ["network"], "network": { "hosts": ["api.github.com"] }
const res = await ctx.http.fetch("https://api.github.com/repos/alvinunreal/openpets")
if (res.ok) {
  const repo = res.json
  await ctx.pet.speak(`Stars: ${repo.stargazers_count}`)
}

For the exact method signatures of every namespace, see the full SDK reference.

Build one step by step

Let's build a real, useful plugin from nothing: Hydrate Buddy, which nudges you to drink water on an interval you control, remembers when it last fired, and exposes a "remind me now" command. It touches schedules, config, storage, commands, and status — a representative slice of the SDK.

Step 1 — Declare the manifest

We request exactly the permissions we'll use, and declare a single numeric config field so users can set their own interval. OpenPets renders that field for us.

openpets.plugin.jsonjson
{
  "manifestVersion": 2,
  "id": "your-name.hydrate-buddy",
  "name": "Hydrate Buddy",
  "version": "1.0.0",
  "description": "Gentle water-break reminders on your schedule.",
  "runtime": "javascript",
  "entry": "index.js",
  "sdkVersion": "1.0.0",
  "permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "commands", "status"],
  "configSchema": {
    "intervalMinutes": { "type": "number", "label": "Remind every (minutes)", "default": 60, "min": 15, "max": 240, "step": 5 }
  }
}

Step 2 — Write the behavior

The entry file does three things: a schedule(config) helper that (re)builds the timer from the current settings, a start handler that runs it on launch and re-runs it whenever settings change, and a command for an on-demand nudge. Read the comments — each line maps to a concept from the sections above.

index.jsjs
OpenPetsPlugin.register({
  async start(ctx) {
    // Rebuild the reminder timer from the user's current settings.
    async function schedule(config) {
      await ctx.schedule.cancelAll()                       // clear any previous timer
      const minutes = Number(config.intervalMinutes) || 60 // read config, with a fallback

      await ctx.schedule.every("hydrate", minutes * 60_000, async () => {
        await ctx.pet.speak("Time for a sip of water.")
        await ctx.pet.react("waving")
        await ctx.storage.set("lastNudge", new Date().toISOString()) // remember it
      })

      await ctx.status.set({ text: `Reminding every ${minutes} min`, tone: "info" })
    }

    // Run once on startup, then again whenever the user saves new settings.
    await schedule(await ctx.config.get())
    ctx.config.onChange((next) => schedule(next))

    // An on-demand command, available from the pet's right-click menu.
    await ctx.commands.register(
      { id: "drink-now", title: "Remind me to drink now" },
      async () => {
        await ctx.pet.speak("Hydrate! Take a sip.")
        await ctx.pet.react("success")
      },
    )
  },

  async stop() {
    // Nothing to do — the runtime cancels the schedule and command for us.
  },
})

Step 3 — That's the whole plugin

No build, no bundle, no dependencies. Two files. Next we'll load it into the running app and watch it work.

Run & iterate locally

Local plugin loading is explicit and development-only — OpenPets won't run arbitrary folders unless you point it at them with an environment variable. Launch the desktop app with your plugin folder on OPENPETS_DEV_PLUGIN_PATHS:

Terminalbash
# From the repo root, load your own plugin folder:
OPENPETS_DEV_PLUGIN_PATHS=~/openpets-plugins/hydrate-buddy pnpm dev:desktop

# Or load the seven official plugins for reference:
pnpm dev:desktop:plugins

OpenPets snapshots the plugin into its app-data directory, auto-approves permissions for these explicit dev paths, and starts it. Open Tray → Plugins to confirm it's enabled, tweak its settings, and right-click your pet to run its commands. To load a whole directory of plugins at once, point OPENPETS_DEV_PLUGIN_ROOTS at the parent folder instead.

After you edit your source, reload the plugin from the Plugins UI (or restart the app) so the snapshot updates. When something misbehaves, the runtime logs under the plugin scope — your own ctx.log.* calls land there too:

Log locationtext
<Electron userData>/logs/openpets.log

# macOS:   ~/Library/Application Support/@open-pets/desktop/logs/openpets.log
# Linux:   ~/.config/@open-pets/desktop/logs/openpets.log

Permissions

Permissions are a contract with the user. You declare every capability you use in the manifest's permissions array; OpenPets shows that list at install time and enforces it at runtime. Changing the list later — or adding a new network host — requires the user to re-approve the plugin, so request the minimum you need and add more only when a feature genuinely calls for it.

PermissionUnlocks
pet:speakctx.pet.speak — speech bubbles.
pet:reactionctx.pet.react — reactions.
pet:movectx.pet.moveBy / wander / moveToHome — bounded movement.
schedulectx.schedule — one-shot, interval, and daily timers.
storagectx.storage — persisted per-plugin state.
statusctx.status — status text and tone.
commandsctx.commands — right-click actions and forms.
networkctx.http — GET to approved hosts (with network.hosts).

ctx.config and ctx.log are always available and don't need a permission.

Validation & limits

The main process validates and rate-limits everything a plugin does. These aren't obstacles — they're the guarantees that let users trust third-party plugins. Design with them in mind:

  • Pet messages are capped at 140 characters and rejected if they resemble code, URLs, file paths, or secrets.
  • Pet movement only moves the default pet, is clamped to the work area and capped per step, and is skipped while the pet is hidden, paused, dragging, or busy.
  • Schedule, command, and storage ids must be short, safe identifiers; intervals enforce a minimum delay.
  • Storage has a per-plugin size quota and restricted keys; values must be clone-safe.
  • Status text and command titles/descriptions have length limits.
  • HTTP is GET-only, HTTPS-only, redirect-blocked, size-capped, and DNS-checked against private targets.
  • Pet actions, logs, and HTTP calls each have per-minute rate limits.

The platform deliberately omits Node APIs, main-process execution, shell, native modules, package installs, broad filesystem access, wildcard networking, and plugin-drawn settings UI. If a design needs one of those, it isn't a plugin — yet.

Configuration UI

Plugins never draw their own settings screens. Instead you describe the fields you want in configSchema, and OpenPets renders a safe, native settings form for them. You read the values back with ctx.config.get() and react to edits with ctx.config.onChange(). Supported field types include text, textarea, number, boolean, select, time, list, and multi-select.

configSchemajson
"configSchema": {
  "quietHoursEnabled": { "type": "boolean", "label": "Quiet hours", "default": true },
  "quietStart": { "type": "time", "label": "Quiet start", "default": "22:00" },
  "snoozeMinutes": { "type": "number", "label": "Snooze minutes", "default": 15, "min": 1, "max": 120, "step": 5 },
  "breaks": {
    "type": "list",
    "label": "Reminders",
    "maxItems": 8,
    "itemSchema": {
      "message": { "type": "textarea", "label": "Message", "maxLength": 140 },
      "reaction": {
        "type": "select", "label": "Reaction", "default": "waiting",
        "options": [
          { "label": "Waving", "value": "waving" },
          { "label": "Waiting", "value": "waiting" },
          { "label": "Success", "value": "success" }
        ]
      },
      "intervalMinutes": { "type": "number", "label": "Every (minutes)", "default": 60, "min": 10, "max": 1440 }
    }
  }
}

Test, package, and publish

When the behavior is solid, the repo gives you a short pipeline to validate and package the plugin without touching production. Run these from the repository root:

Terminalbash
pnpm plugins:test     # run the plugin contract checks
pnpm plugins:check    # dry-run package validation (no uploads)
pnpm plugins:package  # write web/public/plugins/*.json and stage ZIPs locally

plugins:package stages everything the catalog needs without publishing. Promoting those staged artifacts to the live catalog at https://openpets.dev/plugins/catalog.v2.json is a separate, explicit step:

Terminalbash
pnpm plugins:publish
pnpm plugins:deploy

Every ZIP install is verified end-to-end: catalog id/version match, a SHA-256 package hash, ZIP path safety, manifest presence and id/version match, JavaScript entry presence, and safe install/uninstall containment. That's why a published plugin can be trusted to install and remove cleanly.

Official plugins

The seven first-party plugins are the best reference there is: reviewed, real-world SDK usage you can read and copy. Each card links to its source on GitHub — start with Pet Pal (simplest) and Break Buddy (schedules + config + storage).

Go deeper

You now have the whole loop — write, run, iterate, publish. When you want the exhaustive method-by-method contract or you're integrating with the rest of OpenPets, these are the next stops:

Alvin 제작