Skip to content

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.

  1. Create a minimal extension

    Create a directory outside the mikan workspace:

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

    Use 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-mikan
    npm install --ignore-scripts

    Add 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.

  2. Validate without activating

    Terminal window
    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:

    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:

    Terminal window
    # 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:

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

    Installation locations:

    ScopeCode pathDefault 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.

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

Terminal window
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.

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:

Terminal window
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.

FlagMeaning
--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.

api.on("tool_call", ({ toolName, args }) => {
if (toolName === "bash" && JSON.stringify(args).includes("rm -rf")) {
return { block: true, reason: "Blocked by extension policy" };
}
});
HookUse
before_agent_startRewrite the system prompt or user prompt, or block the turn
tool_callObserve or block a tool call before execution
tool_resultObserve or rewrite tool output (e.g. redaction, truncation)
message_endObserve one completed agent message
turn_endObserve the completed turn
session_compactObserve session compaction and its reason
agent_errorObserve a turn that settled on an error
budget_exceededObserve 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.

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.

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.

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,
),
}));

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.

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.

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.

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.

APIMeaning
api.contextConversation ID, workspace directory, model, and thinking level
api.paths.dataDirPrivate data for this extension and conversation; use by default
api.paths.sharedDataDirCross-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.

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>/env

Read 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.

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.

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.

Place skills under the extension directory:

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.

  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.
Terminal window
# 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.

SymptomCheck
No entrypoint foundAdd mikan.extensions to package.json or an index.{mjs,js,ts,mts} file
does not export an activate functionExport default function activate(...) or named activate
Installed but not activeConfirm --state-dir and conversation ID, then send /pi-new
Import/module errorFor local-path sources, install runtime dependencies first; Git sources install declared dependencies automatically
Schedule/notify/react unavailableConfirm the running platform/context provides that host service
Wrong extension identity/data pathThe 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.