podman-desktop/.agents/skills/new-extension/SKILL.md
Simon Rey 040aa7f179 feat(agents): add AI skill for scaffolding new Podman Desktop extensions
Add a comprehensive skill at .agents/skills/new-extension/SKILL.md that
guides agents through creating standalone external extensions with:

- Multi-package layout (backend + Svelte frontend + optional shared)
- TypeScript Vite configs (vite.config.ts) with defineConfig
- Svelte 5 with runes syntax and svelte.config.js for preprocessing
- Backend/frontend tsconfig.json with correct targets (Node vs DOM)
- Backend-to-frontend messaging via postMessage
- Containerfile for OCI image packaging
- Build, test, and publish workflows
- Quick reference table for common API patterns

🤖 Generated with AI assistance

Made-with: Cursor
Signed-off-by: Simon Rey <51708585+simonrey1@users.noreply.github.com>
Made-with: Cursor
2026-04-17 10:04:30 +02:00

20 KiB

name description
new-extension Scaffold a new Podman Desktop extension as a standalone repository with all required boilerplate, build config, Containerfile, and Svelte webview. Use when the user asks to create a new extension, add extension boilerplate, scaffold an extension, or bootstrap a Podman Desktop extension project.

Create a New Podman Desktop Extension

Scaffold a standalone extension in its own repository. The user provides an extension name and a brief description of what it should do. You produce all required files and verify the extension builds and can be loaded locally into Podman Desktop.

Inputs

Ask the user (or infer from context):

  1. Extension name — kebab-case identifier (e.g. apple-container). Used as the directory/repo name and the name field in package.json.
  2. Display name — human-readable title (e.g. Apple Container).
  3. Description — one-sentence summary shown in the extension list.
  4. Feature scope — what the extension should do. Common patterns:
    • Minimal — register a command, show a notification
    • Webview — display a Svelte UI panel (this is the default for any extension that shows content)
    • Provider — register a container engine or Kubernetes provider
    • Status bar / Tray — add status bar entries or tray menu items
    • Configuration — add extension-specific settings
    • Combinations of the above
  5. Publisher — the org or username that owns the extension (default: the user's GitHub username).

If the extension needs to display any UI beyond simple notifications, always use the multi-package webview layout (not inline HTML). This is the standard for all production Podman Desktop extensions.


Where to look in the Podman Desktop codebase

When implementing extension features, you may need to look up API details:

What you need Where to look
Full extension API types (createWebviewPanel, containerEngine, provider, etc.) packages/extension-api/src/extension-api.d.ts in the podman-desktop repo
Extension manifest schema (what contributes fields exist) packages/main/src/plugin/extension/extension-manifest-schema.ts
How the extension loader discovers and activates extensions packages/main/src/plugin/extension/extension-loader.ts
Container operations (createContainer, startContainer, pullImage, listContainers) Search for export namespace containerEngine in extension-api.d.ts
Provider operations (createProvider, getContainerConnections) Search for export namespace provider in extension-api.d.ts
Webview types (WebviewPanel, Webview, WebviewOptions) Search for interface WebviewPanel in extension-api.d.ts
Container create options (Image, Cmd, HostConfig, PortBindings, ExposedPorts) Search for interface ContainerCreateOptions in extension-api.d.ts
Existing built-in extensions as examples extensions/ directory (e.g. extensions/registries/, extensions/kube-context/)
How extensions are published to OCI registries website/docs/extensions/publish/index.md

Step 1 — Choose layout

Minimal (no UI)

For extensions that only register commands, providers, status bar items, or configuration — no webview needed.

{name}/
├── .gitignore
├── Containerfile
├── LICENSE
├── README.md
├── icon.png
├── package.json
├── tsconfig.json
└── src/
    └── extension.ts

Multi-package with Svelte webview (default for any UI)

Always use this layout when the extension displays content. Do not use inline HTML strings. The frontend is a Svelte app built by Vite into static assets that the backend loads into a webview panel.

{name}/
├── .gitignore
├── Containerfile
├── LICENSE
├── README.md
├── package.json                       # root workspace — runs both packages
├── packages/
│   ├── backend/                       # the extension entry point (Node.js)
│   │   ├── icon.png
│   │   ├── package.json               # has "main": "./dist/extension.js"
│   │   ├── tsconfig.json
│   │   ├── vite.config.ts
│   │   └── src/
│   │       └── extension.ts
│   ├── frontend/                      # Svelte app built into backend/media/
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── svelte.config.js
│   │   ├── tsconfig.json
│   │   ├── vite.config.ts
│   │   └── src/
│   │       ├── main.ts
│   │       └── App.svelte
│   └── shared/                        # (optional) shared types between frontend & backend
│       └── src/
│           └── ...

Step 2 — File contents (multi-package webview)

.gitignore

dist/
media/
node_modules/

media/ is a build artifact (frontend output) — do not commit it.

Root package.json

{
  "name": "{name}",
  "displayName": "{displayName}",
  "description": "{description}",
  "version": "0.0.1",
  "private": true,
  "engines": { "node": ">=22.0.0", "npm": ">=11.0.0" },
  "scripts": {
    "build": "concurrently \"npm run -w packages/frontend build\" \"npm run -w packages/backend build\"",
    "watch": "concurrently \"npm run -w packages/frontend watch\" \"npm run -w packages/backend watch\""
  },
  "devDependencies": {
    "concurrently": "^8.2.2",
    "typescript": "^5.9.3",
    "vite": "^7.0.0"
  },
  "workspaces": ["packages/*"]
}

Vite version constraint: @sveltejs/vite-plugin-svelte@6.x requires Vite 6 or 7. Do not use Vite 8+.

packages/backend/package.json

This is the extension manifest that Podman Desktop reads:

{
  "name": "{name}",
  "displayName": "{displayName}",
  "description": "{description}",
  "version": "0.0.1",
  "icon": "icon.png",
  "publisher": "{publisher}",
  "license": "Apache-2.0",
  "engines": { "podman-desktop": ">=1.26.0" },
  "main": "./dist/extension.js",
  "contributes": {},
  "scripts": {
    "build": "vite build",
    "watch": "vite --mode development build -w"
  },
  "devDependencies": {
    "@podman-desktop/api": "^1.26.1",
    "@types/node": "^22"
  }
}

packages/backend/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "lib": ["ES2020"],
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "dist",
    "skipLibCheck": true,
    "types": ["node"],
    "strict": true,
    "allowSyntheticDefaultImports": true
  },
  "include": ["src"]
}

packages/backend/vite.config.ts

Use .ts for type-safe config. The backend builds as a CJS library entry point with @podman-desktop/api and all Node builtins externalized.

import { join } from 'path';
import { builtinModules } from 'module';
import { defineConfig } from 'vite';

const PACKAGE_ROOT = __dirname;

export default defineConfig({
  mode: process.env.MODE,
  root: PACKAGE_ROOT,
  envDir: process.cwd(),
  resolve: {
    alias: {
      '/@/': join(PACKAGE_ROOT, 'src') + '/',
    },
  },
  build: {
    sourcemap: 'inline',
    target: 'esnext',
    outDir: 'dist',
    assetsDir: '.',
    minify: process.env.MODE === 'production' ? 'esbuild' : false,
    lib: {
      entry: 'src/extension.ts',
      formats: ['cjs'],
    },
    rollupOptions: {
      external: ['@podman-desktop/api', ...builtinModules.flatMap(p => [p, `node:${p}`])],
      output: { entryFileNames: '[name].js' },
    },
    emptyOutDir: true,
    reportCompressedSize: false,
  },
});

packages/frontend/package.json

{
  "name": "frontend",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "vite build",
    "watch": "vite --mode development build -w"
  },
  "devDependencies": {
    "@sveltejs/vite-plugin-svelte": "^6.1.0",
    "svelte": "^5.53.5"
  }
}

Optionally add @podman-desktop/ui-svelte for native Podman Desktop components (Button, EmptyScreen, etc.), and tailwindcss + autoprefixer + postcss if you want Tailwind styling.

packages/frontend/tsconfig.json

The frontend runs in a browser-like webview environment, so it needs DOM libs and bundler resolution:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src"]
}

packages/frontend/svelte.config.js

Required for Svelte preprocessing (TypeScript in .svelte files):

import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

export default {
  preprocess: [vitePreprocess()],
};

packages/frontend/vite.config.ts

The key: outDir: '../backend/media' — Vite builds the Svelte app into the backend's media/ folder.

import { join } from 'path';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';
import { fileURLToPath } from 'url';
import path from 'path';

const filename = fileURLToPath(import.meta.url);
const PACKAGE_ROOT = path.dirname(filename);

export default defineConfig({
  mode: process.env.MODE,
  root: PACKAGE_ROOT,
  resolve: {
    alias: { '/@/': join(PACKAGE_ROOT, 'src') + '/' },
  },
  plugins: [svelte()],
  base: '',
  build: {
    sourcemap: true,
    outDir: '../backend/media',
    assetsDir: '.',
    emptyOutDir: true,
    reportCompressedSize: false,
  },
});

packages/frontend/index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{displayName}</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="./src/main.ts"></script>
  </body>
</html>

packages/frontend/src/main.ts

import { mount } from 'svelte';
import App from './App.svelte';

const app = mount(App, { target: document.getElementById('app')! });
export default app;

packages/frontend/src/App.svelte

Write the Svelte component for the extension's UI. Use @podman-desktop/ui-svelte components (Button, EmptyScreen, etc.) for a native look and feel.


Step 3 — Backend loads the built frontend

In packages/backend/src/extension.ts, the backend creates a webview panel and loads the Svelte-built index.html from media/:

import type { ExtensionContext } from '@podman-desktop/api';
import * as extensionApi from '@podman-desktop/api';
import fs from 'node:fs';

export async function activate(extensionContext: ExtensionContext): Promise<void> {
  const panel = extensionApi.window.createWebviewPanel('{name}-panel', '{displayName}', {
    localResourceRoots: [extensionApi.Uri.joinPath(extensionContext.extensionUri, 'media')],
  });
  extensionContext.subscriptions.push(panel);

  const indexHtmlUri = extensionApi.Uri.joinPath(extensionContext.extensionUri, 'media', 'index.html');
  let indexHtml = await fs.promises.readFile(indexHtmlUri.fsPath, 'utf8');

  // Rewrite asset paths so the webview can load them
  const scriptLinks = indexHtml.match(/<script[^>]+src="([^"]+)"/g) ?? [];
  for (const link of scriptLinks) {
    const src = link.match(/src="([^"]+)"/)?.[1];
    if (src) {
      const webviewUri = panel.webview.asWebviewUri(
        extensionApi.Uri.joinPath(extensionContext.extensionUri, 'media', src),
      );
      indexHtml = indexHtml.replace(src, webviewUri.toString());
    }
  }
  const cssLinks = indexHtml.match(/<link[^>]+href="([^"]+)"/g) ?? [];
  for (const link of cssLinks) {
    const href = link.match(/href="([^"]+)"/)?.[1];
    if (href) {
      const webviewUri = panel.webview.asWebviewUri(
        extensionApi.Uri.joinPath(extensionContext.extensionUri, 'media', href),
      );
      indexHtml = indexHtml.replace(href, webviewUri.toString());
    }
  }

  panel.webview.html = indexHtml;
}

export async function deactivate(): Promise<void> {
  console.log('stopping {name} extension');
}

Why this approach? The webview runs in a sandboxed iframe with its own origin. Local file paths don't resolve — you must convert them with panel.webview.asWebviewUri() and set localResourceRoots to grant access to the media/ folder.

Backend-to-frontend messaging

The backend can send messages to the frontend with postMessage, and the frontend listens with window.addEventListener('message', ...):

Backend (in extension.ts):

panel.webview.postMessage({ type: 'status', value: 'running' });

Frontend (in App.svelte):

<script lang="ts">
  let status = $state('idle');

  window.addEventListener('message', (event: MessageEvent) => {
    if (event.data?.type === 'status') {
      status = event.data.value;
    }
  });
</script>

The frontend can also send messages back to the backend using acquirePodmanDesktopApi().postMessage(), and the backend receives them via panel.webview.onDidReceiveMessage. See the full template for a complete RPC implementation using MessageProxy.


Step 4 — Containerfile (multi-package)

FROM scratch AS builder
COPY packages/backend/dist/ /extension/dist
COPY packages/backend/package.json /extension/
COPY packages/backend/media/ /extension/media
COPY packages/backend/icon.png /extension/
COPY LICENSE /extension/
COPY README.md /extension/

FROM scratch

LABEL org.opencontainers.image.title="{displayName}" \
      org.opencontainers.image.description="{description}" \
      org.opencontainers.image.vendor="{publisher}" \
      io.podman-desktop.api.version=">= 1.26.0"

COPY --from=builder /extension /extension

The OCI image copies from packages/backend/ (the extension manifest lives there), plus the media/ folder that contains the built frontend assets.


Step 5 — Build and verify

npm install
npm run build

After building, verify:

  • packages/backend/dist/extension.js exists (the backend)
  • packages/backend/media/index.html exists (the built frontend)

Step 6 — Test locally in Podman Desktop

  1. Open Podman Desktop
  2. Go to Settings > Preferences > Extensions and enable Development mode
  3. Go to Extensions > Local Extensions tab
  4. Click Add a local folder extension... and select the folder containing the extension package.json:
    • Multi-package layout: select packages/backend/ (not the root)
    • Minimal layout: select the repository root
  5. Verify the extension appears as ACTIVE
  6. Test the extension's functionality

Step 7 — Package and publish (when ready)

npm run build
podman build -t quay.io/{publisher}/{name} .
podman push quay.io/{publisher}/{name}

To add it to the official catalog, open a PR on podman-desktop-catalog adding the extension to static/api/extensions.json.


Minimal extension (no webview)

For extensions without a UI panel, use the single-package layout from Step 1. Same package.json / vite.config.ts patterns as the backend package, minus the media/ loading.


Quick reference — common API patterns

Task API
Show info/warning/error message extensionApi.window.showInformationMessage(msg)
Register a command extensionApi.commands.registerCommand(id, callback)
Create a provider extensionApi.provider.createProvider(options)
Create a webview extensionApi.window.createWebviewPanel(viewType, title, options)
Send message to webview panel.webview.postMessage(data)
Receive message from webview panel.webview.onDidReceiveMessage(callback)
Add status bar item extensionApi.window.createStatusBarItem()
Add tray menu item extensionApi.tray.registerMenuItem(item)
Read configuration extensionApi.configuration.getConfiguration(section)
Pull a container image extensionApi.containerEngine.pullImage(connection, image, callback)
Create a container extensionApi.containerEngine.createContainer(engineId, options)
Start/stop a container extensionApi.containerEngine.startContainer(engineId, id)
List containers extensionApi.containerEngine.listContainers()
Get running engine connection extensionApi.provider.getContainerConnections()
Get engine ID from connection extensionApi.containerEngine.listInfos({ provider: connection })
Navigate to webview extensionApi.navigation.navigateToWebview(webviewId)

Common pitfalls

Getting the engineId for container operations

Many containerEngine methods (createContainer, startContainer, stopContainer, deleteContainer, inspectContainer) require an engineId string. This is not connection.name from getContainerConnections() — that will produce a "no engine matching this container" error.

The correct way to obtain the engineId:

const connections = extensionApi.provider.getContainerConnections();
const running = connections.filter(c => c.connection.status() === 'started');
if (running.length === 0) {
  throw new Error('No running container engine found');
}
const connection = running[0].connection;

// Use listInfos to get the real engineId
const infos = await extensionApi.containerEngine.listInfos({ provider: connection });
if (infos.length === 0) {
  throw new Error('No engine info available');
}
const engineId = infos[0].engineId;

await extensionApi.containerEngine.createContainer(engineId, {
  /* ... */
});

Alternatively, if you already have containers or images from listContainers() or listImages(), their engineId field can be reused directly.

createContainer auto-starts by default

ContainerCreateOptions.start defaults to true. If you call createContainer followed by startContainer, the second call will fail with HTTP 304 ("container already started"). Either:

  • Rely on the default and skip startContainer, or
  • Pass start: false in the create options if you need to configure the container before starting it

For the full API surface, see extension-api.d.ts.

Official templates