Kunkun
Building Extensions

Services

Expose typed, schema-validated methods that other extensions and the AI agent can call.

A service is a set of callable methods your extension exposes. Other extensions call them through the service broker, and the built-in AI agent can call them as tools. Services turn your extension into reusable infrastructure — an OCR engine, an FFmpeg wrapper, a YouTube downloader.

The contract-first pattern

Real service extensions (ffmpeg, youtube-tools) share a contract module between provider and consumers, so both sides agree on method names, inputs, and outputs at compile time.

1. Define the contract (shared)

// src/contract.ts
import * as v from "valibot";
import { defineServiceContract, method, type InferService } from "@kunkunsh/sdk/contract";

export const mathContract = defineServiceContract({
  id: "com.you.math",
  serviceName: "math",
  description: "Basic math operations",
  methods: {
    add: method({
      description: "Add two numbers",
      input: v.object({ a: v.number(), b: v.number() }),
      output: v.object({ result: v.number() }),
    }),
  },
});

export type MathService = InferService<typeof mathContract>;

Export it from a dedicated subpath so consumers can import it:

{ "exports": { "./contract": { "types": "./dist/contract.d.ts", "default": "./dist/contract.js" } } }

2. Implement it (provider)

implementService wires your handlers to the contract and validates input/output automatically:

// src/MathService.ts
import { implementService } from "@kunkunsh/sdk/runtime";
import { mathContract } from "./contract";

export default implementService(mathContract, {
  async add({ a, b }) {
    return { result: a + b };
  },
  async onInit() { /* open resources */ },
  async onDestroy() { /* clean up */ },
});

Build it with the service mode:

await Bun.build({
  entrypoints: ["./src/MathService.ts"],
  outdir: "./dist/node",
  target: "node", format: "esm", minify: true,
  plugins: [kunkunCommandPlugin({ mode: "service" }) as BunPlugin],
});

3. Declare it in the manifest

The services[] array is what the store and the broker see (the methods mirror your contract; JSON schemas drive validation and AI tool descriptions):

{
  "kunkun": {
    "services": [
      {
        "name": "math",
        "description": "Basic math operations",
        "main": "dist/node/MathService.js",
        "serviceMode": "node-headless",
        "methods": [
          {
            "name": "add",
            "description": "Add two numbers",
            "inputSchema": { "type": "object", "properties": { "a": { "type": "number" }, "b": { "type": "number" } }, "required": ["a", "b"] },
            "outputSchema": { "type": "object", "properties": { "result": { "type": "number" } } }
          }
        ]
      }
    ]
  }
}

Consuming a service

Three tiers, from loosest to safest:

Tier 1 — untyped (no imports):

import { getHostAPI } from "@kunkunsh/sdk/runtime";
const { result } = await getHostAPI().services.call("com.you.math", "math", "add", { a: 5, b: 3 });

Tier 2 — compile-time types (zero runtime cost):

import { createServiceClient } from "@kunkunsh/sdk/runtime";
import type { MathService } from "com.you.math/contract";

const math = createServiceClient<{ math: MathService }>("com.you.math");
const { result } = await math.math.add({ a: 5, b: 3 }); // fully typed

Tier 3 — types + runtime validation (input validated before send, output after receive):

import { createValidatedServiceClient } from "@kunkunsh/sdk/runtime";
import { mathSchemas } from "com.you.math/contract";

const math = createValidatedServiceClient("com.you.math", mathSchemas);
const { result } = await math.math.add({ a: 5, b: 3 });

Declare the dependency

Pre-approve a provider (so calls don't prompt) with serviceDependencies:

{
  "kunkun": {
    "serviceDependencies": [
      { "extensionIdentifier": "com.you.math", "service": "math" }
    ]
  }
}

Undeclared calls prompt the user for approval via the broker.

Permission composition

Service calls compose permissions so callers don't inherit the provider's private details:

  • The provider supplies its own implementation permissions (e.g. FFmpeg's scoped shell access to the ffmpeg binary).
  • The caller supplies resource permissions for user-chosen inputs (e.g. fs-read/fs-write for the files being converted).

So video-processing can call ffmpeg.convertImage on a user-selected file without ever declaring shell access to ffmpeg. See Permissions → intersection.

AI agent bridging

Every service method is automatically registered as an AI tool named:

plugin__<extensionIdentifier>__<serviceName>__<methodName>

Install a service extension and the agent can call it — a well-described OCR service means "extract the text from this image" just works.

Options & lifecycle

await math.math.add({ a: 1, b: 2 }); // uses default 30s timeout
// per-call: createServiceClient(id, { timeout: 5000 })
  • Default timeout 30s, max 10 minutes.
  • onInit / onDestroy hooks run when the service worker starts/stops (onDestroy has a ~5s hard timeout).

Real examples

  • ffmpeg (provider) ↔ video-processing (consumer) — image/video conversion
  • youtube-tools (provider) ↔ youtube-podcast-generator (consumer) — metadata, transcripts, downloads

On this page