Build on Sunflare

Writing plugins

Plugins are local, desktop-only scripts that extend the app: composer buttons and message menu items, panels with their own controls, saved data and settings, and text tools for the message box. No server, no publishing, no review process: install a .js file and it runs.

Install a plugin#

Settings → Plugins (desktop app only) has an Install Plugin button that opens a native file picker filtered to .js files. If the plugin asks for permissions, you're asked once whether to allow them. Each installed plugin gets a card with an on/off switch, a remove action, its permissions (each one a switch you can change any time), its own settings, and its last error if it hit one. Open Plugins Folder opens the actual folder on disk if you'd rather manage files by hand.

Anatomy of a plugin#

A plugin is one JavaScript file starting with a manifest comment block:

// ==SunflarePlugin==
// @id          your.unique.id
// @name        My Plugin
// @version     1.0.0
// @author      Your Name
// @description What it does, in one line
// @permission  messages
// ==/SunflarePlugin==

// your code goes here

Only @id is required. Install fails without it. Everything else defaults (name → "Untitled Plugin", version → "0.0.0", author → "Unknown"). All fields are length-capped, so keep them short. @permission is optional and can repeat or list several names separated by commas; see Permissions.

Your code runs in a Web Worker, so there's no document or DOM. Everything a worker normally has works: timers, Promise/async, JSON, Math, Intl, OffscreenCanvas. eval and new Function are blocked.

The sunflare API#

Inside a plugin, a global sunflare object is the entire surface you have to work with. sunflare.apiVersion is 2. Calls that return something return a Promise; one that isn't allowed rejects with an Error saying why.

Buttons and menu items#

CallDoes
sunflare.addComposerButton({ id, icon, title })Adds a button to the message composer's icon row. Max 3 per plugin. icon is one of: star, bolt, bell, sound, heart, smile, music, dice, clock, code, sparkle, globe, note, image, gift, list, search, calc.
sunflare.addContextMenuItem({ id, label })Adds an item to the message right-click menu. Max 3 per plugin.
sunflare.onClick(id, event => ...)Fires when your button or menu item with that id is clicked. event is { kind: 'composer' | 'menu', id }. For a menu item, it also has event.message if you hold the messages permission (see below).

Notifications and sound#

CallDoes
sunflare.notify({ title, body })Shows a desktop notification. Title capped at 100 characters, body at 300.
sunflare.toast(text)Shows an in-app toast under your plugin's name. Up to 300 characters.
sunflare.playSound(dataUri)Plays a short sound. Must be a data:audio/mpeg, data:audio/wav, or data:audio/ogg base64 URI, ~300KB or smaller.

notify and toast share a budget of 10 per minute. Going over disables the plugin.

Storage#

A key/value store of your own, saved on this computer and kept until the plugin is removed. Values are anything JSON can hold. 256KB per plugin; keys up to 100 characters.

CallDoes
await sunflare.storage.get(key)The saved value, or null.
await sunflare.storage.set(key, value)Saves it. Rejects if it would go over 256KB. Setting undefined removes the key.
sunflare.storage.remove(key), .keys(), .clear()What they say.

Settings#

Describe your settings once and the app draws them on your plugin's card in Settings → Plugins, saves them, and tells you when they change.

sunflare.settings.define([
  { key: 'greeting', type: 'text', label: 'Greeting', default: 'Hi!', maxLength: 50 },
  { key: 'loud', type: 'toggle', label: 'Also play a sound', default: false },
  { key: 'count', type: 'number', label: 'How many', min: 1, max: 10, step: 1, default: 3 },
  { key: 'style', type: 'select', label: 'Style', default: 'short',
options: [{ value: 'short', label: 'Short' }, { value: 'long', label: 'Long' }] }
]);
const values = await sunflare.settings.get();    // { greeting, loud, count, style }
sunflare.settings.onChange(values => { /* the user changed something */ });

Up to 12 fields. Each can also have a description. Values are checked against the field (numbers clamped to min/max, selects limited to their options), so you always get a valid one.

Panels#

A panel is a small floating card your plugin fills with controls. You describe it; the app draws it in its own style, with your plugin's name and a "Plugin" badge on top. Only one panel is open at a time.

CallDoes
await sunflare.ui.showPanel({ title, blocks })Opens your panel (or replaces its contents if it's already open). Needs a recent click, see below.
sunflare.ui.updatePanel({ title?, blocks? })Changes the open panel. Any time while it's open.
sunflare.ui.closePanel()Closes it.
sunflare.ui.onPanelEvent(event => ...)Clicks and edits inside the panel, and its closing.

blocks is a list of these (up to 60):

BlockNotes
{ type: 'heading', text }A bold line.
{ type: 'text', text, muted? }Plain text; line breaks are kept.
{ type: 'code', text }Monospace block.
{ type: 'divider' }A thin rule.
{ type: 'button', id, label, style?, disabled? }style: 'primary', 'danger', or plain.
{ type: 'row', children }Buttons and text side by side (up to 12).
{ type: 'input', id, label?, placeholder?, value?, maxLength?, reset? }One-line text box.
{ type: 'textarea', id, label?, placeholder?, value?, rows?, reset? }Multi-line text box.
{ type: 'toggle', id, label, value }A switch.
{ type: 'select', id, label?, options, value }options is a list of strings or { value, label }.
{ type: 'image', src, alt?, width? }src must be a base64 data:image/png, jpeg, gif or webp URI, ~200KB or smaller. Nothing is fetched from anywhere.

Events you get: { type: 'click', id } for buttons; { type: 'change', id, value } for text boxes (while typing, slightly delayed), toggles and selects; { type: 'submit', id, value } when Enter is pressed in a text box (Ctrl+Enter in a textarea); and { type: 'close' } when the user closes the panel.

While someone is typing in a text box, re-rendering the panel keeps what they typed. Add reset: true to that block when you really mean to replace it, such as clearing the box after a submit.

The message box#

CallDoes
await sunflare.composer.insert(text)Inserts text at the cursor. Needs a recent click.
await sunflare.composer.setText(text)Replaces the whole draft (Ctrl+Z undoes it). Needs a recent click.
await sunflare.composer.getText()Reads the draft. Needs a recent click and the composer permission.

A plugin can only ever put text in the box. Sending is always the user's choice. Text is capped at 2,000 characters per call.

The recent-click rule#

Opening a panel and anything that touches the message box only work within 5 seconds of the user clicking one of your own buttons, menu items, or panel controls. Nothing pops up or edits a draft on its own. Updating or closing a panel that's already open doesn't need a click.

Permissions#

Two things need the user's say-so. Ask for them with @permission in your manifest; they stay off until the user turns them on, and they can turn them off again on your card at any time.

PermissionGives you
messagesevent.message on a click of your menu item: { id, text, author: { username, displayName }, createdAt } for the one message it was used on. Nothing else, ever.
composersunflare.composer.getText(), after a click on your plugin.

await sunflare.permissions.list() gives the ones you hold right now; sunflare.permissions.has(name) checks one. Handle the "not allowed" case kindly: tell the user where to turn it on.

Examples#

All three live in public/plugins/ in the repo. Install any of them as-is.

Hello (the basics)#

// ==SunflarePlugin==
// @id          sunflare.example.hello
// @name        Hello Plugin
// @version     1.0.0
// @author      Sunflare
// @description Adds one composer button that fires a desktop notification.
// ==/SunflarePlugin==

sunflare.addComposerButton({ id: 'hello-btn', icon: 'star', title: 'Say hi' });
sunflare.onClick('hello-btn', () => {
  sunflare.notify({ title: 'Hello Plugin', body: 'Composer button clicked.' });
});

Quote (a permission)#

// ==SunflarePlugin==
// @id          sunflare.example.quote
// @name        Quote
// @version     1.0.0
// @author      Sunflare
// @description Right-click a message and choose Quote to put it in your message box.
// @permission  messages
// ==/SunflarePlugin==

sunflare.addContextMenuItem({ id: 'quote', label: 'Quote' });

sunflare.onClick('quote', async event => {
  if (!event.message) {
sunflare.toast('Turn on "Read a message" for Quote in Settings > Plugins to use this.');
return;
  }
  const { text, author } = event.message;
  const short = text.length > 300 ? text.slice(0, 300) + '...' : text;
  await sunflare.composer.insert(`"${short}" (${author.displayName}) `);
});

Snippets (storage, settings, a panel)#

A composer button that opens a panel of saved snippets: click one to drop it into the message box, or add a new one. See public/plugins/snippets-plugin.js for the full file.

What a plugin can't do#

This is enforced by the sandbox, not by convention. There's no bridge for any of it:

Limits worth knowing while building#

Upgrading a plugin from before panels#

Everything from the first version of the API works unchanged. The one difference: plugins now run in a Worker instead of a hidden page, so a plugin that used document or other page APIs needs to drop them. window still exists as an alias for the worker's global scope.

Distributing a plugin#

There's no store or publishing flow. Share the .js file however you like (a gist, a repo, a Sunflare server's file uploads). Anyone installing it goes through the same native file picker as any other install.