# Power SDK reference

> The window.FeatherPanel host API (version 2) for plugin frontends: 13 events, 10 cancellable actions, the payload of each, what the host reads back, and examples.

Source: https://featherpanel.com/docs/plugins/power-sdk/

Code that runs in the panel window (your plugin's `Frontend/index.js`, see [Frontend](https://featherpanel.com/docs/plugins/frontend/)) gets `window.FeatherPanel`. Plugin pages in iframes reach the same bus through `postMessage`. Events are notifications: the panel tells you something happened. Actions are hooks that run before the panel does something, and can change or cancel it. The tables below are read from the panel source, so the payload keys are the ones the panel actually sends.

Declared but never sent by the panel yet: `fp:files:selection`, `fp:files:upload`. Listening to them is harmless but nothing will arrive.

## Host API

`window.FeatherPanel.version` is `2`. The `api` methods call plain axios with the URL you pass (use the full path, for example `/api/user/session`), so same-origin requests carry the user's session cookie. They resolve to the response body, run the `fp:api:request` action first and emit `fp:api:error` when the request fails. `navigate()` runs `fp:nav:push` first. `on`, `register`, `contribute` and `subscribe` return a function that removes what they added.

```ts
interface FeatherPanelHostApi {
    version: number;
    events: {
        on: (event: string, handler: EventHandler) => () => void;
        once: (event: string, handler: EventHandler) => () => void;
        off: (event: string, handler: EventHandler) => void;
        emit: (event: string, payload?: unknown) => void;
    };
    actions: {
        register: (actionId: string, registration: ActionRegistration) => () => void;
        unregister: (actionId: string, handlerId: string) => void;
        run: (actionId: string, ctx?: Record<string, unknown>) => Promise<ActionContext>;
    };
    theme: {
        getMode: () => 'light' | 'dark';
        getAccent: () => string;
        getPackId: () => string;
        subscribe: (listener: () => void) => () => void;
    };
    api: {
        get: <T = unknown>(url: string, config?: object) => Promise<T>;
        post: <T = unknown>(url: string, data?: unknown, config?: object) => Promise<T>;
        put: <T = unknown>(url: string, data?: unknown, config?: object) => Promise<T>;
        delete: <T = unknown>(url: string, config?: object) => Promise<T>;
    };
    toast: {
        success: (message: string) => void;
        error: (message: string) => void;
        info: (message: string) => void;
        message: (message: string) => void;
    };
    navigate: (path: string) => void;
    context: {
        getPathname: () => string;
        getUser: () => unknown;
        getUiPackId: () => string;
        subscribe: (listener: () => void) => () => void;
    };
    ui: {
        modal: {
            register: (def: PluginModalDefinition) => () => void;
            open: (id: string, props?: Record<string, unknown>) => void;
            close: () => void;
        };
    };
    search: {
        contribute: (provider: PluginSearchProvider) => () => void;
        query: (q: string) => Promise<PluginSearchResult[]>;
    };
    shortcuts: {
        register: (shortcut: PluginShortcut) => () => void;
    };
}
```

## Events

Subscribe with `events.on(id, handler)`. The handler gets one payload object with these keys.

| Event | Payload keys | Sent from |
| --- | --- | --- |
| `fp:host:ready``FP_EVENTS.HOST_READY` | `version` | 
[`FeatherPanelHost.tsx:97`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L97)

 |
| `fp:route:change``FP_EVENTS.ROUTE_CHANGE` | `pathname`, `prev` | 

[`FeatherPanelHost.tsx:154`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L154)

 |
| `fp:page:mount``FP_EVENTS.PAGE_MOUNT` | `pathname`, `area` | 

[`FeatherPanelHost.tsx:156`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L156)

[`FeatherPanelHost.tsx:161`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L161)

 |
| `fp:page:unmount``FP_EVENTS.PAGE_UNMOUNT` | `pathname` | 

[`FeatherPanelHost.tsx:155`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L155)

 |
| `fp:theme:change``FP_EVENTS.THEME_CHANGE` | `theme`, `accentColor`, `themePackId` | 

[`FeatherPanelHost.tsx:138`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L138)

 |
| `fp:uipack:change``FP_EVENTS.UI_PACK_CHANGE` | `uiPackId` | 

[`FeatherPanelHost.tsx:146`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L146)

 |
| `fp:session:change``FP_EVENTS.SESSION_CHANGE` | `user`, `authenticated` | 

[`FeatherPanelHost.tsx:182`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L182)

 |
| `fp:server:context``FP_EVENTS.SERVER_CONTEXT` | `uuidShort`, `entering`, `pathname` | 

[`FeatherPanelHost.tsx:171`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L171)

 |
| `fp:server:power:result``FP_EVENTS.SERVER_POWER_RESULT` | `uuid`, `action`, `ok`, `cancelled`, `reason`, `error` | 

[`ServerConsolePage.tsx:555`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/server/ServerConsolePage.tsx#L555)

[`ServerConsolePage.tsx:593`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/server/ServerConsolePage.tsx#L593)

[`ServerConsolePage.tsx:600`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/server/ServerConsolePage.tsx#L600)

 |
| `fp:server:console:ready``FP_EVENTS.SERVER_CONSOLE_READY` | `uuid`, `uuidShort` | 

[`ServerConsolePage.tsx:663`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/server/ServerConsolePage.tsx#L663)

 |
| `fp:files:saved``FP_EVENTS.FILES_SAVED` | `uuid`, `path` | 

[`files-api.ts:285`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/lib/files-api.ts#L285)

 |
| `fp:files:selection``FP_EVENTS.FILES_SELECTION` | \- | not sent |
| `fp:api:error``FP_EVENTS.API_ERROR` | `method`, `url`, `error` | 

[`host-api.ts:70`](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/lib/plugin-sdk/host-api.ts#L70)

 |

## Actions

Register with `actions.register(id, { id, priority, handler })`. "Cancellable" means the panel checks `ctx.cancelled` after the chain and stops. "Reads back" lists the context fields the panel uses after the chain, so changing them changes what the panel does.

| Action | Context keys | Cancellable | Reads back |
| --- | --- | --- | --- |
| `fp:server:power`[ServerConsolePage.tsx:549](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/server/ServerConsolePage.tsx#L549) | `action`, `uuid`, `uuidShort` | yes | `action` |
| `fp:files:save`[files-api.ts:267](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/lib/files-api.ts#L267) | `uuid`, `path`, `content` | yes | `path`, `content` |
| `fp:files:upload`not run by the panel | \- | \- | \- |
| `fp:nav:push`[FeatherPanelHost.tsx:127](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/FeatherPanelHost.tsx#L127), [host-api.ts:116](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/lib/plugin-sdk/host-api.ts#L116) | `path` | yes | `path` |
| `fp:form:submit`[data-fp-action.ts:86](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/lib/plugin-sdk/data-fp-action.ts#L86) | `formId`, `action`, `method`, `data`, `source` | yes | `submitNative` |
| `fp:ui:confirm`[ServerConsolePage.tsx:567](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/server/ServerConsolePage.tsx#L567) | `kind`, `uuid`, `message` | yes | \- |
| `fp:api:request`[host-api.ts:37](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/lib/plugin-sdk/host-api.ts#L37) | `method`, `url`, `data`, `config` | yes | `url`, `data`, `config` |
| `fp:search:query`[useGlobalSearchItems.ts:685](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/hooks/useGlobalSearchItems.ts#L685) | `query` | yes | `query` |
| `fp:shortcut:invoke`[PluginShortcutHost.tsx:50](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/components/plugins/PluginShortcutHost.tsx#L50) | `shortcutId`, `combo`, `actionId` | yes | \- |
| `fp:data:action`[data-fp-action.ts:56](https://github.com/MythicalLTD/FeatherPanel/blob/15b94e9fc9df233123706b47c42728b81e48cb3d/frontendv2/src/lib/plugin-sdk/data-fp-action.ts#L56) | `actionId` | no | \- |

`fp:form:submit` only runs for forms that carry `data-fp-action="fp:form:submit"`; a form with any other `data-fp-action` value runs that action instead. A cancelled `fp:ui:confirm` stops a server kill. `fp:data:action` runs after every `data-fp-action` click that was not cancelled, with the action id and the element's payload, for plugins that want to observe clicks.

## How actions run

-   Handlers run highest `priority` first (default `0`). Registering the same handler `id` again replaces the old one.
-   Each handler receives `(ctx, next)`. Call `await next()` to run the rest of the chain. A handler that returns without calling `next()` stops the chain without cancelling the action.
-   `ctx.cancel(reason)` sets `ctx.cancelled` and `ctx.cancelReason` (default `'cancelled'`). Later handlers do not run.
-   The context object is shared, so a handler can change fields for later handlers and for the panel.
-   `actions.run(id, ctx)` runs a chain yourself, so plugins can define their own actions for other plugins to hook.

## Actions from HTML

The panel watches the page for clicks on elements with `data-fp-action` and for submits of forms with `data-fp-action`. It prevents the default behaviour and runs the named action:

| Trigger | Context | After the chain |
| --- | --- | --- |
| `[data-fp-action] click` | `...payload`, `elementId`, `href`, `source` | Unless cancelled, runs `fp:data:action` and then navigates to `ctx.href` (or the element's `href`) if there is one. |
| `form[data-fp-action] submit` | `formId`, `action`, `method`, `data`, `source` | Unless cancelled, submits the form natively when `ctx.submitNative` is `true`. |

`data-fp-payload` must be JSON; anything else arrives as `{ raw: '...' }`.

## Examples

### Confirm a power action

```js
// Frontend/index.js
const fp = window.FeatherPanel;

// Ask before a server is stopped. ctx.action is 'start', 'stop', 'restart' or 'kill'.
fp?.actions.register('fp:server:power', {
  id: 'myplugin-confirm-stop',
  priority: 10,
  handler: async (ctx, next) => {
    if (ctx.action === 'stop' && !window.confirm('Stop this server?')) {
      ctx.cancel('Stop cancelled');
      return;
    }
    await next();
  },
});

// Every power request reports back, including cancelled ones.
fp?.events.on('fp:server:power:result', (p) => {
  if (p.cancelled) console.info('[myplugin] power action cancelled:', p.reason);
});
```

### Change a file before it is saved

```js
// Normalise line endings before a file is written. The host reads ctx.path and ctx.content back.
window.FeatherPanel?.actions.register('fp:files:save', {
  id: 'myplugin-crlf',
  handler: async (ctx, next) => {
    if (typeof ctx.content === 'string' && ctx.path.endsWith('.properties')) {
      ctx.content = ctx.content.replace(/\r\n/g, '\n');
    }
    await next();
  },
});
```

### Search, shortcuts and modals

```js
const fp = window.FeatherPanel;

// Results in the panel's global search
fp?.search.contribute({
  id: 'myplugin-search',
  search: async (query) => [
    { id: 'myplugin-docs', title: 'My plugin settings', description: query, href: '/admin/myplugin', category: 'My plugin' },
  ],
});

// A keyboard shortcut that runs a direct handler
fp?.shortcuts.register({
  id: 'myplugin-open',
  combo: 'ctrl+shift+m',
  description: 'Open My plugin',
  handler: () => fp.navigate('/admin/myplugin'),
});

// A modal that shows a page from your plugin's public folder
fp?.ui.modal.register({ id: 'myplugin-modal', title: 'My plugin', componentUrl: '/components/myplugin/modal.html', size: 'md' });
fp?.ui.modal.open('myplugin-modal');
```

### Buttons and forms

```html
<!-- Runs the action "myplugin:refresh" with { scope: 'all', elementId, href, source: 'data-fp-action' } -->
<button id="refresh" data-fp-action="myplugin:refresh" data-fp-payload='{"scope":"all"}'>Refresh</button>

<!-- Runs "myplugin:subscribe" with { formId, action, method, data, source: 'data-fp-form' } -->
<form id="subscribe" data-fp-action="myplugin:subscribe" method="post">
  <input name="email" type="email" />
  <button type="submit">Subscribe</button>
</form>

<script>
  window.FeatherPanel?.actions.register('myplugin:subscribe', {
    id: 'myplugin-subscribe',
    handler: async (ctx, next) => {
      await window.FeatherPanel.api.post('/api/myplugin/subscribe', ctx.data);
      ctx.submitNative = false; // true submits the form normally after the chain
      await next();
    },
  });
</script>
```

## Types

```ts
type ActionContext = Record<string, unknown> & {
    /** Set by cancel() - when true, the host must abort the action. */
    cancelled?: boolean;
    cancelReason?: string;
    cancel: (reason?: string) => void;
};

type ActionHandler = (ctx: ActionContext, next: () => Promise<void>) => void | Promise<void>;

type ActionRegistration = {
    /** Unique handler id (plugin-scoped). */
    id: string;
    /** Higher runs earlier. Default 0. */
    priority?: number;
    handler: ActionHandler;
};

type PluginModalDefinition = {
    id: string;
    title?: string;
    /** Public URL to HTML iframe */
    componentUrl?: string;
    /** Public URL to ESM React remote */
    remoteUrl?: string;
    /** Optional width class hint */
    size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
};

type PluginSearchResult = {
    id: string;
    title: string;
    description?: string;
    href?: string;
    category?: string;
    onSelect?: () => void;
};

type PluginSearchProvider = {
    id: string;
    /** Sync or async results for the current query */
    search: (query: string) => PluginSearchResult[] | Promise<PluginSearchResult[]>;
};

type PluginShortcut = {
    id: string;
    /** e.g. "ctrl+shift+m" (lowercase, + separated) */
    combo: string;
    description?: string;
    /** Run a named action hook, or a direct handler */
    actionId?: string;
    handler?: () => void | Promise<void>;
};
```
