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
ikengaCLI on your$PATH. You don’t need a build toolchain for this track — we’ll use the no-build “thin” shape.
1. Pick an archetype
Section titled “1. Pick an archetype”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 shell | UI iframe ← this tutorial |
| To drive a third-party site that blocks iframe embedding | UI webview |
| Tools the AI engine can call | MCP server |
| A new AI backend | Engine adapter |
| A supervised background worker | Sidecar |
| Skills / commands / agents only | Skill-only |
A pkg can combine several of these — but start with one. We’re building a UI iframe.
The three iframe shapes
Section titled “The three iframe shapes”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:
| Shape | Toolchain | Pick when |
|---|---|---|
| Thin vanilla | None — one HTML file + inline JS | One screen, simple form or CRUD. |
| Thin React | None — React loaded at runtime, no build step | Multiple screens, shared components, but you don’t want a build. |
| Vite + React | pnpm install / pnpm build → dist/ | 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.
2. Scaffold
Section titled “2. Scaffold”You have two ways to get a starting skeleton.
Option A — the pkg-builder skill (recommended)
Section titled “Option A — the pkg-builder skill (recommended)”If you have the ikenga-pkg-builder Claude Code skill installed, just ask:
/ikenga-pkg-builderIt 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.
Option B — copy a template by hand
Section titled “Option B — copy a template by hand”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 modulesTwo 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 adist/even though nothing is “built.”- Keep app code flat under
dist/— at the same level asindex.html, not in a nestedsrc/. When the shell mounts your iframe it inlines your entry script, and relativeimportpaths 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.)
3. Write the manifest
Section titled “3. Write the manifest”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:
idis a reverse-DNS identifier you own (com.yourname.<slug>). It’s how the kernel namespaces routes, permissions, and storage.sourceis a path underdist/. 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.cspoverrides 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 fromesm.shand talking to Supabase.capabilities.supabase— setrequired: trueonly if your pkg can’t run without credentials; the shell then fails the mount with an actionable error if the vault is empty. Withfalse, the credentials arrive asnulland you handle the no-Supabase case.- A pkg with an empty
permissionsblock and no sensitive capabilities auto-trusts on install. Asking forfs.writeoutside 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 stateThree things that trip people up:
- Import from the
/app-with-depssubpath, not the bare package — the bundled subpath inlines its dependencies and avoids a CDN resolution trap that throws at module init. - Handle
onhostcontextchangedidempotently — the shell re-emitshostContexton 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
hostContextarrives.
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.
4. Run it hot-mounted
Section titled “4. Run it hot-mounted”This is the loop. From a terminal, point ikenga dev at your pkg folder while the shell is running:
ikenga dev /path/to/hello-panelThat 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.json | The kernel re-reads, unregisters, re-registers, and remounts the pane — within ~300ms, no shell restart. |
A file matching a sidecar / MCP restart_when_changed glob | The 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.
5. Publish
Section titled “5. Publish”When it’s ready, published pkgs live in the ikenga-pkgs monorepo under packages/<type>/<slug>/, ship Apache-2.0, and release through Changesets.
Pre-publish checklist
Section titled “Pre-publish checklist”-
manifest.jsonis valid against the schema. -
package.jsonfilesarray includesmanifest.jsonand any non-dist/runtime content. -
package.jsonhas"license": "Apache-2.0"and"publishConfig": { "access": "public" }. -
README.mdsays what the pkg does.
The release flow
Section titled “The release flow”- Make your change inside
ikenga-pkgs/packages/<type>/<slug>/. - Run
pnpm changeset— a short wizard. Pick the bump (patch / minor / major) and write a one-line summary. - Commit your change and the new
.changeset/*.mdfile together. Push, and open a PR. A bot confirms the changeset is present. - Merge to
main. The release workflow opens a “Version Packages” PR that bumps versions and writes changelogs. - 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
ikengaCLI 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 toikenga-registry.
Once it’s in the registry, anyone can install it with ikenga add <pkg> — and you’ve shipped your first pkg.
Where to go next
Section titled “Where to go next”- 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.