Kunkun
Building Extensions

Custom UI Extension

Full UI control with any frontend framework, loaded as a static SPA.

Custom-View extensions are static SPAs loaded into an isolated BrowserWindow via the kunkun-ext:// protocol. Use React, Vue, Svelte — anything that builds to a static frontend.

Choose custom-view when you need full UI control, are converting an existing web app, or want framework-specific features. If you want Raycast-style list/form UIs without managing your own rendering, use a Worker Extension instead.

Project structure

package.json
vite.config.ts
index.html

Project setup

Scaffold with Vite — pick any framework that builds to a static frontend. React is shown first because it's the most common; the other tabs are the same steps.

bun create vite my-extension --template react-ts
cd my-extension
pnpm add @kunkunsh/sdk
bun create vite my-extension --template vue-ts
cd my-extension
pnpm add @kunkunsh/sdk
bun create vite my-extension --template svelte-ts
cd my-extension
pnpm add @kunkunsh/sdk
bun create vite my-extension --template vanilla-ts
cd my-extension
pnpm add @kunkunsh/sdk

npm create vite@latest / pnpm create vite work the same way.

Custom views load from the extension's own kunkun-ext:// origin, so asset URLs must be relative. Set base: './' in vite.config.ts:

import { defineConfig } from 'vite'
// import framework plugin: react() / vue() / svelte()
export default defineConfig({
  base: './',   // required: assets load relative to kunkun-ext://
  // plugins: [react()],
})

Prefer a meta-framework like SvelteKit or Nuxt? That's fine — just emit a static SPA (no SSR). For SvelteKit use @sveltejs/adapter-static with ssr = false and hash routing so client routes resolve under kunkun-ext://. The main / dist / devMain manifest fields below are identical.

Need multiple pages? Keep it purely client-side — add a router like React Router or TanStack Router (React), or Vue Router (Vue). Prefer hash or memory history so deep links resolve under the kunkun-ext:// origin without a server. You don't need a meta-framework just to get routing.

Manifest

The manifest is the kunkun field of your project's existing package.json — add it alongside the $schema hint; don't create a separate file:

package.json
{
  "name": "my-extension",
  "version": "0.1.0",
  // ...your normal package.json fields
  "$schema": "https://schema.kunkun.sh/",
  "kunkun": {
    "identifier": "com.you.my-extension",
    "name": "My Extension",
    "icon": { "type": "iconify", "value": "mdi:puzzle", "invert": true },
    "shortDescription": "A brief description",
    "permissions": ["clipboard-read", "clipboard-write", "storage"],
    "commands": [
      {
        "name": "main",
        "title": "My Command",
        "mode": "custom-view",
        "main": "/",
        "dist": "dist",
        "devMain": "http://localhost:5173"
      }
    ]
  }
}
FieldMeaning
mainRoute path within your SPA
distBuild output directory
devMainDev server URL (HMR in development)

Window options

{
  "name": "window-demo",
  "mode": "custom-view",
  "main": "/window-demo",
  "dist": "dist",
  "window": { "titleBarStyle": "overlay", "transparent": true, "vibrancy": "sidebar", "width": 700, "height": 550 }
}

titleBarStyle: "default" | "hidden" | "overlay" · transparent, vibrancy (macOS blur), width/height. See window-control to change these at runtime.

Using the SDK

Import @kunkunsh/sdk/ui/custom once in your entry file, before the app mounts — this side-effect import wires the extension ↔ host RPC channel. Everything else (Clipboard, db, fs, showToast, …) comes from @kunkunsh/sdk.

// src/main.tsx (React) / src/main.ts (Vue, Svelte, vanilla)
import "@kunkunsh/sdk/ui/custom"; // must come first
// ...your normal bootstrap: createRoot(el).render(<App />) / mount(App, ...) / createApp(App).mount(...)

Then call the APIs from your components:

import { useState } from "react";
import { Clipboard, showToast, Toast } from "@kunkunsh/sdk";

export default function App() {
  const [text, setText] = useState("");
  async function read() {
    try {
      setText(await Clipboard.readText());
      await showToast({ title: "Read", style: Toast.Style.Success });
    } catch (err) {
      await showToast({ title: "Error", message: String(err), style: Toast.Style.Failure });
    }
  }
  return (
    <>
      <button onClick={read}>Read clipboard</button>
      <p>{text}</p>
    </>
  );
}
<script setup lang="ts">
import { ref } from "vue";
import { Clipboard, showToast, Toast } from "@kunkunsh/sdk";

const text = ref("");
async function read() {
  try {
    text.value = await Clipboard.readText();
    await showToast({ title: "Read", style: Toast.Style.Success });
  } catch (err) {
    await showToast({ title: "Error", message: String(err), style: Toast.Style.Failure });
  }
}
</script>

<template>
  <button @click="read">Read clipboard</button>
  <p>{{ text }}</p>
</template>
<script lang="ts">
  import { Clipboard, showToast, Toast } from '@kunkunsh/sdk'

  let text = $state('')
  async function read() {
    try {
      text = await Clipboard.readText()
      await showToast({ title: 'Read', style: Toast.Style.Success })
    } catch (err) {
      await showToast({ title: 'Error', message: String(err), style: Toast.Style.Failure })
    }
  }
</script>

<button onclick={read}>Read clipboard</button>
<p>{text}</p>

For persistent structured data (lists, history, bookmarks), reach for Record Storage instead of stuffing JSON into LocalStorage.

Permissions

Declare what you need; scope filesystem and network tightly:

{
  "permissions": [
    "clipboard-read", "clipboard-write", "notifications", "storage",
    { "permission": "fs-read", "allow": ["$EXTENSION_SUPPORT/**", "$HOME/Documents/**"] },
    { "permission": "fs-write", "allow": ["$EXTENSION_SUPPORT/**"] },
    { "permission": "network", "domains": ["*.github.com", "api.example.com"] }
  ]
}

See Permissions for path aliases and scoped forms.

Build & dev

pnpm build   # outputs static files into `dist`/`build`
pnpm dev     # Kunkun loads `devMain` with HMR

The installed tarball is served as-is — bundle your dependencies. Kunkun does not npm install inside an installed extension.

Real examples

The custom-view samples in the repo happen to be written in Svelte, but the setup above is identical across frameworks — the React/Vue code in this guide is authored inline.

  • sample-custom-view-dev — a minimal Svelte + Vite starter (the canonical base: './' + main:"/" + dist shape)
  • ai-config-manager — a real SvelteKit custom view managing AI config in a scoped directory
  • kkterminal / space-lens — custom views backed by a spawned backend (see CLI + Plugin)

On this page