Extension development and installation
Build, validate, install, update, and operate host-side mikan extensions safely.
mikan extensions add hooks, agent tools, schedules, proactive messages, reactions, secrets, and bundled skills without changing mikan itself. They are different from skills: a skill is prompt content, while an extension is executable code loaded into the mikan host process.
Quickstart
Section titled “Quickstart”-
Create a minimal extension
Create a directory outside the mikan workspace:
hello-mikan/├── index.ts└── package.jsonUse mikan as a development dependency for TypeScript types:
{"name": "hello-mikan","version": "0.1.0","private": true,"type": "module","devDependencies": {"@geminixiang/mikan": "*"},"mikan": {"extensions": ["./index.ts"]}}Install development dependencies without lifecycle scripts:
Terminal window cd hello-mikannpm install --ignore-scriptsAdd
index.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. -
Validate without activating
Terminal window mikan ext validate ./hello-mikanValidation resolves the entrypoint, imports the module, and verifies that it exports a default or named
activatefunction. Importing executes top-level module code, but does not callactivate; keep top-level code side-effect free.Accepted layouts:
extensions/audit.mjsextensions/audit/index.tsextensions/audit/package.json # mikan.extensions points to the entrypointDirectory entrypoint fallback names are
index.mjs,index.js,index.ts, andindex.mts.package.jsonis the preferred source for name, version, description, entrypoint, and dependencies. -
Install the extension
Choose exactly one scope:
Terminal window # One conversation: safer default for conversation-specific behaviormikan ext install ./hello-mikan --conversation <conversationId># Every conversationmikan ext install ./hello-mikan --globalUse the same state directory as the running mikan instance when it is not
~/.mikan:Terminal window mikan ext install ./hello-mikan \--conversation <conversationId> \--state-dir=/srv/mikan/stateInstallation 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.dataDirremains isolated per conversation even for a global extension. -
Activate the extension
After installation, send
/pi-newin each affected conversation. A new harness instance discovers and activates the extension; restarting the whole mikan process is unnecessary.
4. Install from Git
Section titled “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:
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
Section titled “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:
mikan ext dev ./my-extension> add a poll for lunch[agent reply]> /pi-new # reloads after you editIt 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
Section titled “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
Section titled “7. Extension API”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
Section titled “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:
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
Section titled “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
Section titled “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:
api.on("tool_result", ({ content }) => ({ content: content.map((part) => part.type === "text" ? { ...part, text: part.text.replaceAll(SECRET, "***") } : part, ),}));Custom tools
Section titled “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 for a typed tool implementation.
Custom commands
Section titled “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):
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
Section titled “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:
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
Section titled “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:
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
Section titled “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
Section titled “Secrets”Declare the secrets your extension reads in package.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:
<state-dir>/vaults/extensions/<slug>/envRead them without exposing values:
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
Section titled “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:
// 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
Section titled “Platform reads: history and users”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)
Section titled “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:
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
Section titled “Bundled skills”Place skills under the extension directory:
hello-mikan/├── index.ts├── package.json└── skills/ └── hello-guide/ └── SKILL.mdExtension 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
Section titled “8. Lifecycle rules”- Make
activateidempotent./pi-newcan activate the extension again;schedules.upsertis safe for this pattern. - Release long-lived resources (database handles, watchers) in a disposer (
api.onDisposeor theactivatereturn value). Preferapi.schedulesoversetIntervalfor timers — schedules survive restarts, in-process timers do not. - Default to
api.paths.dataDir. UsesharedDataDironly for deliberate multi-conversation behavior. - Keep top-level imports and initialization safe because validation imports the module.
- Treat extension output and tool parameters as trust boundaries; validate untrusted input.
9. Update, inspect, and remove
Section titled “9. Update, inspect, and remove”# Reinstall updates code and preserves extension datamikan ext install ./hello-mikan --conversation <conversationId>
# Global extensions onlymikan ext list
# Global plus one conversation's extensionsmikan 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 busmikan 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
Section titled “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 when you need tools, SQLite persistence, schedules, proactive messaging, or bundled skills together.