Skip to content

Build your first pkg

This is an end-to-end track. By the end you’ll have a working pkg running live inside the shell, reloading as you save, and you’ll know the exact path to publish it. We’ll build a UI-iframe pkg — a panel that renders your own HTML inside Ikenga — because it’s the most common archetype and exercises the whole loop. The shape generalizes to every other archetype.

Prerequisites: Ikenga installed and running (see Install), and the ikenga CLI on your $PATH. You don’t need a build toolchain for this track — we’ll use the no-build “thin” shape.

Every pkg starts with one decision: what does it contribute? The full decision matrix lives on the Pkgs page; the short version:

You want…Archetype
A panel that renders your own UI in the shellUI iframe ← this tutorial
To drive a third-party site that blocks iframe embeddingUI webview
Tools the AI engine can callMCP server
A new AI backendEngine adapter
A supervised background workerSidecar
Skills / commands / agents onlySkill-only

A pkg can combine several of these — but start with one. We’re building a UI iframe.

A UI-iframe pkg can be filled in three ways. They produce the same kind: "iframe" route — the kernel doesn’t know or care which you used. Pick the smallest that fits:

ShapeToolchainPick when
Thin vanillaNone — one HTML file + inline JSOne screen, simple form or CRUD.
Thin ReactNone — React loaded at runtime, no build stepMultiple screens, shared components, but you don’t want a build.
Vite + Reactpnpm install / pnpm builddist/Heavy interactive UI, a design-system kit, type safety, HMR.

Default to thin. We’ll use thin vanilla here. Vite is only worth it when you genuinely need type-checked TSX or a shared component library.

You have two ways to get a starting skeleton.

Section titled “Option A — the pkg-builder skill (recommended)”

If you have the ikenga-pkg-builder Claude Code skill installed, just ask:

/ikenga-pkg-builder

It asks four or five plain-language questions, derives the right archetype and shape, copies the matching template, substitutes your package details, sets any vault keys it needs, and registers the pkg with the running shell — landing you straight at step 4. If you went this route, skim steps 3 and 4 to understand what it set up, then jump to step 5.

Copy the thin-vanilla starter and rename it. Your pkg directory should look like this:

hello-panel/
├── manifest.json
├── package.json
├── README.md
└── dist/ # served as the iframe's document root
├── index.html # entry — the manifest's ui.routes[].source points here
├── app.js # your code, at the SAME level as index.html
└── lib/... # internal modules

Two layout rules that will save you an afternoon:

  • dist/ is mandatory. The kernel serves iframe files only from <install_path>/dist/. There is no other lookup path. The thin shapes ship a dist/ even though nothing is “built.”
  • Keep app code flat under dist/ — at the same level as index.html, not in a nested src/. When the shell mounts your iframe it inlines your entry script, and relative import paths resolve against the document base. A nested layout will 404 on its own imports. (The shipped template already gets this right — don’t restructure it.)

The manifest.json is the contract. Here’s a complete one for a thin-vanilla panel that loads React from a CDN and talks to Supabase:

{
"id": "com.yourname.hello-panel",
"name": "Hello Panel",
"version": "0.1.0",
"ikenga_api": "1",
"kind": "embedded",
"author": { "name": "Your Name", "key": "yourname" },
"ui": {
"routes": [
{ "path": "/", "kind": "iframe", "source": "dist/index.html" }
],
"csp": {
"script-src": ["'self'", "'unsafe-inline'", "https://esm.sh"],
"style-src": ["'self'", "'unsafe-inline'", "https://esm.sh"],
"font-src": ["'self'", "https://esm.sh", "data:"],
"connect-src": ["'self'", "https://esm.sh", "https://*.supabase.co", "wss://*.supabase.co"]
}
},
"capabilities": {
"supabase": { "required": false }
},
"permissions": {
"shell.execute": [],
"fs.read": [],
"fs.write": [],
"net": ["https://esm.sh", "https://*.supabase.co"],
"supabase.tables": [],
"vault.keys": []
}
}

What matters here:

  • id is a reverse-DNS identifier you own (com.yourname.<slug>). It’s how the kernel namespaces routes, permissions, and storage.
  • source is a path under dist/. The route registers internally and the shell mounts it at /pkg/<id>/.
  • ikenga_api: "1" is the host-API version you target. The host supports the current version and the one before it.
  • csp overrides replace the matching directive — list 'self' explicitly if you still want same-origin access. The block above is the right starting point for a thin pkg loading React from esm.sh and talking to Supabase.
  • capabilities.supabase — set required: true only if your pkg can’t run without credentials; the shell then fails the mount with an actionable error if the vault is empty. With false, the credentials arrive as null and you handle the no-Supabase case.
  • A pkg with an empty permissions block and no sensitive capabilities auto-trusts on install. Asking for fs.write outside your own storage triggers the trust gate.

The AppBridge handshake (the part that makes it live)

Section titled “The AppBridge handshake (the part that makes it live)”

Inside the shell, your iframe is an MCP App client. On mount, the shell hands you a hostContext — theme, host style variables, and Supabase credentials if you declared the capability. Wire it up like this:

import {
App,
applyDocumentTheme,
applyHostStyleVariables,
} from 'https://esm.sh/@modelcontextprotocol/ext-apps@1.7.1/app-with-deps';
const app = new App({ name: 'Hello Panel', version: '0.1.0' });
// Register handlers BEFORE connect — the host may emit notifications
// synchronously right after the initialize response.
app.onhostcontextchanged = (ctx) => {
if (ctx.theme) applyDocumentTheme(ctx.theme);
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
// ctx.supabase is { url, anonKey } when you declared the capability
// and the vault is populated — otherwise null.
};
app.onerror = console.error;
await app.connect();
const ctx = app.getHostContext();
if (ctx?.theme) applyDocumentTheme(ctx.theme); // apply the initial state

Three things that trip people up:

  • Import from the /app-with-deps subpath, not the bare package — the bundled subpath inlines its dependencies and avoids a CDN resolution trap that throws at module init.
  • Handle onhostcontextchanged idempotently — the shell re-emits hostContext on every theme change.
  • Don’t construct a Supabase client at module-eval time. The handshake is async, so the credentials aren’t there yet. Use a lazy getter that runs after hostContext arrives.

When you open the same file outside the shell (plain browser, or your dev server), the handshake is skipped — build a fallback so local dev still works.

This is the loop. From a terminal, point ikenga dev at your pkg folder while the shell is running:

Terminal window
ikenga dev /path/to/hello-panel

That command symlinks the pkg into the running shell, auto-trusts the dev source, registers it, and starts watching manifest.json. Now navigate the shell to your pkg’s route (/pkg/com.yourname.hello-panel/) and you’ll see your panel.

What reloads on what:

You edit…What happens
Your iframe code (HTML/JS/CSS)Reloads through your dev server’s HMR, or reload the iframe pane for a thin pkg.
manifest.jsonThe kernel re-reads, unregisters, re-registers, and remounts the pane — within ~300ms, no shell restart.
A file matching a sidecar / MCP restart_when_changed globThe supervisor restarts just that child; the route stays mounted.

Manifest edits are atomic: a broken manifest.json leaves your previous working version registered and returns a clear error, then resumes the moment it parses again. Ctrl-C on the ikenga dev process unregisters the pkg cleanly — symlink removed, registries cleaned up.

Iterate here until the panel does what you want.

When it’s ready, published pkgs live in the ikenga-pkgs monorepo under packages/<type>/<slug>/, ship Apache-2.0, and release through Changesets.

  • manifest.json is valid against the schema.
  • package.json files array includes manifest.json and any non-dist/ runtime content.
  • package.json has "license": "Apache-2.0" and "publishConfig": { "access": "public" }.
  • README.md says what the pkg does.
  1. Make your change inside ikenga-pkgs/packages/<type>/<slug>/.
  2. Run pnpm changeset — a short wizard. Pick the bump (patch / minor / major) and write a one-line summary.
  3. Commit your change and the new .changeset/*.md file together. Push, and open a PR. A bot confirms the changeset is present.
  4. Merge to main. The release workflow opens a “Version Packages” PR that bumps versions and writes changelogs.
  5. Merge “Version Packages.” The workflow then publishes the pkg, creates a GitHub Release for it, and pushes a fresh entry to the registry index — the static catalogue the shell’s package manager and the ikenga CLI both read.

The @ikenga/ npm scope is owned by Royalti, Inc. Third-party authors publish under their own scope and submit to the registry index via a PR to ikenga-registry.

Once it’s in the registry, anyone can install it with ikenga add <pkg> — and you’ve shipped your first pkg.

  • Pkgs → — the full archetype matrix, capabilities, and lifecycle.
  • Engines → — build an engine adapter instead of a UI panel.
  • mcp-iyke → — expose tools the AI engine can call.