---
title: "Extension development and installation"
description: "Build, validate, install, update, and operate host-side mikan extensions safely."
url: "https://geminixiang.github.io/extension-development/"
---

# Extension development and installation

mikan extensions add hooks, agent tools, schedules, proactive messages, reactions, secrets, and bundled skills without changing mikan itself. They are different from [skills](skills/): a skill is prompt content, while an extension is executable code loaded into the mikan host process.

:::caution[Extensions are trusted host code]
An extension runs with the same operating-system privileges as mikan and can access host files, platform tokens, and network resources. Install only reviewed code. Extension code belongs under the host-only state directory, never in the sandbox-mounted workspace.
:::

## Quickstart

1. **Create a minimal extension**

   Create a directory outside the mikan workspace:

   ```text
   hello-mikan/
   ├── index.ts
   └── package.json
   ```

   Use mikan as a development dependency for TypeScript types:

   ```json
   {
     "name": "hello-mikan",
     "version": "0.1.0",
     "private": true,
     "type": "module",
     "devDependencies": {
       "@geminixiang/mikan": "*"
     },
     "mikan": {
       "extensions": ["./index.ts"]
     }
   }
   ```

   Install development dependencies without lifecycle scripts:

   ```bash
   cd hello-mikan
   npm install --ignore-scripts
   ```

   Add `index.ts`:

   ```ts
   import type { MikanExtensionApi } from "@geminixiang/mikan";

   export default function activate(api: MikanExtensionApi): void {
     api.log(`hello-mikan active for ${api.context.conversationId}`);

     api.on("before_agent_start", (event) => ({
       systemPrompt: `${event.systemPrompt}\n\nAlways end the final answer with: 🍊`,
     }));
   }
   ```

   `activate(api)` is called once for each conversation harness instance. TypeScript, MTS, ESM JavaScript, and MJS entrypoints are loaded directly through jiti; no build step is required.

2. **Validate without activating**

   ```bash
   mikan ext validate ./hello-mikan
   ```

   Validation resolves the entrypoint, imports the module, and verifies that it exports a default or named `activate` function. Importing executes top-level module code, but does not call `activate`; keep top-level code side-effect free.

   Accepted layouts:

   ```text
   extensions/audit.mjs
   extensions/audit/index.ts
   extensions/audit/package.json  # mikan.extensions points to the entrypoint
   ```

   Directory entrypoint fallback names are `index.mjs`, `index.js`, `index.ts`, and `index.mts`. `package.json` is the preferred source for name, version, description, entrypoint, and dependencies.

3. **Install the extension**

   Choose exactly one scope:

   ```bash
   # One conversation: safer default for conversation-specific behavior
   mikan ext install ./hello-mikan --conversation <conversationId>

   # Every conversation
   mikan ext install ./hello-mikan --global
   ```

   Use the same state directory as the running mikan instance when it is not `~/.mikan`:

   ```bash
   mikan ext install ./hello-mikan \
     --conversation <conversationId> \
     --state-dir=/srv/mikan/state
   ```

   Installation locations:

   | Scope        | Code path                                                       | Default data path                                                   |
   | ------------ | --------------------------------------------------------------- | ------------------------------------------------------------------- |
   | Global       | `<state-dir>/global/extensions/<slug>/`                         | `<state-dir>/conversations/<conversationId>/extension-data/<slug>/` |
   | Conversation | `<state-dir>/conversations/<conversationId>/extensions/<slug>/` | `<state-dir>/conversations/<conversationId>/extension-data/<slug>/` |

   Global installation controls where code is available. `api.paths.dataDir` remains isolated per conversation even for a global extension.

4. **Activate the extension**

   After installation, send `/pi-new` in each affected conversation. A new harness instance discovers and activates the extension; restarting the whole mikan process is unnecessary.

## 4. Install from Git

Supported sources include HTTPS Git URLs, SSH Git URLs, and the GitHub shorthand. Append `#subpath` when the extension is inside a larger repository:

```bash
mikan ext install \
  github:geminixiang/mikan#deploy/examples/extensions/agent-pm \
  --conversation <conversationId>
```

When a git source declares `dependencies` in its `package.json`, mikan runs `npm install --omit=dev` in the fetched clone. This executes those dependencies' install scripts on the mikan host, at the same trust level as importing the extension itself — review a package before adding it. Local-path sources are used by reference and are never installed for you, so their `node_modules` must already be present.

Prefer Node built-ins and zero runtime dependencies regardless: every dependency is more code running with mikan's privileges.

## 5. Develop without a Slack workspace

Installing mikan into a real Slack workspace just to see whether `activate()` runs is a poor first hour. `mikan ext dev` gives you the same runtime in your terminal:

```bash
mikan ext dev ./my-extension
> add a poll for lunch
[agent reply]
> /pi-new          # reloads after you edit
```

It is a stdin/stdout conversation on the production runtime — same package resolution, same loader, same commands — with the platform swapped for your terminal and the sandbox set to `host` (no Docker, no image pull). An extension that works here works in a channel, because nothing about how it is found or activated differs.

Your working copy is registered as a conversation-scoped local package. Local sources are used **by reference**, never copied, so the loop is edit → `/pi-new` → test with no reinstall step. Each extension gets its own `ext-dev-<name>` conversation, so two can be developed side by side.

| Flag                | Meaning                                                          |
| ------------------- | ---------------------------------------------------------------- |
| `--workspace <dir>` | where conversation state is written (default `./.mikan-ext-dev`) |
| `--state-dir <dir>` | use an isolated state dir instead of the shared one              |

## 6. Packages: installing without host access

Most people who need an extension enabled cannot SSH into the mikan host. Packages are the path for them: a git repository is declared in settings, and mikan fetches it.

Send `/pi-admin` to get a portal link, then use the **Extensions** panel (conversation scope) or **Global Extensions** panel (every conversation). Paste a git URL, optionally a tag/branch/commit, and press Add.

The clone and validation happen when you press Add, not later — a typo, a private repo, a missing `#subpath`, or a repo with no extension in it fails right there in the form. Activation still happens on the next `/pi-new`.

A package repository is laid out one of two ways, and mikan picks by what it finds:

```
several extensions            one extension
├── extensions/               ├── index.ts
│   ├── reporter/index.ts     ├── package.json
│   └── triage/index.ts       └── skills/
└── skills/
```

Skills shipped in `skills/` mount read-only at `/mikan/packages/<slug>/skills`, so unlike extension-shipped skills they can carry scripts and templates the agent actually reads from disk. They sit at the lowest precedence: a workspace or conversation skill of the same name wins.

Scopes are additive — global packages load everywhere and a conversation's load on top — except when both declare the same repository, in which case the conversation's copy wins and the global one is shown as shadowed. Pinning one channel to `@v2` while everyone else stays on `@v1` works that way.

Only git sources and host paths are supported today; npm is not implemented.

## 7. Extension API

### Hooks

```ts
api.on("tool_call", ({ toolName, args }) => {
  if (toolName === "bash" && JSON.stringify(args).includes("rm -rf")) {
    return { block: true, reason: "Blocked by extension policy" };
  }
});
```

| Hook                 | Use                                                         |
| -------------------- | ----------------------------------------------------------- |
| `before_agent_start` | Rewrite the system prompt or user prompt, or block the turn |
| `tool_call`          | Observe or block a tool call before execution               |
| `tool_result`        | Observe or rewrite tool output (e.g. redaction, truncation) |
| `message_end`        | Observe one completed agent message                         |
| `turn_end`           | Observe the completed turn                                  |
| `session_compact`    | Observe session compaction and its reason                   |
| `agent_error`        | Observe a turn that settled on an error                     |
| `budget_exceeded`    | Observe the run budget circuit breaker tripping             |

Hooks run in registration order. Result semantics are per hook: `tool_call` returns the first non-`undefined` result; `before_agent_start` and `tool_result` chain — each handler sees the event as rewritten by earlier handlers, and a `block` from any `before_agent_start` handler wins regardless of registration order. Hook errors are logged and skipped rather than crashing the run.

#### Run origin

Hook events carry an optional `origin` with the run's platform provenance: `kind` (`"interactive"` or `"event"`), `platform`, and — for interactive runs — `messageTs`, `userId`, `userName`, `threadTs`, and downloaded `attachments`. Use it for per-user policy, and pass `origin.messageTs` to `api.react` to react to the triggering message:

```ts
api.on("before_agent_start", async ({ origin }) => {
  if (origin?.kind === "interactive" && origin.userId && !allowlist.has(origin.userId)) {
    return { block: true, reason: "not authorized for this bot" };
  }
  if (origin?.messageTs) await api.react(origin.messageTs, "eyes");
});
```

Autonomous runs fired by schedules have no triggering platform message, so identity fields are unset — always null-check them.

#### Blocking a turn

Returning `{ block: true, reason }` from `before_agent_start` stops the turn before the model is called. The user message never enters the transcript or the session store, and the platform shows `reason` as a diagnostic message.

#### Rewriting tool results

Return `{ content }` (and/or `{ isError }`) from `tool_result` to replace what the model — and the persisted session — sees. This is the mechanism for redacting secrets from tool output:

```ts
api.on("tool_result", ({ content }) => ({
  content: content.map((part) =>
    part.type === "text" ? { ...part, text: part.text.replaceAll(SECRET, "***") } : part,
  ),
}));
```

### Custom tools

Register an `AgentTool` with `api.registerTool(tool)`. Use TypeBox-compatible parameter schemas and return standard text/image tool content. See the complete [`agent-pm` example](https://github.com/geminixiang/mikan/tree/main/deploy/examples/extensions/agent-pm) for a typed tool implementation.

### Custom commands

`api.registerCommand` contributes a chat command dispatched deterministically — no model call, no tokens, no agent-session entry (the triggering message still appears in chat history):

```ts
api.registerCommand({
  name: "pm",
  description: "Show the follow-up board",
  handler: async ({ args, userId, respond }) => {
    await respond(renderBoard(args));
  },
});
```

Users invoke it as `/pm list`. Matching is case-insensitive; built-in commands (`/pi-*`, `/login`, …) always win over extension commands, and the first registration of a name wins across extensions. Slash text that matches no command still goes to the agent as a normal prompt. A handler error is reported in the conversation and logged; it never crashes mikan.

Dispatch is plain-text: on Telegram, Discord and GitHub comments the message must start with `/pm`. On Slack it must not have to. The Slack client keeps every `/`-prefixed line for commands declared in the Slack App itself, so an extension's `/pm` never leaves the user's machine — and the extension cannot declare one there, because `apps.manifest.update` needs an app configuration token that expires twice a day and is bound to one person in one workspace. So Slack accepts the bare name: `pm list` dispatches, and so does `/pm list` if the user pastes it.

That looseness is bounded by the registry, not the parser. Any name-shaped first word parses; nothing is registered under it, so the text carries on to the agent untouched. What it cannot avoid is a Slack message that opens with a registered command's name and means something else — that reaches the command and gets its usage line back. Loud, instant and free, against the alternative of the agent guessing and charging for a plausible wrong answer. Pick command names that do not read as ordinary sentence openings.

### Isolated subagents

`api.subagent.run` starts one fresh subagent and returns its result to the extension without posting to the conversation or adding to conversation history:

```ts
import { Type } from "@sinclair/typebox";

const result = await api.subagent.run({
  task: "Classify this project update.",
  input: { text: "Still investigating; no ETA yet." },
  tools: [],
  outputSchema: Type.Object({
    quality: Type.Union([Type.Literal("substantive"), Type.Literal("low_content")]),
    stuck: Type.Boolean(),
  }),
  budget: { maxTurns: 1, maxCostUsd: 0.02, maxDurationMs: 30_000 },
});

if (result.status === "completed") {
  api.log(`quality=${result.output.quality}`);
}
```

Subagents receive no conversation history and no tools by default. Requested tool names must already be available to the parent runner; unknown tools fail before the subagent starts. Subagent runs cannot recursively call `api.subagent.run`. When `outputSchema` is present, the final response must be exact JSON matching the TypeBox schema — mikan does not extract JSON from prose or Markdown fences.

A run can opt in to context from the active parent run with `parentContext: { mode: "normalized", recentTurns }`: the host snapshots the last `recentTurns` user turns (1–8, default 3) with their assistant replies — plus the latest compaction summary when one precedes them — into a normalized text block prepended to the task. The snapshot is read-only reference material; it grants no tools and gives the subagent no way to write into the parent session.

`api.subagent.run` never rejects: every failure — including request validation, an unknown tool name, or an attempted nested run — resolves to a result with a terminal status and an `error` message, so callers handle one shape.

Runs default to 100 model turns, 10 minutes, and USD 10. Every built-in profile provides a 100,000-token allowance; a request may raise it by specifying a larger `maxTokens` value. Turn, cost, and duration request fields can only tighten profile caps. Built-in profiles can be patched by `<workspaceDir>/agents/<name>.md`, while new profile files must declare their tool grant.

Results include a run ID, terminal status, raw final text, model identity, turns, tokens, cost, duration, and an optional validated `output`. Terminal statuses are `completed`, `failed`, `cancelled`, `timeout`, `budget_exceeded`, and `invalid_output`.

Subagent transcripts use an in-memory `SessionStore` and never create a session file or enter the conversation session store. They are discarded with the subagent runtime.

### Lifecycle and disposal

A harness instance is discarded on `/pi-new`, idle eviction, and session rotation. Release resources (database handles, watchers) by returning a disposer from `activate` or registering one with `api.onDispose`:

```ts
export default function activate(api: MikanExtensionApi) {
  const db = openDb(api.paths.dataDir);
  api.onDispose(() => db.close());
}
```

Disposers run in reverse registration order; errors are logged and never propagate.

### Context and storage

| API                       | Meaning                                                                               |
| ------------------------- | ------------------------------------------------------------------------------------- |
| `api.context`             | Conversation ID, workspace directory, model, and thinking level                       |
| `api.paths.dataDir`       | Private data for this extension and conversation; use by default                      |
| `api.paths.sharedDataDir` | Cross-conversation data; partition by conversation ID and handle concurrency yourself |
| `api.log(message)`        | Extension-scoped structured log entry                                                 |

Never store state inside the installed code directory: reinstall replaces code, while extension data is intentionally preserved.

### Secrets

Declare the secrets your extension reads in `package.json`:

```json
{
  "mikan": {
    "secrets": [
      { "key": "LINEAR_TOKEN", "description": "Linear API token", "required": true },
      { "key": "OPENAI_API_KEY" }
    ]
  }
}
```

Declarations surface in `mikan ext list` / `validate` and in the admin
portal's **Extension Secrets** panel, where an administrator provisions
values. A `required` secret that is unprovisioned fails activation with a
provisioning hint instead of a confusing runtime error (`mikan ext dev`,
which resolves no secrets, still activates). Provisioning writes `KEY=value`
lines to:

```text
<state-dir>/vaults/extensions/<slug>/env
```

Read them without exposing values:

```ts
const token = api.secrets.get("LINEAR_TOKEN");
const availableNames = api.secrets.list();
```

Secrets are read-only through the extension API and keyed by slug, so a
global and a conversation-scoped install of the same extension share one
secret set. Do not log them or place them in tool descriptions, prompts,
schedule text, or returned content.

### Schedules, runs, notifications, reactions, and uploads

A schedule fires one of two actions. A `text` schedule triggers an autonomous
agent run; a `callback` schedule runs a handler you register — deterministic,
no agent run, no model call, no token spend:

```ts
// Agent-run schedule: the model interprets `text` as a task.
await api.schedules.upsert("daily-check", {
  type: "periodic",
  schedule: "0 9 * * 1-5",
  timezone: "Asia/Taipei",
  text: "Check overdue work. Report only actionable items.",
});

// Callback schedule: deterministic host-side code on a cron.
api.schedules.onCallback("process-boards", async ({ scheduleName, args }) => {
  const digest = buildDigest(args);
  await api.notify(digest);
});
await api.schedules.upsert("boards-daily", {
  type: "periodic",
  schedule: "30 9 * * *",
  timezone: "Asia/Taipei",
  callback: "process-boards",
  args: { teams: ["content"] },
});

await api.triggerRun("Check the failed deploy and summarize the cause.");
await api.notify("The scheduled check is ready.");
await api.notify("Cross-post", { conversationId: "C0OTHER" });
await api.notify("In-thread reply", { threadTs });
const dm = await api.openDm("U0123456");
await api.notify("Your follow-up is due.", { conversationId: dm });
await api.react(messageTs, "white_check_mark");
await api.uploadFile("/path/on/host/report.pdf", "Weekly report");
```

`text` schedules create mikan event files and trigger autonomous runs without inheriting conversation history, so the text must be self-contained; `api.triggerRun` fires such a run immediately (e.g. in response to an external event). Do not put secrets in schedule text. `callback` schedules persist under the host-only state dir (sandboxed agents cannot forge them), survive restarts, and fire even when no harness instance is live — register the handler with `onCallback` during `activate`, and keep `args` JSON-serializable. Both kinds share one name namespace: upserting a name switches its kind.

Proactive messaging defaults to the conversation's own platform — `platform` is only needed when a cross-conversation `notify` targets another platform's conversation. `api.notify` accepts `threadTs` for threaded posts on platforms that support it; `api.openDm(userId)` resolves a user's DM conversation id for direct messages; `api.uploadFile` sends a host-side file into the conversation on platforms with upload support.

### Platform reads: history and users

```ts
const users = await api.listUsers();
const messages = await api.fetchHistory({ conversationId: "C0TEAM", limit: 200 });
for (const message of messages) {
  // { ts, threadTs?, userId?, userName?, text, isBot }
}

// Post something, then read what people replied to it — no webhook needed.
const ts = await api.notify("Status update please 🙏");
const replies = await api.fetchHistory({ threadTs: ts });
```

`api.fetchHistory` reads recent top-level messages (oldest first, one page — pass the last `ts` as `oldest` to page forward) and `api.listUsers` lists the workspace's active users, both without an agent run. Passing `threadTs` reads that thread's replies instead, excluding the parent message, which is how a polling extension follows up on its own posts. Slack backs them with `conversations.history` / `conversations.replies` and `users.list`; platforms without the capability throw a descriptive error.

### Interactive messages (Block Kit)

`api.blockkit` posts and updates interactive Slack Block Kit messages — buttons, select menus, custom layouts — and receives their interactions without a model call:

```ts
const { ts } = await api.blockkit.post({
  text: "投票:晚餐吃什麼",
  blocks: [
    { type: "markdown", text: "**晚餐吃什麼**" },
    {
      type: "actions",
      elements: [
        {
          type: "button",
          text: { type: "plain_text", text: "拉麵" },
          action_id: "vote",
          value: "ramen",
        },
      ],
    },
  ],
});

api.blockkit.onAction("vote", async (event) => {
  // event: { actionId, value, selectedValues, userId, messageTs, threadTs, … }
  if (!event.messageTs) return;
  await api.blockkit.update(event.messageTs, {
    text: "Vote recorded",
    blocks: [{ type: "markdown", text: `✅ ${event.userId} voted for ${event.value}` }],
  });
});
```

Every `action_id` in posted blocks is namespaced `ext:<slug>:…`, so interactions on this extension's elements dispatch **exclusively** to its `onAction` handlers — no agent run, no model call, millisecond latency. The extension decides whether the model gets involved afterwards (call `api.triggerRun` from a handler, e.g. after an approval button). After handling an interaction, `update` the message to reflect the outcome — buttons stay clickable otherwise. See `deploy/examples/extensions/poll` for a complete poll built this way.

### Bundled skills

Place skills under the extension directory:

```text
hello-mikan/
├── index.ts
├── package.json
└── skills/
    └── hello-guide/
        └── SKILL.md
```

Extension skills are discovered automatically and inlined into the system prompt because host-only extension paths are not mounted into the sandbox. A conversation skill with the same name takes precedence.

## 8. Lifecycle rules

1. Make `activate` idempotent. `/pi-new` can activate the extension again; `schedules.upsert` is safe for this pattern.
2. Release long-lived resources (database handles, watchers) in a disposer (`api.onDispose` or the `activate` return value). Prefer `api.schedules` over `setInterval` for timers — schedules survive restarts, in-process timers do not.
3. Default to `api.paths.dataDir`. Use `sharedDataDir` only for deliberate multi-conversation behavior.
4. Keep top-level imports and initialization safe because validation imports the module.
5. Treat extension output and tool parameters as trust boundaries; validate untrusted input.

## 9. Update, inspect, and remove

```bash
# Reinstall updates code and preserves extension data
mikan ext install ./hello-mikan --conversation <conversationId>

# Global extensions only
mikan ext list

# Global plus one conversation's extensions
mikan ext list --conversation <conversationId>

# Remove code; data remains on disk (leftovers are reported)
mikan ext remove hello-mikan --conversation <conversationId>

# Also sweep the slug's schedules, secrets vault, and data dirs;
# --workspace additionally sweeps its files on the events bus
mikan ext remove hello-mikan --conversation <conversationId> --purge --workspace <dir>
```

Send `/pi-new` after update or removal. In chat, `/pi-extensions` lists the extensions visible to the current conversation.

Packages are managed from the admin portal rather than the CLI. **Update** re-fetches the source: a pinned ref stays where it is, an unpinned one advances to its branch's current tip. Re-adding the same repository with a different ref moves the pin instead of creating a second entry. **Remove** drops the declaration but keeps the checkout on disk, so re-adding needs no network round trip.

## Troubleshooting

| Symptom                                | Check                                                                                                               |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `No entrypoint found`                  | Add `mikan.extensions` to `package.json` or an `index.{mjs,js,ts,mts}` file                                         |
| `does not export an activate function` | Export `default function activate(...)` or named `activate`                                                         |
| Installed but not active               | Confirm `--state-dir` and conversation ID, then send `/pi-new`                                                      |
| Import/module error                    | For local-path sources, install runtime dependencies first; Git sources install declared dependencies automatically |
| Schedule/notify/react unavailable      | Confirm the running platform/context provides that host service                                                     |
| Wrong extension identity/data path     | The slug comes from the installed file/directory name, not editable metadata                                        |

Start from [`deploy/examples/extensions/agent-pm`](https://github.com/geminixiang/mikan/tree/main/deploy/examples/extensions/agent-pm) when you need tools, SQLite persistence, schedules, proactive messaging, or bundled skills together.
