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.

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-pluginEdit 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 installScaffolding 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.jsonwith marketplace/support metadataindex.htmlplussrc/main.tswired to@taiku/plugin-sdk, andsrc/main.test.tswith three tests to start frompackage.json,vite.config.ts, and scripts that validate the manifest and copy it intodist/on buildREADME.mdthat 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 buildnpm 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 taikuIt 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-pluginIt 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:
- Open Settings (toolbar, or
Ctrl+Shift+S), expand Integrations, and pick the Plugins tab. It is there when you are the session's admin. - Go to the Submit tab. (That tab also holds registry submission; for your own work you want Load Custom Plugin at the top.)
- Paste the full URL to
manifest.jsonand click Load. If you paste a folder URL without the filename, taiku appends/manifest.jsonfor 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 plusmanifest.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.jsoncan load. - Host the folder on any https static host (for a scaffolded project,
its
dist/afternpm run build), then load…/manifest.jsonfrom 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 hotkeystitle: displayed in the panel headerposition: one ofsidebar,sidebar-left,bottom,floating,tooltip,overlay,outputs(a section in an agent tile's Outputs, below), oragent-sidebar(a group in an agent tile's rail, below)htmlUrl: path to the HTML file, resolved relative to the manifest locationdefaultWidth/defaultHeight: initial dimensions in pixelspersistPosition: iftrue, reopening the panel restores its last dragged location and size instead of returning to the manifest defaultsbackground: iftrue, 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:
| Value | Where |
|---|---|
web | taiku in a computer's browser or the desktop app |
phone | taiku on a phone, in its browser or the app |
tui | the terminal client, for a plugin with an exec entry point |
agent-tile | its own tile carrying an agent |
outputs | a section beside an agent's conversation |
new-menu | an entry in the New menu |
cli | taiku 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 atoolbarActionsitem inmanifest.jsonaddCommand(...)-> declare ahotkeysitem or expose actions inside your paneladdSettingTab(...)-> declaresettingsandsecretsinmanifest.jsonloadData()/saveData()-> usegetSetting()/setSetting()for local browser-backed config,getUserData()/setUserData()for persistent per-user data, or session KV for collaborative stateonload()-> initialize afterawait 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 :rootWithout 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:
- Your panel HTML loads and creates a
TaikuPlugininstance. - The constructor starts listening for
postMessageevents on the window. - The host detects the iframe is ready and sends a
taiku:initmessage containing thepluginIdandsessionId. - 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-sdkor 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 characteristic | Use this |
|---|---|
| Shared within current session, ephemeral | Session KV (setPluginData / getPluginData) |
| User-specific, must survive session end | User KV via HTTP (setUserData / getUserData) |
| File-like data, binary, or large | Plugin storage via HTTP (writePluginFile / readPluginFile) |
| Device-specific secrets, never leaves browser | Local secrets (getSecret) |
| Real-time updates for all participants | Event 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:
| Event | data |
|---|---|
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_unfocused | as 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
// 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 historygetShellOutput()+ event subscriptions = real-time filtered terminal viewswriteToShell()+getShellOutput()= programmatic terminal automationsetPluginData()+broadcastEvent()= shared state across all participantssetUserData()+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 paramshost.getShellWrites(id): what was written to a shellhost.getBadges()andhost.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
- For the full bridge and HTTP API surface, see the API Reference.
- For runtime boundaries and persistence layer details, see the Technical reference.
- For behavior you can copy, read the built-in plugins that ship with taiku.