Kunkun

Service Plugins

Expose APIs for other plugins and the AI agent to call

Service plugins expose callable methods that other plugins and the AI agent can invoke. This enables cross-plugin communication and turns your extensions into tools for the built-in AI.

Overview

When you declare a services[] array in your manifest, your plugin becomes a service provider. Other plugins (and the AI agent) can call your service methods through the service broker.

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  Plugin A       │     │  Service Broker │     │  Plugin B       │
│  (caller)       │────▶│  (dispatcher)   │────▶│  (service)      │
│                 │     │                 │     │                 │
│  services.call( │     │  - Permission   │     │  math.add()     │
│    "math",      │     │    intersection │     │  math.multiply()│
│    "add", {...} │     │  - Validation   │     │                 │
│  )              │     │  - Routing      │     │                 │
└─────────────────┘     └─────────────────┘     └─────────────────┘

Service Definition

Manifest Declaration

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

Service Implementation

Create a service plugin with the ServiceDefinition pattern:

// src/MathService.ts
import type { ServiceDefinition } from "@kunkunsh/api/runtime";
import * as v from "valibot";

// Define input schemas for validation
const TwoNumbersSchema = v.object({
  a: v.number(),
  b: v.number(),
});

// Export service definition
export default {
  services: {
    math: {
      async add(raw: unknown) {
        const { a, b } = v.parse(TwoNumbersSchema, raw);
        return { result: a + b };
      },

      async multiply(raw: unknown) {
        const { a, b } = v.parse(TwoNumbersSchema, raw);
        return { result: a * b };
      },

      async fibonacci(raw: unknown) {
        const { n } = v.parse(v.object({ n: v.number() }), raw);
        let a = 0, b = 1;
        for (let i = 0; i < n; i++) {
          [a, b] = [b, a + b];
        }
        return { result: a };
      },
    },

    // Multiple services from one plugin
    text: {
      async reverse(raw: unknown) {
        const { text } = v.parse(v.object({ text: v.string() }), raw);
        return { result: text.split("").reverse().join("") };
      },

      async wordCount(raw: unknown) {
        const { text } = v.parse(v.object({ text: v.string() }), raw);
        const words = text.trim().split(/\s+/).filter(w => w.length > 0);
        return { count: words.length, words };
      },
    },
  },

  // Lifecycle hooks (optional)
  onInit() {
    console.log("Service initialized");
  },

  onDestroy() {
    console.log("Service destroyed");
  },
} satisfies ServiceDefinition;

Build Configuration

Use the "service" mode in the build plugin:

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

Consuming Services

From Another Plugin

import { services } from "@kunkunsh/api";

// Call a service method
const result = await services.call("com.example.math-plugin", "math", "add", {
  a: 5,
  b: 3,
});

console.log(result); // { result: 8 }

With Type Safety

Define the service interface and use the typed client:

import { createServiceClient, services } from "@kunkunsh/api";

interface MathService {
  add(a: number, b: number): Promise<{ result: number }>;
  multiply(a: number, b: number): Promise<{ result: number }>;
}

const mathClient = createServiceClient<MathService>(
  "com.example.math-plugin",
  "math"
);

const { result } = await mathClient.add(5, 3);

From the AI Agent

Services are automatically bridged into the AI agent's tool registry as:

plugin__<pluginId>__<serviceName>__<methodName>

For example: plugin__com.example.math-plugin__math__add

When users ask the AI to perform math operations, the agent can call your service directly.

Permission Intersection

When Plugin A calls Plugin B's service, the effective permissions are the intersection of both:

  • Simple permissions: Set intersection (both must allow)
  • Scoped permissions: Intersect allow lists, union deny lists

This prevents privilege escalation — a low-permission plugin cannot gain access through a high-permission service.

Permission-Keyed Worker Pools

Services run in separate workers based on the caller's permissions:

serviceWorkerPool: Map<
  serviceKey,
  Map<permissionKey, ServiceWorkerEntry>
>

Different callers with different permissions get separate worker instances.

Service Options

Timeout Control

const result = await services.call(
  "com.example.plugin",
  "service",
  "method",
  { data: "..." },
  { timeout: 5000 } // 5 second timeout
);

Default Timeout

  • Default: 30 seconds
  • Maximum: 10 minutes
  • Configurable: Per-call via options.timeout

Lifecycle Hooks

export default {
  services: { /* ... */ },

  async onInit() {
    // Called when service worker starts
    // Setup database connections, load caches, etc.
  },

  async onDestroy() {
    // Called when service worker terminates
    // Cleanup resources, flush data, etc.
  },
} satisfies ServiceDefinition;

Timeout Behavior

  • Worker-side: onDestroy has a 5-second hard timeout
  • Host-side: terminate() has a 6-second timeout

Multiple Services

A single plugin can expose multiple services:

{
  "services": [
    {
      "name": "math",
      "description": "Math operations",
      "main": "dist/node/MathService.js",
      "serviceMode": "node-headless",
      "methods": [/* ... */]
    },
    {
      "name": "text",
      "description": "Text utilities",
      "main": "dist/node/MathService.js",
      "serviceMode": "node-headless",
      "methods": [/* ... */]
    }
  ]
}

Both services can be implemented in the same file with separate handlers in the ServiceDefinition.

Best Practices

  1. Validate input: Always use Valibot or Zod schemas to validate input
  2. Return structured output: Match your outputSchema declaration
  3. Document methods: Use clear descriptions in the manifest
  4. Handle errors: Catch and return meaningful error messages
  5. Keep methods focused: One method = one operation
  6. Use lifecycle hooks: Initialize resources in onInit, cleanup in onDestroy

Full Example

See apps/sample-worker-ext/src/MathService.ts for a complete service plugin implementation.

On this page