User GuidePluginsAPI Reference

Building Plugins

Build taiku plugins with panels, hotkeys, storage, shell aliases, and the SDK host bridge.

Taiku plugins run in sandboxed iframes and communicate with the host application through a typed postMessage protocol. The plugin SDK wraps this protocol into a clean async API, so you write normal TypeScript and the SDK handles serialization, permission enforcement, and message routing.

This guide walks you through building a plugin from scratch, from creating the manifest to rendering a panel that reads live terminal output.

Plugin architecture showing sandboxed iframe, host bridge, and server API

Prerequisites

Before you start, you need:

  • A running taiku session to load your plugin into
  • Familiarity with HTML and JavaScript (plugins render in iframes, so any web technology works)
  • Somewhere to serve the plugin's files over https, or a taiku tunnel to your dev server (see Load your plugin from a URL)

What is public today is the plugin runtime, the message protocol it speaks, and the taiku-plugin-examples repository. The typed SDK (@taiku/plugin-sdk), its browser bundle, the create-taiku-plugin scaffolder and the MockHost test harness are used inside the taiku team and are not published yet. Sections that need them say so. Everything else in this guide works from plain HTML and JavaScript.

How plugins work

Every plugin consists of two pieces: a manifest that declares what the plugin needs, and one or more HTML files that implement the plugin's UI and logic.

When you enable a plugin, taiku reads the manifest to understand what permissions the plugin requests, what panels it provides, and what toolbar actions and hotkeys it registers. Then it creates a sandboxed iframe for each panel, loads the HTML file into it, and sends an initialization message with the plugin ID and session ID. From that point forward, the plugin communicates with taiku exclusively through postMessage.

The sandbox is strict. Plugin iframes have the sandbox attribute set, which means no access to the parent page's DOM, no shared cookies, no direct script execution in the host context. The only channel is the message protocol.

Starting from an example

Copy a plugin that already works and change it. The examples repository holds complete plugins with no build step, plus a blank template:

git clone https://github.com/taiku-dev/taiku-plugin-examples
cp -r taiku-plugin-examples/templates/blank-static my-plugin

Edit manifest.json (at least id, name and author), serve the folder, and load it as described in Load your plugin from a URL.

Without the SDK, a panel talks to taiku with postMessage directly. taiku sends taiku:init once the iframe loads, with a MessagePort in e.ports. Send each call on that port as a taiku:request; taiku replies on the same port with a taiku:response carrying the same id, and sends your taiku:events there too:

const hostWindow = window.parent;
let init = null;
let port = null;
let nextId = 0;
const pending = new Map();

function call(method, params = {}) {
  const id = "req_" + ++nextId;
  const { pluginId, panelId = "", authToken = "" } = init;
  port.postMessage({
    type: "taiku:request",
    id,
    pluginId,
    panelId,
    authToken,
    method,
    params,
  });
  return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
}

function onHostMessage(d) {
  if (d?.type === "taiku:response" && pending.has(d.id)) {
    const { resolve, reject } = pending.get(d.id);
    pending.delete(d.id);
    d.error ? reject(new Error(d.error)) : resolve(d.result);
  }
}

window.addEventListener("message", (e) => {
  if (e.source !== hostWindow || e.data?.type !== "taiku:init") return;
  init = e.data;
  port = e.ports[0];
  port.onmessage = (ev) => onHostMessage(ev.data);
  port.postMessage({ type: "taiku:port-ready" });
  // The app's design tokens, so the panel can match it (see below).
  for (const [name, value] of Object.entries(init.theme?.tokens ?? {}))
    document.documentElement.style.setProperty(name, value);
  call("getSession").then(
    (s) => console.log("connected to", s.id),
    (err) => console.error(err.message),
  );
});

When the app's theme changes while the panel is open (a contrast preference, the interface font), taiku sends { type: "taiku:theme", panelId, theme } with the whole token set again, on the port. Apply it the same way. The SDK does this for you: anything you styled with applyTheme() follows, and onThemeChange(fn) tells you when it moved.

taiku:port-ready tells taiku the panel reads its port, so events go there from the start. The port belongs to this page: if the panel navigates away, nothing taiku sends on it reaches the next page. Requests posted to window.parent still work, and are answered on the window, but use the port.

Check e.source against window.parent as shown: without it, any other frame can post a forged taiku:init into your panel. getSession needs session.read in the manifest's permissions; a call without its permission comes back as a taiku:response with an error naming the one that is missing. Method names, parameters and permissions are listed in the API Reference. Every plugin in the examples repository is a complete panel written this way.

Creating a plugin with the scaffolding tool

Needs a taiku source checkout. create-taiku-plugin and @taiku/plugin-sdk are not published, so npm create taiku-plugin@latest returns a 404. If you are outside the taiku team, use Starting from an example.

From the root of a taiku checkout, with Node.js 18 or newer:

node packages/create-taiku-plugin/index.js my-plugin --id your-org.my-plugin --author "Your Name"
cd my-plugin
npm install

Scaffolding this way copies the SDK source into your project at src/taiku-sdk/, which is what lets npm install succeed: nothing in the generated package.json needs the unpublished package. Your code still imports it under its real name, @taiku/plugin-sdk, resolved by a paths entry in tsconfig.json and an alias in vite.config.ts. When the package is published, delete src/taiku-sdk, remove those two entries and run npm i @taiku/plugin-sdk; nothing you wrote has to change.

This creates a working starter with:

  • manifest.json with marketplace/support metadata
  • index.html plus src/main.ts wired to @taiku/plugin-sdk, and src/main.test.ts with three tests to start from
  • package.json, vite.config.ts, and scripts that validate the manifest and copy it into dist/ on build
  • README.md that maps taiku concepts to common plugin patterns
  • .claude/skills/taiku-plugin-authoring/SKILL.md, an authoring guide that Claude Code loads when you work in the project

You can still create these files manually, since there is no hidden runtime, but the scaffolding gives you a much stronger default path.

Check the scaffold before writing anything. All four of these pass on a fresh project:

npm test          # three tests against MockHost, no browser or session needed
npm run typecheck
npm run validate  # manifest.json against the rules the host enforces
npm run build

npm run validate checks the manifest offline, before you load anything into a session. It reports every problem at once, names the field, and suggests a correction for a misspelled permission:

manifest.json: 3 problems
  id: Plugin id must be 1-64 characters of letters, digits, '.', '_' or '-', starting with a letter or digit. ...
  permissions[6]: Unknown plugin permission "session.reed". The host refuses the whole manifest on an unknown name. Did you mean "session.read"?
  toolbarActions[0].panelId: References unknown panel "ghost". Declared panels: main.

npm run build runs it first, so a manifest that cannot install cannot ship.

Keeping a project current

The SDK copy in src/taiku-sdk/ and the authoring skill both describe the taiku they were scaffolded from, and the skill records that version. To see whether taiku has moved on since:

npm run check:taiku                               # against https://taiku.live
npm run check:taiku -- --url https://your-taiku   # against another taiku

It prints Up to date or Out of date, and exits 1 when out of date so it can gate CI. When it cannot reach the server it only warns.

To bring an older project up to date, update your taiku checkout and run the scaffolder with --refresh on the project:

node packages/create-taiku-plugin/index.js --refresh path/to/my-plugin

It rewrites only the files it owns (the skill, scripts/check-taiku.mjs, the check:taiku script entry and src/taiku-sdk/), lists what changed, and says Already current when nothing did. Your manifest and your own code are left alone. It refuses to write through a symlink, and changes nothing at all when it finds one.

Load your plugin from a URL

Custom plugins are loaded from a manifest URL inside taiku:

  1. Open Settings (toolbar, or Ctrl+Shift+S), expand Integrations, and pick the Plugins tab. It is there when you are the session's admin.
  2. Go to the Submit tab. (That tab also holds registry submission; for your own work you want Load Custom Plugin at the top.)
  3. Paste the full URL to manifest.json and click Load. If you paste a folder URL without the filename, taiku appends /manifest.json for you.

The plugin is on as soon as it loads. It appears in the Installed tab, where On/Off turns it off and on again.

If the manifest has mistakes, Load lists all of them at once, each with the field it is in, and suggests the right name for a misspelt permission. Fix them and press Load again: taiku fetches the manifest and the panel's HTML afresh each time, so an edit shows up even when your host caches files. Scripts and styles the panel loads from separate files follow your host's usual caching.

Serving the files. taiku fetches manifest.json and your panel from its own origin, so the server has to send Access-Control-Allow-Origin (for example *). Without it the browser refuses the response, and Load says it could not reach the server. GitHub Pages sends it, so a plugin pushed to a GitHub Pages site loads as is; a local dev server may need it switched on.

If taiku is open on https://…, browsers refuse http:// addresses as mixed content. Chrome and Firefox make an exception for http://localhost; other browsers may not. Practical options:

  • Tunnel the dev port in the same session (taiku --tunnel 5174), then use your tunnel base URL plus manifest.json (same pattern as other tunneled HTTP paths; see the tunnels guide).
  • Use a local http taiku tab during web development (if your setup serves taiku over HTTP), where http://127.0.0.1:5174/manifest.json can load.
  • Host the folder on any https static host (for a scaffolded project, its dist/ after npm run build), then load …/manifest.json from there.

Session installs. A plugin you load with Load Custom Plugin is installed into the session, the same as one from Browse, so everyone connected to the session gets it too. Keep that in mind if you are debugging with collaborators.

The manifest

The manifest is a JSON file that declares everything taiku needs to know about your plugin. Here is a complete example for a plugin that opens a sidebar panel and reads terminal output:

{
  "id": "my-org.shell-inspector",
  "name": "Shell Inspector",
  "version": "1.0.0",
  "author": "my-org",
  "description": "Inspect terminal output and highlight patterns",
  "tagline": "Pattern matching for terminal output",
  "homepageUrl": "https://github.com/my-org/shell-inspector",
  "documentationUrl": "https://github.com/my-org/shell-inspector#readme",
  "supportUrl": "https://github.com/my-org/shell-inspector/discussions",
  "fundingUrl": "https://github.com/sponsors/my-org",
  "bugsUrl": "https://github.com/my-org/shell-inspector/issues",
  "license": "MIT",
  "keywords": ["terminal", "inspector", "patterns"],
  "categories": ["devtools"],
  "permissions": [
    "session.read",
    "terminal.read",
    "ui.panel",
    "ui.toolbar.custom",
    "events.subscribe",
    "hotkey.register"
  ],
  "panels": [
    {
      "id": "inspector-panel",
      "title": "Shell Inspector",
      "position": "sidebar",
      "htmlUrl": "panel.html",
      "defaultWidth": 360
    }
  ],
  "toolbarActions": [
    {
      "id": "open-inspector",
      "label": "Shell Inspector",
      "icon": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>",
      "panelId": "inspector-panel"
    }
  ],
  "hotkeys": [
    {
      "id": "toggle-inspector",
      "label": "Toggle Shell Inspector",
      "hotkey": "Cmd+Shift+I",
      "category": "Plugins",
      "action": { "type": "openPanel", "panelId": "inspector-panel" }
    }
  ]
}

Manifest fields explained

id is a globally unique identifier for your plugin. Use a reverse-domain style like my-org.plugin-name. Built-in plugins use the taiku. prefix; example plugins use example..

name is the human-readable display name shown in the plugin panel and toolbar.

version follows semver. Taiku uses this for update detection when plugins are loaded from URLs.

homepageUrl, documentationUrl, supportUrl, fundingUrl, and bugsUrl let taiku surface the plugin's ecosystem links directly in the marketplace and detail UI.

license and keywords improve discovery and make distribution terms explicit.

permissions is an array of permission strings. Taiku enforces these at runtime. If your code calls a bridge method that requires a permission you did not declare, the call is rejected. Always declare the minimum set you need. See the permissions reference for the full list and risk notes.

panels defines the iframe panels your plugin provides. Each panel has:

  • id: unique within the plugin, referenced by toolbar actions and hotkeys
  • title: displayed in the panel header
  • position: one of sidebar, sidebar-left, bottom, floating, tooltip, overlay, outputs (a section in an agent tile's Outputs, below), or agent-sidebar (a group in an agent tile's rail, below)
  • htmlUrl: path to the HTML file, resolved relative to the manifest location
  • defaultWidth / defaultHeight: initial dimensions in pixels
  • persistPosition: if true, reopening the panel restores its last dragged location and size instead of returning to the manifest defaults
  • background: if true, the panel is invisible (used for background event listeners)

A panel with "position": "outputs" is a section in each agent tile's Outputs. It needs the outputs surface and the ui.outputs permission. taiku draws the section's header: your plugin's icon, the panel's title and a count. Your page is the body. The section doesn't appear until your page calls outputs.show, so call it once you have something to show, with an optional count from 1 to 999. outputs.hide takes it away again. The section starts closed, and the count shows while it is closed.

Your page also runs while the section is closed, out of sight, so it can decide when to show. It hears outputs.context with { agentSlug, runId } for the agent beside it. Your page can be reloaded when the section opens, closes or moves between the column and the sheet, so keep what it shows in plugin data, not in the page. resizePanel with a number sets the body's height, between 80 and 320 pixels. The example.outputs-ports plugin is a short example.

A plugin that holds ui.outputs can also offer files to the gallery wall with setOutputsItems: a list of { id, title, file_name, mime?, modified_at?, content } that replaces your last list whole, under your plugin's name. Send the file's bytes (the SDK encodes them), never a URL or a page: taiku draws the thumbnail and opens the file itself. At most 40 files, 4 MiB each and 16 MiB in all; a call over a limit is refused and changes nothing, as is a second call within a second or more than 32 MiB in a minute. The list is kept on disk when the browser allows (the answer's stored says whether it did), so the wall shows it while your page is not running, and it goes when the plugin is removed or stops asking for ui.outputs.

{
  "surfaces": ["web", "phone", "outputs"],
  "permissions": ["ui.outputs"],
  "panels": [
    {
      "id": "ports",
      "title": "Ports",
      "position": "outputs",
      "htmlUrl": "panel.html"
    }
  ]
}

A panel with "position": "agent-sidebar" is a group in each agent tile's right rail, after the agent's own groups. It needs the agent-tile surface and the ui.agent-panel permission, and a manifest that has the panel without the permission is refused with that reason. Your page draws nothing: it runs out of sight beside each agent tile, hears agent-panel.context with { slug } for the agent it sits beside, and hands taiku that agent's rows with setAgentPanel({ slug, rows }). taiku draws them the way it draws every role's, one line each, under your plugin's name. A group with no rows is not drawn, and does not open the column on its own session. When the tile moves to another agent taiku empties your group at once and sends agent-panel.context for the new one. slug names the agent you read the rows for: rows for an agent the tile has left are dropped (the call answers kept: false), so a read still out when it moved never shows under the next one. When a read fails, send the rows you last read and failed, why in a few words: taiku puts a failure badge on your plugin's mark, with those words for its tip and its description to a screen reader, rather than a row saying it failed. Leave failed out once a read works.

A row is { text, key?, icon?, tone?, status?, num?, ref?, meta?, href?, action? }. Everything is plain text, cut to fit. An icon is a name from taiku's set, a tone is danger, warn, ok, run, accent or muted, and href opens in a new tab and must be https. An unknown icon, a tone outside those or a link that is not https is dropped and the row stays. A row has at most one action of { label, id }: pressing it sends your page agent-panel.action with { slug, group, row, id }. The first rows show, then Show all; hand over at most 50, and say more or total if you have others. example.agent-digest has a rail panel.

toolbarActions adds buttons to the taiku toolbar. Each action can reference a panelId to toggle that panel, or an eventType to broadcast an event when clicked. The icon field accepts inline SVG strings.

replyActions adds one action to the More (⋯) under each finished reply in an agent conversation, a row with its label over your plugin's name. It needs agent.actions and a panelId. Pressing it shows that panel and sends it one reply_action event, with that reply and nothing else. Your plugin's other panels and sections don't get it. If the panel isn't open yet, the event comes right after it starts:

{
  "permissions": ["agent.actions", "ui.panel", "session.read", "storage.write"],
  "replyActions": [
    {
      "id": "save",
      "label": "Save to notes",
      "panelId": "notes"
    }
  ]
}
taiku.on("reply_action", (event) => {
  const { actionId, reply } = event.data as ReplyAction;
  // reply.text, reply.runId, reply.agent.slug, reply.agent.name
});

This example keeps each reply it's given, so it asks for session.read to read what it kept with getUserData and storage.write to save it with setUserData. An action that only shows the reply needs neither.

A plugin declares at most one. The label is plain words, at most 24 characters. The panel is how whoever pressed sees what came of it, so it has to be one the session draws, not an outputs panel or a background one, and it should say what it did with the reply, or that it couldn't. The More appears in a session's agent tile, not on the agent's own page.

composerCommands adds up to three slash commands to the agent composer. Each shows in the / list ending in your plugin's name and the word plugin, "Done list plugin" for example. A command's optional panelId names a panel that running it also shows, so it has to be one the session draws: not an outputs panel or a background one. Only that panel gets the command, and if it was closed it gets the command as soon as it starts, so answer once the panel has what it needs: the Done list sample reads its list first. A command with no panelId goes to each of your panels that is open. It needs composer.contribute, and a panel (any position but outputs) to hear the command run. Running one sends nothing to the agent. Your plugin gets one composer_command event with the words typed after the command, and can answer with one draft for the person to read and send:

{
  "permissions": ["composer.contribute", "ui.panel"],
  "composerCommands": [
    {
      "name": "standup",
      "description": "Draft a standup from your list.",
      "panelId": "list"
    }
  ]
}
taiku.on("composer_command", async (event) => {
  const { commandId, command, args } = event.data as ComposerCommand;
  // `/standup shipping the docs` arrives as args "shipping the docs"
  await taiku.fillComposer(commandId, `Standup\nNext: ${args}`);
});

A name is lowercase letters, digits and dashes, and can't be one of the composer's own commands, such as /clear or /btw. If the agent has a command of the same name, the agent's wins. fillComposer works once per run, within 30 seconds, for up to 16 KiB of plain text, and only while the composer is still empty: if the person has started typing, it's refused and their text stays. Text with control characters other than tab and newline, or with invisible formatting characters such as direction marks, zero-width spaces or tag characters, is refused too. The joiner and variation selector that emoji are built from are allowed. The commands appear in a session's agent tile, not on the agent's own page.

hotkeys registers keyboard shortcuts. The action field supports three types:

  • { "type": "openPanel", "panelId": "..." }: toggles a panel
  • { "type": "broadcastEvent", "eventType": "..." }: emits an event
  • { "type": "toggleToolbar", "actionId": "..." }: toggles a toolbar action

The hotkey you declare is a default, not a claim. Users can rebind it, clear it, or reset it to your default from Settings → Keyboard, and their choice is keyed on your plugin id plus the hotkey id. That means it survives a plugin update: republishing your manifest with a different hotkey changes the default but never moves a key the user chose. Keep hotkey ids stable for that reason, and don't put a : in them.

Because the plugin id is part of that key, it has to be short and simple: 1 to 64 characters, letters, digits, ., _ and -, starting with a letter or digit. A plugin whose id breaks that rule will not install.

Pick a default that is unlikely to be taken. A shortcut that collides with a builtin command or another plugin shows up in the editor named on both sides, and the user is told about it when they install or update your plugin, so it never changes hands quietly. Until they resolve it, the builtin wins.

Avoid Ctrl+Alt+… as a default. On German, French, and other layouts with a third-level key, Windows and Linux report AltGr as Ctrl+Alt, so taiku refuses those keystrokes to keep the user's typing intact and your shortcut would never fire for them.

secrets declares API keys or credentials the user needs to provide. These are stored in this client’s local storage, never sent to the server, and the plugin reads them via getSecret(). Each entry needs a key (a letter, then up to 63 letters, digits, ., _ or -) and a label; a manifest can declare up to 32, and a value can be up to 16 KB. The user enters them in the plugin's Secrets section under Installed. getSecret rejects a key your manifest does not declare, and a later version that drops a key, or moves it behind a ref, deletes the value the user saved for it.

A secret can instead name where it lives, so the user never types it into the browser:

"secrets": [
  {
    "key": "openai",
    "label": "OpenAI key",
    "ref": { "source": "env", "id": "OPENAI_API_KEY" }
  }
]

source is env, file (a relative path), exec (the name of a command the host has configured, not a command line) or store (with a provider, such as a keychain). taiku shows the pointer and never stores or hands over the value: getSecret for that key is rejected with an error that names it. Nothing in the web client reads a ref yet, so use one only for a value your plugin does not need to read itself.

settings declares user-configurable options. Each setting has a type (text, number, toggle, select, or button), a default value, and a label. Users configure these in the plugin settings UI, and the plugin reads them via getSetting().

surfaces lists where the plugin can be used:

ValueWhere
webtaiku in a computer's browser or the desktop app
phonetaiku on a phone, in its browser or the app
tuithe terminal client, for a plugin with an exec entry point
agent-tileits own tile carrying an agent
outputsa section beside an agent's conversation
new-menuan entry in the New menu
clitaiku commands
"surfaces": ["web", "phone"]

Each surface lists and runs only the plugins that name it. Everywhere else the plugin is still listed, marked "Not available here", and a tile left open for it says where it works. Leave the field out and the plugin gets web, phone unless mobileFriendly is false, and tui if it has exec. An empty list, an unknown value or a repeated one is refused at install. An agent plugin must include agent-tile or outputs. agent-tile, outputs and cli are accepted now, so you can declare them ahead; they take effect as each surface opens to plugins.

With new-menu, each of your visible (not background) panels gets an entry in the "+ New" menu of an empty page, named by the panel's title. Picking it opens that panel as a tile. It needs the workspace.write permission, the same as createPluginTile, and resourceLimits.maxTiles still applies. List web or phone too, since the entry shows only where the plugin runs:

"surfaces": ["web", "phone", "new-menu"]

mobileFriendly set to false is the older way to leave out phone. A manifest that sets it and lists phone in surfaces is refused.

If you're coming from Obsidian

Taiku plugins are not class-based host extensions. They run in sandboxed iframes and communicate with the app through the SDK bridge. The feature mapping is still straightforward:

  • addRibbonIcon(...) -> declare a toolbarActions item in manifest.json
  • addCommand(...) -> declare a hotkeys item or expose actions inside your panel
  • addSettingTab(...) -> declare settings and secrets in manifest.json
  • loadData() / saveData() -> use getSetting() / setSetting() for local browser-backed config, getUserData() / setUserData() for persistent per-user data, or session KV for collaborative state
  • onload() -> initialize after await taiku.ready()

The result is similar to Obsidian's plugin ergonomics, but with a stricter and safer runtime boundary.

Building the panel

The panel HTML file is a self-contained web page. It loads the SDK, connects to the host, and renders its UI. Here is a minimal panel that displays session information:

<!DOCTYPE html>
<html>
  <head>
    <style>
      body {
        background: rgb(var(--tk-bg, 10 10 12));
        color: rgb(var(--tk-ink-secondary, 205 208 214));
        font-family: var(--tk-ui-font, system-ui, sans-serif);
        font-size: 13px;
        padding: 12px;
        margin: 0;
      }
      h2 {
        color: rgb(var(--tk-ink, 244 244 245));
        font-size: 14px;
        margin: 0 0 8px;
      }
      pre {
        background: rgb(var(--tk-surface-2, 32 32 37));
        padding: 8px;
        border-radius: 4px;
        overflow-x: auto;
        font-size: 12px;
      }
    </style>
  </head>
  <body>
    <h2>Shell Inspector</h2>
    <div id="content">Connecting...</div>

    <!--
      IIFE bundle, not a module import: this file has no build step, so it
      uses the pattern from "Using the IIFE browser bundle" below rather than
      `import ... from "./browser.js"`, which needs a `tsc` build the SDK's
      own scripts don't produce as a single drop-in file. Build once from a
      taiku source checkout
      (`cd packages/plugin-sdk && npm install && npm run build`) and copy
      dist/browser.iife.js next to this HTML. The bundle is not published;
      without a checkout, use the postMessage bridge from "Starting from an
      example" instead.
    -->
    <script src="./browser.iife.js"></script>
    <script>
      (async () => {
        const taiku = new TaikuSDK.TaikuPlugin();
        await taiku.ready();
        taiku.applyTheme();

        const content = document.getElementById("content");

        async function render() {
          const session = await taiku.getSession();
          content.innerHTML = `
            <p>Session: <strong>${session.name || session.id}</strong></p>
            <p>Users: ${session.userCount} | Shells: ${session.shellCount}</p>
          `;

          // Show recent output from the first shell
          const shells = await taiku.getShells();
          if (shells.length > 0) {
            const output = await taiku.getShellOutput(shells[0].id, 5);
            const pre = document.createElement("pre");
            pre.textContent = output.chunks.join("");
            content.appendChild(pre);
          }
        }

        await render();

        // Re-render when a shell opens or closes
        taiku.on("shells_changed", render);
      })();
    </script>
  </body>
</html>

Matching the app's look

Your panel runs in a sandboxed iframe with an opaque origin, so it inherits none of taiku's CSS: custom properties do not cross that boundary. The host sends its design tokens with the init message instead, and the SDK writes them onto your document:

await taiku.ready();
taiku.applyTheme(); // sets --tk-* on your :root

Without the SDK, the loop in Starting from an example does the same thing. Then style with the same tokens the app uses:

body {
  background: rgb(var(--tk-bg));
  color: rgb(var(--tk-ink));
  font-family: var(--tk-ui-font);
}
.muted {
  color: rgb(var(--tk-ink-muted));
}
.accent {
  color: rgb(var(--tk-accent) / 0.8);
}

Colour tokens are channel triples (46 143 255) rather than rgb() strings, which is what makes rgb(var(--tk-accent) / <alpha>) work. The ones you will reach for: --tk-bg, --tk-surface-1/2/3, --tk-ink, --tk-ink-secondary, --tk-ink-muted, --tk-line, --tk-accent, --tk-ok, --tk-warn, --tk-danger, plus --tk-ui-font and --tk-doc-font.

applyTheme() returns false when the host sent no tokens, so give every token a fallback, as in rgb(var(--tk-bg, 10 10 12)). Use the app's own values as the fallbacks and the panel paints in the right colours before the init message arrives, with no flash when the tokens land. The plugins that ship with taiku all work this way.

Keyboard

Never cancel the Tab key in your panel. Keys pressed in your panel stay in your panel's page, so Tab and Shift+Tab are how a keyboard moves through it and back out into taiku. A keydown handler that calls preventDefault() on Tab traps anyone who can't use a mouse. Use Tab for its usual job, and give your own shortcuts other keys.

In a panel whose page comes from html or htmlUrl, Ctrl+Shift+X (Focus Tile Controls) moves focus out to the panel's own controls whatever your page does with its keys, so don't bind it to anything of your own. A panel loaded from url has no such way out, and Tab is all its users have. The manifest validator warns about a panel whose inline html looks like it cancels Tab.

The SDK initialization flow

The initialization sequence works like this:

  1. Your panel HTML loads and creates a TaikuPlugin instance.
  2. The constructor starts listening for postMessage events on the window.
  3. The host detects the iframe is ready and sends a taiku:init message containing the pluginId and sessionId.
  4. The ready() promise resolves, and your plugin can start making bridge calls.

Always await taiku.ready() before calling any other SDK methods. Calls made before initialization will timeout.

Using the SDK with a bundler

A scaffolded project already has the SDK: it vendors the source at src/taiku-sdk/, and imports of @taiku/plugin-sdk resolve there. Nothing to install.

Adding it to a project you did not scaffold, point a dependency at a local build from a taiku source checkout:

cd packages/plugin-sdk && npm install && npm run build
npm install file:../path/to/taiku/packages/plugin-sdk

or use the IIFE bundle, described above.

The SDK is not published, so npm install @taiku/plugin-sdk returns a 404 and both routes above need a taiku source checkout. Without one, use the postMessage bridge shown in Starting from an example.

Then import it in your TypeScript or JavaScript:

import { TaikuPlugin } from "@taiku/plugin-sdk";

const taiku = new TaikuPlugin();
await taiku.ready();

const session = await taiku.getSession();
console.log(`Connected to session: ${session.id}`);

Using the IIFE browser bundle

For simple plugins that do not use a build system, the SDK provides a pre-built IIFE bundle that exposes window.TaikuSDK. Put that bundle in the same folder as your HTML (build the SDK once from a taiku source checkout, then copy the IIFE output next to the page).

<script src="./browser.iife.js"></script>
<script>
  const taiku = new TaikuSDK.TaikuPlugin();
  taiku.ready().then(() => {
    // Plugin is connected
  });
</script>

The architecture split: host bridge vs HTTP API

Plugin capabilities are split across two communication channels, and understanding which to use is important for writing performant plugins.

The host bridge (fast, in-browser)

The host bridge is the postMessage protocol between the plugin iframe and the taiku host page. Bridge calls are synchronous from the host's perspective. The host receives the message, validates the permission, executes the action, and sends a response. Round-trip latency is sub-millisecond because no network request is involved.

Use the host bridge for:

  • Reading session, shell, and user information
  • Writing to terminals
  • Opening and closing panels
  • Subscribing to events
  • Managing workspace layout (split tiles, switch workspaces)
  • Showing toasts and sending notifications
  • Broadcasting events to other plugins
// All of these are bridge calls -- fast, in-browser
const session = await taiku.getSession();
const shells = await taiku.getShells();
const output = await taiku.getShellOutput(1, 10);
await taiku.writeToShell(1, "echo hello\n");
await taiku.showToast("Build complete!", "success");

The HTTP API (persistent, server-backed)

Some operations require server involvement because the data must persist beyond the browser session or the current WebSocket connection. These go through HTTP endpoints on the taiku server.

Use the HTTP API for:

  • Session KV data (shared by everyone in the session)
  • User KV data (persistent across sessions, Postgres-backed)
  • Plugin file storage (server-side file persistence)
  • Session-managed plugin coordination
// These are HTTP calls -- slightly slower, but stored on the server
await taiku.setPluginData("key", { count: 42 });
await taiku.setUserData("theme", "dark");
const theme = await taiku.getUserData("theme");

await taiku.writePluginFile("report.json", JSON.stringify(data));
const files = await taiku.listPluginFiles();

The SDK abstracts this distinction: you call the same TaikuPlugin methods regardless of whether the underlying transport is postMessage or HTTP. But the distinction matters for performance: use bridge calls in hot paths (every keystroke) and HTTP calls for persistence.

For session-scoped HTTP routes, the SDK and host runtime also abstract the authentication details. Internally, taiku now uses a one-time challenge-based flow for session REST access: direct callers fetch /api/s/{session_id}/auth-challenge, sign the returned nonce with the session secret, and send both the nonce and proof headers. The server consumes that challenge once, which prevents a captured proof from acting like a reusable bearer token. If the route needs writer or admin access, callers may also need X-Taiku-Session-Auth derived from the same challenge. If you stay on the bridge/SDK path, you do not need to implement that flow yourself.

Account-owned HTTP routes, such as plugin storage, per-user KV, and saving a session's plugin defaults, do not use the session challenge flow. They rely on normal account authentication instead: either the taiku_session browser cookie, Authorization: Bearer <api_key>, or X-Taiku-Token: <api_key>. The authenticated account must still own the session. Common user-facing failures across the HTTP layer are 401 for missing or invalid auth, 403 for missing write/admin/owner access, 429 for storage rate limits, 502 when no CLI can accept a CLI-backed request, 504 when the CLI times out, and 503 when the server cannot queue the CLI request or the database is unavailable.

For detailed coverage of each storage layer with code examples, see Using Plugins.

Decision guide

Data characteristicUse this
Shared within current session, ephemeralSession KV (setPluginData / getPluginData)
User-specific, must survive session endUser KV via HTTP (setUserData / getUserData)
File-like data, binary, or largePlugin storage via HTTP (writePluginFile / readPluginFile)
Device-specific secrets, never leaves browserLocal secrets (getSecret)
Real-time updates for all participantsEvent broadcast via bridge (broadcastEvent)

Events

taiku.on(type, callback) subscribes your panel to events from the host ("*" receives all of them). It needs events.subscribe. The callback gets { type, sessionId, timestamp, data }. These are the events taiku sends:

Eventdata
shells_changed{ shellIds }, when a shell opens or closes
shell_adopted{ shellIds }
terminal_title_changed{ shellId, title, customName }
layout_changed{ tileCount, tiles }
tile_focused{ tileKey, type, id }
workspace_changed{ activeIndex, pageCount }
user_joined{ userId, userName, canWrite, device }
user_left{ userId, userName, device }
user_focused, user_unfocusedas user_joined, plus focus and previousFocus
chat{ userId, userName, message, mine }. Also needs chat.read
tunnel_changed{ ports }. Also needs tunnel.read
agent_status{ shellId, instanceId, sessionId, agentType, status, model, tokenUsage }. Needs agent.events
agent.<type>one entry from an agent's history, such as agent.tool_call. Needs agent.events
plugin.<pluginId>.<eventType>whatever that plugin passed to broadcastEvent
plugin.<your id>.typed{ id, outcome, why? }: what became of a pending writeToShell. Terminal client only
reply_action{ actionId, reply }: a press on your reply action, sent to the panel it shows
composer_command{ commandId, command, args, agent }: your slash command was run, sent to you alone
settings.changed{ key, value }: your own setting, changed in Settings > Plugins

An event whose extra permission your manifest lacks is never delivered, and the browser console says so once. Another participant's plugin.<pluginId>.* event reaches your plugin only when your copy was installed from the same place as theirs, and its data then carries senderUid, the user id of whoever sent it. settings.changed needs no permission, reaches only the plugin whose setting it is, and is not sent for a change that plugin made with setSetting. There is no event per chunk of terminal output: to follow a shell, re-read it with getShellOutput().

Complete example: a "hello world" plugin

Here is a complete, working plugin that opens a sidebar panel, reads terminal output from all active shells, and keeps it current as shells come and go. You can copy these two files into a directory and load them into a taiku session.

manifest.json

{
  "id": "example.hello-world",
  "name": "Hello World",
  "version": "0.1.0",
  "author": "you",
  "description": "Displays live terminal output from all shells",
  "permissions": [
    "session.read",
    "terminal.read",
    "events.subscribe",
    "ui.panel",
    "ui.toolbar.custom"
  ],
  "panels": [
    {
      "id": "hello-panel",
      "title": "Hello World",
      "position": "sidebar",
      "htmlUrl": "panel.html",
      "defaultWidth": 320
    }
  ],
  "toolbarActions": [
    {
      "id": "open-hello",
      "label": "Hello World",
      "icon": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"4 17 10 11 4 5\"/><line x1=\"12\" y1=\"19\" x2=\"20\" y2=\"19\"/></svg>",
      "panelId": "hello-panel"
    }
  ]
}

panel.html

<!DOCTYPE html>
<html>
  <head>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        background: rgb(var(--tk-bg, 10 10 12));
        color: rgb(var(--tk-ink-secondary, 205 208 214));
        font-family: var(--tk-ui-font, system-ui, sans-serif);
        font-size: 12px;
        padding: 0;
        overflow-y: auto;
        height: 100vh;
      }
      .header {
        padding: 8px 12px;
        border-bottom: 1px solid rgb(var(--tk-line, 38 38 43));
        color: rgb(var(--tk-ink, 244 244 245));
        font-weight: 600;
        font-size: 13px;
        position: sticky;
        top: 0;
        background: rgb(var(--tk-bg, 10 10 12));
      }
      .shell-section {
        padding: 8px 12px;
        border-bottom: 1px solid rgb(var(--tk-line, 38 38 43));
      }
      .shell-label {
        color: rgb(var(--tk-ink-muted, 139 139 150));
        font-size: 11px;
        text-transform: uppercase;
        letter-spacing: 0.5px;
        margin-bottom: 4px;
      }
      .shell-output {
        background: rgb(var(--tk-surface-1, 22 22 25));
        padding: 6px 8px;
        border-radius: 4px;
        font-family: "SF Mono", "Fira Code", monospace;
        font-size: 11px;
        white-space: pre-wrap;
        word-break: break-all;
        max-height: 200px;
        overflow-y: auto;
        color: rgb(var(--tk-ink, 244 244 245));
      }
      .status {
        padding: 12px;
        color: rgb(var(--tk-ink-muted, 139 139 150));
        text-align: center;
      }
    </style>
  </head>
  <body>
    <div class="header">Live Terminal Output</div>
    <div id="shells">
      <div class="status">Connecting...</div>
    </div>

    <!--
      IIFE bundle, no build step of your own required: build the SDK once
      from a taiku source checkout
      (cd packages/plugin-sdk && npm install && npm run build) and copy
      dist/browser.iife.js into this directory before loading the plugin.
      The bundle is not published; without a checkout, use the postMessage
      bridge from "Starting from an example" instead.
    -->
    <script src="./browser.iife.js"></script>
    <script>
      (async () => {
        const taiku = new TaikuSDK.TaikuPlugin();
        await taiku.ready();
        taiku.applyTheme();

        const shellsEl = document.getElementById("shells");

        async function refreshShells() {
          const shells = await taiku.getShells();
          if (shells.length === 0) {
            shellsEl.innerHTML = '<div class="status">No shells yet</div>';
            return;
          }

          shellsEl.innerHTML = "";
          for (const shell of shells) {
            const output = await taiku.getShellOutput(shell.id, 5);
            const section = document.createElement("div");
            section.className = "shell-section";
            section.dataset.shellId = shell.id;
            section.innerHTML = `
              <div class="shell-label">Shell ${shell.id} (${shell.cols}x${
                shell.rows
              })</div>
              <div class="shell-output">${escapeHtml(
                output.chunks.join(""),
              )}</div>
            `;
            shellsEl.appendChild(section);
          }
        }

        function escapeHtml(str) {
          return str
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;");
        }

        // Initial load
        await refreshShells();

        // Rebuild the list when a shell opens or closes
        taiku.on("shells_changed", refreshShells);

        // There is no per-keystroke output event, so re-read the output
        // of the shells already on screen every couple of seconds
        setInterval(async () => {
          for (const section of shellsEl.querySelectorAll("[data-shell-id]")) {
            const output = await taiku.getShellOutput(
              Number(section.dataset.shellId),
              5,
            );
            section.querySelector(".shell-output").textContent =
              output.chunks.join("");
          }
        }, 2000);
      })();
    </script>
  </body>
</html>

This plugin demonstrates the core pattern: initialize the SDK, read data from the bridge, render it as HTML, and subscribe to events for live updates. shells_changed is one of the events taiku sends; the rest are listed under Events. From here, you can extend it with writing to terminals, adding KV storage, registering hotkeys, or any other capability the plugin system supports.

Beyond install-and-forget: building interactive systems

The hello-world example above reads terminal output and displays it. That is useful, but it barely scratches the surface. The plugin API exposes enough of the session runtime that you can build genuinely interactive systems: not just passive monitors, but tools that make decisions, surface the right information at the right time, and take action on your behalf.

Here are three concrete directions that go well beyond what a manifest.json sidecar alone can do.

Programmatic agent cost management

If you run multiple AI coding agents in a session, costs add up fast. A plugin can track spending in real time and enforce budgets. This one needs session.read, agent.events, ui.toast and ui.notify:

const taiku = new TaikuPlugin();
await taiku.ready();

// Token and cost totals across the session's agents
const { totals, agents } = await taiku.getAgentCostSummary();
// totals: { input, output, cached, cost, agentCount }
// agents: [{ shellId, agentType, model, input, output, cached, cost }, ...]

// Per-session event history for each tracked agent
const sessions = await taiku.getAgentSessions();
for (const session of sessions) {
  const events = await taiku.getAgentSessionEvents(session.sessionId, {
    types: ["tool_call", "turn_complete"],
  });
  // Analyze token usage patterns, detect runaway loops, etc.
}

// A budget kept in user KV, so it persists across sessions
const budget = (await taiku.getUserData("monthly_budget")) ?? { limit: 50 };
if (totals.cost > budget.limit * 0.8) {
  await taiku.showToast(
    `Agent spending at ${Math.round(
      (totals.cost / budget.limit) * 100,
    )}% of monthly budget`,
    "error",
  );
  await taiku.sendNotification(
    "Budget alert",
    `$${totals.cost.toFixed(2)} of $${budget.limit} used`,
  );
}

This is not hypothetical. The getAgentCostSummary(), getAgentSessions(), and getAgentSessionEvents() bridge methods exist today. A plugin can read every tool call, every completion, and every token count, then make decisions about whether to alert, throttle, or surface a summary.

Surfacing the right agent messages

In a session with three Claude instances running in parallel, the raw terminal output is a wall of text. Most of it is routine. What you actually need to see is: which agent just finished a task, which one hit an error, which one is waiting for approval.

A plugin can filter the noise:

// Needs events.subscribe and agent.events: agent_status is withheld without
// the second one.
const waiting = new Set();

taiku.on("agent_status", async (event) => {
  const { shellId, status, agentType, instanceId } = event.data;
  const key = instanceId ?? shellId;

  if (status === "waiting" && !waiting.has(key)) {
    waiting.add(key);
    // Agent needs human input -- this is urgent
    await taiku.showToast(
      `${agentType} in shell ${shellId} is waiting for input`,
      "info",
      10_000, // milliseconds, clamped to 1-15 seconds
    );
    await taiku.sendNotification(
      "Agent needs attention",
      `${agentType} is waiting in shell ${shellId}`,
    );
  } else if (status !== "waiting") {
    waiting.delete(key);
  }
  // Show the count on the toolbar button. The id must be one of your
  // manifest's `toolbarActions` ids (`open-hello` above): a badge on an
  // action that does not exist has nothing to render on, and is rejected.
  await taiku.setBadge("open-hello", waiting.size);

  if (status === "completed") {
    // Read the last few chunks to extract the summary
    const output = await taiku.getShellOutput(shellId, 3);
    const summary = extractCompletionSummary(output.chunks.join(""));

    // Store in session KV so all participants see it
    await taiku.setPluginData(`agent-summary-${shellId}`, {
      agentType,
      status: "completed",
      summary,
      timestamp: Date.now(),
    });
  }
});

The key insight: you are not limited to showing notifications. You can read terminal output, parse it, store structured summaries in session KV, and render a custom panel that shows exactly the information that matters. The built-in Event Log plugin illustrates consuming the same event stream, but a custom plugin can be tailored to your specific workflow, filtering for your error patterns, your deployment messages, your test results.

Triggering terminal actions programmatically

Plugins can write to terminals, not just read from them. Combined with event subscriptions, this lets you build interactive automation:

// Example: auto-approve agent tool use after review
async function approveAgentAction(shellId: number, action: string) {
  // Show the action in the plugin panel for human review
  renderActionForReview(action);

  // If auto-approve is enabled for this action type...
  const settings = await taiku.getSettings();
  if (settings.auto_approve_safe_actions && isSafeAction(action)) {
    await taiku.writeToShell(shellId, "y\n");
    await taiku.showToast(`Auto-approved: ${action}`, "info", 3000);
  }
}

// Example: inject context into an agent's terminal
async function provideContext(shellId: number) {
  const relevantData = await gatherContextFromOtherShells();
  // Write a command that pipes context to the agent
  await taiku.writeToShell(
    shellId,
    `echo '${escapeShell(relevantData)}' | pbcopy\n`,
  );
}

This is where plugins stop being passive dashboards and start being active participants in the session. A plugin with terminal.write permission can send keystrokes to any shell, which means it can approve prompts, inject commands, restart processes, or orchestrate multi-terminal workflows.

The browser and desktop app answer { state: "typed" } once the bytes have gone to the shell. When the session can't send them right then, as while it reconnects, nothing is typed or kept for later, and the call fails: send them again once the session is back. The terminal client asks the person before a plugin types into a shell: writeToShell answers { state: "pending", id }, nothing is typed until they answer, and the plugin then gets one plugin.<its id>.typed event, with { id, outcome: "typed" | "declined" } as its data. A plugin that handles pending works in both:

const write = await taiku.writeToShell(shellId, "make test\n");
if (write.state === "pending") {
  const typed = await taiku.waitForEvent(`plugin.${taiku.getPluginId()}.typed`);
  if (typed.data.outcome === "declined") return;
}

That waits for the next such event. With more than one write waiting, match the event's id to the one the answer carried.

The manifest is just the entry point

The manifest declares what your plugin can do. The SDK gives you the tools to actually do it. The difference between a simple status display and a real interactive system is not in the manifest. It is in how you compose the bridge methods:

  • getAgentSessions() + getAgentSessionEvents() = full agent history
  • getShellOutput() + event subscriptions = real-time filtered terminal views
  • writeToShell() + getShellOutput() = programmatic terminal automation
  • setPluginData() + broadcastEvent() = shared state across all participants
  • setUserData() + getUserData() = persistent configuration that follows you

You can build a cost dashboard, an intelligent notification filter, a deployment orchestrator, a test result aggregator, or a custom agent supervisor. The plugin runs in a sandboxed iframe with declared permissions and cannot do anything you did not explicitly allow. Within those permissions, it has full programmatic access to the session.

One plugin, both surfaces

The terminal client runs plugins too. There, a plugin is not an iframe but a separate process, and the same manifest.json can describe both: panels for the browser and desktop app, and an exec block for the terminal client.

"exec": {
  "command": "python3",
  "args": ["main.py"]
}

command runs directly, never through a shell. A bare name like python3 is looked up on the path; anything with a / is resolved inside your plugin's folder.

What the process speaks

One JSON object per line on stdin and stdout. Your process asks, taiku answers, and taiku can also push events at any time:

you send       {"t": "req", "id": "r1", "method": "showToast", "params": {"message": "hi"}}
taiku answers  {"t": "res", "id": "r1", "result": null}
          or   {"t": "res", "id": "r1", "error": "why it was refused"}
taiku pushes   {"t": "event", "event": {"type": "...", "data": {...}}}

Flush after every line. A request sitting in your output buffer never arrives.

Method names, parameters and error messages match the browser SDK. The terminal client supports getSetting, getSettings, setSetting, getSecret, getSecrets, showToast and sendNotification. The integrations.* methods are refused there for now, and anything else answers "unknown method".

When it runs

Nothing starts when the terminal client does. A plugin's process launches the first time you run one of its palette commands or press one of its settings buttons, and then stays running for the next one. Give it a palette command (commands in the manifest, with palette.contribute) so there is a way to start it that does nothing else.

Both of those reach your process as events. A palette command arrives as {"type": "command", "data": {"command": "<its id>"}}. A settings button arrives as its own eventType, with data.source set to "settings", and a settings button arrives the same way in a browser panel, so one check handles both.

Another participant's plugin.<id>.* events reach your process only when the terminal client knows your plugin came from the same place as theirs, and today it knows that only for a plugin installed from a GitHub repository at a pinned commit. A plugin copied into the folder by hand gets none of them.

A plugin with composer.contribute can also add slash commands to the agent console (taiku agent), the terminal client's own agent view. Declare composerCommands as in the browser, up to three, each a /name in the / list ending in your plugin's name. Running one sends nothing to the agent; your process gets one event, to it alone:

taiku pushes   {"t": "event", "event": {"type": "composer_command", "data":
                {"commandId": "...", "command": "standup", "args": "shipping docs", "agent": "triage"}}}
you send       {"t": "req", "id": "r1", "method": "fillComposer",
                "params": {"commandId": "...", "text": "Standup\nNext: shipping docs"}}

fillComposer is answered once, within 30 seconds, with up to 16 KiB of plain text, and only while the composer is still empty, exactly as in the browser. The text is a draft: the person reads it and sends it. A panelId is accepted for a manifest written for both surfaces and does nothing here, since the terminal client draws no panels. The Standup plugin that ships with taiku is a complete example. The commands are read when the console opens, so a plugin you enable afterwards appears the next time you open it.

What the sandbox allows

The process runs on macOS and Linux, with no network and nothing in its environment beyond PATH, HOME, TMPDIR, XDG_DATA_HOME and LANG. It can write only to its own data folder, which is also its HOME. There is nowhere to install a dependency, so use what the interpreter ships with. A manifest asking for fs.read or fs.write will not launch in the terminal client at all.

Do not use hostPlatforms to say the process needs macOS or Linux. In the browser, hostPlatforms hides the whole plugin on every other OS, including a panel that would have worked fine on Windows. The terminal client refuses to launch on Windows by itself.

Trying one locally

Copy the plugin's folder into ~/.local/share/taiku/plugins/<plugin id>/ (on Linux, $XDG_DATA_HOME/taiku/plugins/<plugin id>/ if you set that), then open the plugins list with :open plugin_manager and approve it.

The Break Timer plugin that ships with taiku is a complete plugin built this way: panel.html is the browser half, main.py is the terminal half, and they share one manifest, one setting and one button.

Testing with the mock host

MockHost intercepts window.parent.postMessage and answers with fixtures you control, so plugin logic is unit-testable with no server and no browser. A scaffolded project has this wired to npm test already, with three passing tests to start from. It ships with the SDK, so like the SDK it needs a taiku source checkout for now.

import { describe, expect, it } from "vitest";
import { TaikuPlugin } from "@taiku/plugin-sdk";
import { MockHost } from "@taiku/plugin-sdk/testing";

async function connected(opts = {}) {
  const plugin = new TaikuPlugin({ timeout: 2000 });
  const host = new MockHost(opts);
  host.connect();
  await plugin.ready();
  return { plugin, host };
}

describe("my plugin", () => {
  it("writes the command it was asked to write", async () => {
    const { plugin, host } = await connected({
      shells: [{ id: 1, rows: 24, cols: 80 }],
    });

    await plugin.writeToShell(1, "ls\n");

    expect(host.getShellWrites(1)).toEqual(["ls\n"]);
  });
});

One ordering rule: construct the TaikuPlugin before calling host.connect(). The plugin's constructor registers the message listener that connect()'s init message is dispatched into; with no listener yet, the init is dispatched into nothing and ready() never resolves.

Assert on what your plugin asked the host to do:

  • host.requests: every call, in order, with its params
  • host.getShellWrites(id): what was written to a shell
  • host.getBadges() and host.getBroadcastedEvents()
  • host.emit(type, data): push an event at your plugin's listeners

The default handler covers session, shells, chat, terminal, workspace, toolbar and broadcast. Everything else (KV, storage, settings, secrets, toasts, notifications, agents, integrations) is deliberately not covered and rejects with MockHost: unknown method: register a handler with host.onRequest(method, handler) for the ones your plugin calls.

Set theme in the options to test a panel's applyTheme() path:

const { plugin } = await connected({
  theme: { tokens: { "--tk-accent": "46 143 255" } },
});

Where to go next

On this page