Plugin Development Guide

August 11, 2026 · View on GitHub

English | 中文


Plugin Development Guide

An LLM Space Plugin is an npm-compatible package installed under LLM_SPACE_HOME/plugins/. A Plugin may contribute zero or more Extensions, including Skills, MCP servers, model providers, Tools, commands, and Thread Storages.

This guide describes the current Plugin system: package layout, metadata, Settings, supported Extensions, lifecycle, diagnostics, and development practices.

1. Concepts

A Plugin is the unit of distribution, versioning, configuration, reload, enablement, and disablement. An Extension is one capability contributed by a Plugin.

Plugin
├── Metadata (package.json)
├── Settings (optional)
└── Extensions (zero or more)
    ├── Skill
    ├── MCP server
    ├── Model provider
    ├── Command
    ├── Plugin Tool
    └── Thread Storage

The main rules are:

  • package.json is the only metadata file. Its name is also the stable Plugin ID.
  • Extensions are discovered from conventional paths. They are not enumerated in package.json.
  • A newly discovered Plugin is enabled by default.
  • A Plugin can be enabled or disabled as a whole. Plugin Skills can also be enabled individually from Settings → Skills; other Extension types cannot currently be toggled individually.
  • Plugin Settings are saved automatically. Changing Settings reloads the Plugin.
  • Plugin files are read-only contributions. LLM Space does not rewrite mcp.json, models.json, Skill files, or executable Extension files.
  • Local Plugins are fully trusted. Runtime process isolation is not a security sandbox.

2. Installation and discovery

LLM_SPACE_HOME defaults to ~/.llm-space, so the default installation directory is:

~/.llm-space/plugins/

Set LLM_SPACE_HOME before starting LLM Space to use a separate data directory.

2.1 Regular and scoped packages

The Plugin scanner recognizes these layouts:

plugins/
├── weather-kit/
│   └── package.json
└── @example/
    └── team-tools/
        └── package.json
  • Regular package: plugins/<name>/package.json
  • Scoped package: plugins/@<scope>/<name>/package.json

Regular packages are scanned one level deep; scoped packages are scanned two levels deep. Discovery is not recursive and ignores node_modules.

The directory must match package.json.name exactly:

Installation directoryRequired name
plugins/weather-kit/weather-kit
plugins/@example/team-tools/@example/team-tools

The Plugin root, Extension directories, and discovered files must be real directories or regular files. Symlinks are rejected, and discovered paths may not escape the Plugin root.

2.2 Extension discovery

LLM Space discovers Extensions only at these paths:

PathExtension
skills/*/SKILL.mdSkills
mcp.jsonMCP servers and tools
models.jsonModel providers and models
tools/*.{ts,js,mjs}Executable Plugin Tools
commands/*.{ts,js,mjs}Command Palette commands
thread-storages/*.{ts,js,mjs}Thread Storages
config.schema.jsonPlugin Settings form

Except for the contents of each Skill directory, Extension discovery is not recursive. For example, commands/open-dashboard.ts is discovered, while commands/admin/open-dashboard.ts is not.

A complete Plugin might look like this:

@example/team-tools/
├── package.json
├── icon.png
├── config.schema.json
├── mcp.json
├── models.json
├── tools/
│   └── project-info.ts
├── skills/
│   └── incident-review/
│       ├── SKILL.md
│       └── references/
├── commands/
│   └── open-dashboard.ts
└── thread-storages/
    └── team-library.ts

Do not create empty directories merely to satisfy the layout. A Skill-only Plugin needs only package.json and its Skill directory.

2.3 Persistent Plugin data

A Plugin must store downloaded files, caches, indexes, databases, and other runtime-generated state outside its installation directory, under:

~/.llm-space/data/plugins/<plugin-name>/

For a scoped Plugin, this becomes ~/.llm-space/data/plugins/@scope/<plugin-name>/. Respect LLM_SPACE_HOME rather than hard-coding ~/.llm-space:

const home = process.env.LLM_SPACE_HOME?.trim()
  || path.join(os.homedir(), ".llm-space");
const dataDirectory = path.join(
  home,
  "data",
  "plugins",
  ...pluginName.split("/"),
);

The ZIP installer replaces the Plugin installation directory during an update. Therefore, any runtime data written under LLM_SPACE_HOME/plugins/<name>/ can be overwritten or deleted. The external data/plugins/<name>/ directory is not part of the package replacement and remains intact across install, update, reload, and disable operations. Do not include runtime data in the Plugin ZIP.

2.4 Extensions in Settings

Settings → Plugins groups discovered Extensions by type, with an icon, count, activation status, and any load diagnostic. Select an Extension to reveal its source file or directory. Add concise descriptions so users can understand a Plugin before enabling or running it:

ExtensionDescription source
Skilldescription in SKILL.md frontmatter
Settingstop-level description in config.schema.json
Commandclass description property
Plugin Toolclass description property
Thread Storageclass description property
MCP server / Model providerserver or provider name

Descriptions are UI copy, not identifiers. Stable IDs still come from the Plugin package name and the declaring file or object ID.

3. Metadata in package.json

Minimal metadata:

{
  "name": "@example/team-tools",
  "version": "1.0.0",
  "displayName": "Team Tools",
  "description": "Example collaboration extensions for LLM Space.",
  "author": "Example Team",
  "license": "MIT",
  "homepage": "https://example.com/team-tools",
  "engines": {
    "llm-space": ">=4.7.1"
  }
}

Required fields:

  • name: an npm-style package name and the Plugin ID. Use lowercase letters, digits, dots, underscores, and hyphens, optionally with a scope.
  • version: a valid SemVer version.
  • engines["llm-space"]: the supported LLM Space SemVer range.

displayName, description, author, license, and homepage are recommended. Other npm fields may remain in the file, but LLM Space does not execute code because a package declares scripts, main, module, exports, or bin.

Changing name creates a different Plugin identity. Settings and persisted references associated with the old name are not migrated automatically.

3.1 Icon

Place the icon at the Plugin root:

icon.png

A 512 × 512 PNG is recommended. The file must be a valid PNG, no larger than 2 MiB, with neither dimension exceeding 4096 pixels. A missing or invalid icon falls back to the default Plugin icon and does not prevent loading.

4. Install, refresh, and reload

4.1 Package a Plugin ZIP

The installer accepts either of these archive layouts:

weather-kit-1.2.3.zip          weather-kit-1.2.3.zip
├── package.json               └── weather-kit/
├── tools/                         ├── package.json
└── ...                            ├── tools/
                                   └── ...

In other words, package.json may be at the ZIP root or inside exactly one top-level package directory. Its name determines the installation directory; the ZIP filename and wrapper-directory name do not.

From the Plugin root, create a release archive with:

zip -r ../weather-kit-1.2.3.zip . \
  -x "data/*" ".git/*" ".DS_Store" "__MACOSX/*"

Write the ZIP outside the Plugin root so it cannot include itself. Do not ship data/: installation-specific state belongs in LLM_SPACE_HOME/data/plugins/<plugin-name>/. Include all Extension source files, assets, and runtime dependencies needed by the Plugin. LLM Space does not run a package-manager install after extraction.

Current archive limits are 50 MiB compressed, 200 MiB extracted, and 10,000 entries. Unsafe absolute paths, .. traversal, backslash paths, and archives with multiple package roots are rejected. Common macOS metadata is ignored.

4.2 Install or update by dragging the ZIP

  1. Start LLM Space and drag one or more .zip files onto the main window.
  2. Wait for the Drop plugin ZIP to install overlay, then release the files.
  3. After installation, use the success notification's View plugin action, or open Settings → Plugins.

The success notification includes the Plugin ID and installed version. A ZIP whose package.json.name matches an installed Plugin replaces that Plugin's package files and reloads it. Data stored at LLM_SPACE_HOME/data/plugins/<plugin-name>/ remains untouched. A different package name installs as a separate Plugin; changing name is not an upgrade or migration and does not migrate the old Plugin's data.

4.3 Manual installation

To install an unpacked Plugin manually:

  1. Copy the complete Plugin directory to the correct location under LLM_SPACE_HOME/plugins/.
  2. Open Settings → Plugins.
  3. Select Refresh plugins to discover added, removed, or renamed packages.
  4. Select the Plugin and inspect compatibility, location, Extensions, and diagnostics on the General tab.

4.4 Refresh and reload

After editing files in an already discovered Plugin, select Reload on that Plugin.

  • Refresh plugins rescans the installation directory. Use it for additions, removals, and renames.
  • Reload reloads one existing Plugin. Use it after changing its metadata, schema, configuration, or Extension files.

LLM Space does not fetch Plugins from a registry, run npm install, resolve dependencies, compare versions, or roll versions back. Any runtime dependencies must be shipped inside the Plugin ZIP or directory.

5. Settings

Plugin enablement and configuration are stored in:

LLM_SPACE_HOME/settings/plugins.json
{
  "schemaVersion": 1,
  "plugins": {
    "@example/team-tools": {
      "enabled": true,
      "settings": {
        "endpoint": "https://api.example.com",
        "workspace": "demo"
      }
    }
  }
}

A Plugin without a stored entry behaves as if it had:

{
  "enabled": true,
  "settings": {}
}

Settings are saved automatically. Disabling or removing a Plugin does not delete its Settings, so reinstalling or re-enabling the same Plugin restores them.

If the Settings file is corrupt and cannot be recovered, third-party Plugins are disabled for that startup. The application itself continues to start.

5.1 Settings forms with JSON Schema

Add config.schema.json to generate the Plugin's Settings tab. The current form renderer supports:

  • nested objects;
  • string, number, integer, and boolean;
  • enum;
  • arrays of primitive values;
  • required, default, title, and description.

Example:

{
  "type": "object",
  "required": ["endpoint"],
  "properties": {
    "endpoint": {
      "type": "string",
      "title": "Service endpoint",
      "description": "Base URL of the example service.",
      "default": "https://api.example.com"
    },
    "requestTimeout": {
      "type": "integer",
      "title": "Request timeout (seconds)",
      "default": 30
    },
    "mode": {
      "type": "string",
      "title": "Mode",
      "enum": ["standard", "strict"],
      "default": "standard"
    },
    "notifications": {
      "type": "object",
      "properties": {
        "enabled": {
          "type": "boolean",
          "title": "Show notifications",
          "default": true
        }
      }
    }
  }
}

Defaults are merged with previously saved values. Code Extensions receive a read-only Settings snapshot for each invocation. An invalid schema disables the form and records an Extension error without overwriting existing Settings.

5.2 Environment variables and Settings interpolation

Strings in mcp.json and models.json support:

{
  "token": "$EXAMPLE_API_TOKEN",
  "url": "${settings.endpoint}/v1",
  "label": "Workspace: ${settings.workspace}"
}
  • A complete string in the form $ENV_NAME reads that environment variable. A missing variable resolves to an empty string.
  • ${settings.key} reads Plugin Settings and supports nested paths such as ${settings.network.endpoint}.
  • Non-string Settings values are JSON-serialized before interpolation.

Keep passwords, API keys, and tokens in environment variables. Do not commit them to the Plugin directory.

6. Skills

Plugin Skills use the Agent Skills directory format:

skills/
└── release-checklist/
    ├── SKILL.md
    ├── references/
    │   └── checklist.md
    └── scripts/
        └── verify.ts

Minimal SKILL.md:

---
name: release-checklist
description: Check a release candidate against the team's public checklist.
---

# Release checklist

1. Read `references/checklist.md`.
2. Compare every item with the supplied release notes.
3. Report missing evidence without changing external systems.

Plugin Skills appear as a Plugin source in Settings → Skills. The source cannot be removed, but its menu supports revealing the directory and enabling or disabling all of its Skills. Each Skill can also be enabled or disabled individually.

These choices are stored as user overrides in LLM Space's Skills Settings; Plugin files are never modified. Disabled Plugin Skills remain visible in Settings but are excluded from available_skills and cannot be loaded by the built-in skill() tool. Disabling the entire Plugin removes all of its Skills from new Agent Runs. Re-enabling the Plugin restores the previous per-Skill choices.

7. MCP servers

mcp.json may declare one or more MCP servers. They are shown in Settings → MCP under MCPs in Plugins and remain separate from user-managed servers.

7.1 Stdio example

{
  "servers": [
    {
      "id": "weather",
      "name": "Example Weather",
      "transport": "stdio",
      "command": "bun",
      "args": ["run", "./servers/weather.mjs"],
      "cwd": "${settings.serverDirectory}",
      "env": {
        "WEATHER_API_TOKEN": "$EXAMPLE_WEATHER_TOKEN"
      }
    }
  ]
}

7.2 Streamable HTTP example

{
  "servers": [
    {
      "id": "knowledge-base",
      "name": "Example Knowledge Base",
      "transport": "streamableHttp",
      "url": "${settings.endpoint}/mcp",
      "headers": {
        "Authorization": "$EXAMPLE_API_TOKEN"
      }
    }
  ]
}

Each server requires:

  • id: a stable ID unique within the Plugin;
  • name: the display name;
  • transport: stdio, streamableHttp, or sse.

LLM Space derives the final ID by adding the Plugin namespace:

plugin:@example/team-tools:mcp:knowledge-base

Do not include this prefix in mcp.json. Plugin MCP definitions cannot be edited or removed from the MCP page; configure them with Plugin Settings or environment variables. Disabling the Plugin closes its MCP connections and rejects new calls.

The MCP page identifies the owning Plugin and shows connection details, readiness, discovered Tool count, and Tool names. LLM Space caches successful readiness and Tool discovery results to avoid reconnecting during every UI refresh. A Plugin configuration change marks that snapshot as stale; the next connection test or Tool discovery refreshes it from the server.

Plugins can contribute local executable Tools through tools/*.{ts,js,mjs} or expose remote and shared tools through MCP.

8. Models

models.json can declare providers and models using an API adapter already supported by LLM Space:

  • anthropic-messages
  • openai-completions
  • openai-responses

Example:

{
  "providers": [
    {
      "id": "example-cloud",
      "name": "Example Cloud",
      "api": "openai-completions",
      "baseUrl": "${settings.modelEndpoint}",
      "apiKey": "$EXAMPLE_MODEL_API_KEY",
      "models": [
        {
          "id": "example-chat-1",
          "name": "Example Chat 1",
          "api": "openai-completions",
          "reasoning": false,
          "input": ["text"],
          "contextWindow": 128000,
          "maxTokens": 8192,
          "cost": {
            "input": 0,
            "output": 0,
            "cacheRead": 0,
            "cacheWrite": 0
          }
        }
      ]
    }
  ]
}

A Plugin can declare configuration but cannot load a custom provider implementation. Provider IDs are namespaced automatically. Plugin and user providers remain separate sources. Models from a disabled Plugin are unavailable to new Runs.

9. Plugin Tools

Plugin Tools are PI-compatible tools that the model can call in a local Thread. Use them for repeatable, parameterized operations; use Commands when a person should explicitly start an action from the Command Palette. Every direct tools/*.{ts,js,mjs} file must default-export a zero-argument class. Importing the optional contract catches definition errors while developing the Plugin:

import type {
  PluginToolContext,
  PluginToolExtension,
} from "@llm-space/core";

export default class ReadProjectFileTool implements PluginToolExtension {
  name = "read_project_file";
  description = "Read a UTF-8 file from the LLM Space workspace.";
  parameters = {
    type: "object",
    properties: {
      path: {
        type: "string",
        description: "Workspace-relative path, for example docs/plan.md.",
      },
      maxChars: {
        type: "integer",
        minimum: 1,
        maximum: 50000,
        default: 12000,
      },
    },
    required: ["path"],
    additionalProperties: false,
  } as const;
  strict = true;

  async execute(
    context: PluginToolContext,
    args: Record<string, unknown>
  ) {
    const path = String(args.path ?? "");
    const requestedMaxChars = Number(args.maxChars ?? 12000);
    if (!path) throw new Error("path is required.");
    if (!Number.isInteger(requestedMaxChars) || requestedMaxChars < 1) {
      throw new Error("maxChars must be a positive integer.");
    }
    const maxChars = Math.min(requestedMaxChars, 50000);

    const content = await context.readWorkspaceFile(path);
    return {
      path,
      content: content.slice(0, maxChars),
      truncated: content.length > maxChars,
    };
  }
}

name is the model-facing Tool name and should be stable, descriptive, and snake_case. description explains when to call it. parameters is its JSON Schema; prefer required plus additionalProperties: false, and validate any semantic constraints again inside execute(). strict asks compatible model providers to enforce the Schema strictly.

The file name independently determines the stable Extension ID persisted with the Tool:

tools/read-project-file.ts
→ plugin:@example/team-tools:tool:read-project-file

Changing the package name, file name, or model-facing name can break saved Threads or prompts. Disabling or removing a Plugin does not delete its Tools from saved Threads; calls fail as unavailable until the Plugin returns.

9.1 Tool context and resolved variables

execute(context, args) receives the Thread that owns the Tool Call, not the currently selected tab. The important fields are:

context.settings; // read-only Plugin Settings snapshot
context.signal; // optional AbortSignal
context.thread; // detached, deeply frozen owning Thread
context.variables; // resolved Prompt Variables
context.notify(message);
context.openLink(url);
context.pickFile(options);
context.readWorkspaceFile(path);
context.writeWorkspaceFile(path, content);
context.executeHostCommand(type, args);
context.createResult(content);

The Thread snapshot has no filename, tab API, or write method. Do not mutate it. Parallel Tool Calls in one model batch share the same Thread and resolved variable snapshot.

context.variables resolves configured variables at invocation time:

Variable typeResolved value
Custom, working directory, current dateString
SkillsFormatted string using the configured format
FileUTF-8 file contents
JSONParsed JSON value

Empty or unresolvable variables are omitted. Original definitions remain at context.thread.context.variables; custom variants remain at context.thread.context.variableVariants.

For example, a Tool can combine Settings, resolved variables, and call arguments without relying on the UI's active tab:

async execute(context: PluginToolContext, args: Record<string, unknown>) {
  return {
    project: context.settings.project ?? null,
    cwd: context.variables.current_working_directory ?? null,
    query: typeof args.query === "string" ? args.query : null,
    owningThreadTitle: context.thread.title,
  };
}

9.2 Return values, rich content, and errors

Return JSON-compatible values for ordinary results. LLM Space serializes them as Tool output for the model. Use createResult() when the result needs explicit text and image parts:

return context.createResult([
  { type: "text", text: "Rendered architecture diagram." },
  {
    type: "image",
    data: pngBytesAsBase64,
    mimeType: "image/png",
  },
]);

Throw an Error for an invalid call or an operation that failed. Keep messages actionable and do not include secrets. A Tool class is instantiated once per Plugin load, so multiple calls may reach the same instance. Keep invocation state local to execute() and make shared caches concurrency-safe. If the instance owns a process, timer, or connection, clean it up in dispose():

async dispose() {
  await this.client?.close();
}

Plugin Tools are local-runtime only. They are not listed for remote Threads, cannot execute remotely, and cannot currently be exported to LangGraph. MCP Tools follow the MCP server's transport and schema instead of the class API above; use mcp.json when the Tool already lives in a separate or shared MCP server.

10. Commands

Commands are user-triggered actions shown in the Command Palette. Every direct commands/*.{ts,js,mjs} file must default-export a zero-argument class:

import type {
  PluginCommandContext,
  PluginCommandExtension,
} from "@llm-space/core";

export default class OpenDocumentationCommand
  implements PluginCommandExtension
{
  displayName = "Open example documentation";
  description = "Open the public documentation in the default browser.";

  async execute(context: PluginCommandContext) {
    await context.openLink("https://example.com/docs");
    return context.createResult({
      level: "success",
      message: "Documentation opened.",
    });
  }
}

The file name determines the stable ID and file-stem alias:

commands/open-documentation.ts
→ plugin:@example/team-tools:command:open-documentation
→ palette alias: open-documentation

The class is instantiated once per Plugin load. It may hold short-lived, concurrency-safe state and implement dispose() for resources that must be released when the Plugin is disabled, reloaded, or the app exits.

The progress and terminal-result APIs documented below require LLM Space 4.9.0 or later. Declare that compatibility when using them:

{
  "engines": {
    "llm-space": ">=4.9.0"
  }
}

10.1 Command context

execute(context, arguments) receives common host capabilities plus the invocation-specific arguments, active tab snapshot, and feedback APIs:

context.settings;
context.signal;
context.notify(message);
context.openLink(url);
context.pickFile(options);
context.readWorkspaceFile(path);
context.writeWorkspaceFile(path, content);
context.executeHostCommand(type, args);
context.arguments;
context.activeTab?.filename;
context.activeTab?.thread;
await context.activeTab?.writeThread(thread);
await context.report({ phase: "loading", message: "Loading records…" });
return context.createResult({ level: "success", message: "Import complete." });

Workspace file paths are resolved relative to the LLM Space workspace. The currently allowed host Commands are openSettings and refreshTree; other types are rejected.

10.2 Palette arguments

Users may append shell-style arguments to either the full display name or the file-stem alias. Both of these invoke commands/sync-active-thread.ts:

Sync active thread production "Release 4.9" --dry-run
sync-active-thread production 'Release 4.9' --dry-run

The Command receives:

["production", "Release 4.9", "--dry-run"]

The same frozen array is available as context.arguments and as the optional second execute() parameter. Single and double quotes group whitespace; backslashes escape the next character except inside single quotes. An unclosed quote or trailing escape is reported in the palette and does not execute a partial command.

Validate arguments and return a controlled message for expected user mistakes:

async execute(context: PluginCommandContext, args: readonly string[]) {
  const [environment, ...flags] = args;
  if (!environment) {
    return context.createResult({
      level: "warning",
      message: "Usage: sync-active-thread <environment> [--dry-run]",
    });
  }

  const dryRun = flags.includes("--dry-run");
  await context.report({
    phase: "validated",
    message: dryRun ? "Validated dry-run arguments." : "Arguments validated.",
  });
  // Continue with validated values…
}

10.3 Reading and transactionally updating the active Thread

context.activeTab is the Thread tab that was active when execution started, or null when the active tab is not a Thread. Its thread is a detached snapshot of the complete Thread, including model, Prompt Variables, messages, Tools, Run History, and Evaluations.

activeTab.writeThread(nextThread) stages a complete Thread replacement; it does not mutate the UI immediately. LLM Space validates and commits the staged value only after the Command succeeds, the same tab is still active, and the pane has no Run or persistence operation in progress. Always clone the snapshot before editing it:

import type { PluginCommandContext, Thread } from "@llm-space/core";

async function prefixActiveTitle(
  context: PluginCommandContext,
  prefix: string
) {
  if (!context.activeTab) {
    return context.createResult({
      level: "warning",
      message: "Open a Thread before running this command.",
    });
  }

  const nextThread = structuredClone(context.activeTab.thread) as Thread;
  nextThread.title = `${prefix}: ${nextThread.title}`;
  await context.activeTab.writeThread(nextThread);
  return context.createResult({
    level: "success",
    message: `Updated ${context.activeTab.filename}.`,
  });
}

Only the final staged replacement is committed. A controlled error result, an exception, or a host-side commit conflict discards it.

10.4 Progress and terminal feedback

LLM Space creates one persistent feedback item when a Command starts. Update that same item during longer work with report():

await context.report({ phase: "reading", message: "Reading local metadata…" });
await context.report({ phase: "uploading", message: "Uploading 3 records…" });
await context.report({ phase: "refreshing" });

phase must be a non-empty, stable Command-defined identifier. message is optional user-facing copy. Reports are scoped to the current invocation and reach the UI before execute() finishes.

Ordinary JSON return values are available to the host but are not displayed. Return the opaque value created by createResult() for explicit terminal copy:

return context.createResult({
  level: "success", // "success" | "warning" | "error"
  message: "Synced 6 records.",
});
OutcomeUser feedbackStaged Thread write
Return successsuccess styling and custom messagecommitted if still safe
Return warningwarning styling and custom messagecommitted if still safe
Return errorcontrolled failure and custom messagediscarded
Return JSON or voiddefault completion messagecommitted if still safe
Throw Errorunexpected failure messagediscarded

Use warning or error for expected, actionable outcomes. Throw for unexpected failures such as a broken network contract. Never include credentials in a report, result, or thrown error.

10.5 Complete example: synchronize and update a Thread

This example combines Settings, arguments, progress, network access, a staged Thread update, and a terminal result:

import type {
  PluginCommandContext,
  PluginCommandExtension,
  Thread,
} from "@llm-space/core";

export default class SyncActiveThreadCommand
  implements PluginCommandExtension
{
  displayName = "Sync active thread";
  description = "Fetch the canonical title and apply it to the active Thread.";

  async execute(context: PluginCommandContext, args: readonly string[]) {
    const [environment = "production"] = args;
    const endpoint = String(context.settings.endpoint ?? "").replace(/\/$/, "");

    if (!endpoint) {
      return context.createResult({
        level: "warning",
        message: "Configure endpoint in Plugin Settings first.",
      });
    }
    if (!context.activeTab) {
      return context.createResult({
        level: "warning",
        message: "Open a Thread before running this command.",
      });
    }

    await context.report({
      phase: "fetching",
      message: `Fetching ${environment} metadata…`,
    });

    const response = await fetch(
      `${endpoint}/threads/${encodeURIComponent(context.activeTab.filename)}`
    );
    if (!response.ok) {
      throw new Error(`Metadata request failed (${response.status}).`);
    }

    const payload = (await response.json()) as { title?: unknown };
    if (typeof payload.title !== "string" || !payload.title.trim()) {
      return context.createResult({
        level: "error",
        message: "The server response did not contain a valid title.",
      });
    }

    await context.report({ phase: "updating", message: "Updating the Thread…" });
    const nextThread = structuredClone(context.activeTab.thread) as Thread;
    nextThread.title = payload.title.trim();
    await context.activeTab.writeThread(nextThread);

    return context.createResult({
      level: "success",
      message: `Updated ${context.activeTab.filename}.`,
    });
  }
}

For workspace changes, write the file and refresh the tree explicitly:

await context.writeWorkspaceFile(
  "examples/README.md",
  "# Example\n\nCreated by a Plugin Command.\n"
);
await context.executeHostCommand("refreshTree");
return context.createResult({ level: "success", message: "README created." });

Only one invocation of a given Plugin Command may run at a time; distinct Commands may run concurrently. Plugin Commands currently appear only in the Command Palette and do not automatically receive native menu entries, shortcuts, or context-menu entries.

11. Thread Storages

A Thread Storage reads a Thread from an external backend or writes a Thread to one. It does not replace the workspace: imported Threads are saved under the local workspace/imported/ directory.

Each file must default-export a zero-argument class implementing PluginThreadStorage:

import type {
  PluginThreadStorage,
  Thread,
  ThreadLocator,
  ThreadStorageContext,
} from "@llm-space/core";

export default class TeamLibraryStorage implements PluginThreadStorage {
  displayName = "Example Team Library";
  description = "Read and write Threads in an example service.";
  deepLinkId = "team-library";
  capabilities = { read: true, write: true };

  async resolveLatest(
    id: string,
    context: ThreadStorageContext
  ): Promise<ThreadLocator> {
    const endpoint = String(context.settings.endpoint ?? "");
    const response = await fetch(
      `${endpoint}/threads/${encodeURIComponent(id)}`
    );
    if (!response.ok) throw new Error(`Request failed (${response.status}).`);
    const data = await response.json();
    return { id, version: data.version, filename: data.filename };
  }

  async read(
    locator: ThreadLocator,
    context: ThreadStorageContext
  ): Promise<Thread> {
    const endpoint = String(context.settings.endpoint ?? "");
    const response = await fetch(
      `${endpoint}/threads/${encodeURIComponent(locator.id)}/content`
    );
    if (!response.ok) throw new Error(`Request failed (${response.status}).`);
    return (await response.json()) as Thread;
  }

  async write(
    thread: Thread,
    id: string | undefined,
    context: ThreadStorageContext
  ): Promise<ThreadLocator> {
    const endpoint = String(context.settings.endpoint ?? "");
    const response = await fetch(
      id
        ? `${endpoint}/threads/${encodeURIComponent(id)}`
        : `${endpoint}/threads`,
      {
        method: id ? "PUT" : "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(thread),
      }
    );
    if (!response.ok) throw new Error(`Request failed (${response.status}).`);
    return (await response.json()) as ThreadLocator;
  }
}

The response format in this example is illustrative. A real Storage must validate remote data before converting it to a valid LLM Space Thread or ThreadLocator.

11.1 Capabilities

CapabilitiesRequired methodsProduct entry point
{ read: true, write: false }resolveLatest(), read()Import from…
{ read: false, write: true }write()Save to…
{ read: true, write: true }all three methodsboth

resolveLatest() converts an opaque backend resource ID into a ThreadLocator. Both filename and version are optional. Callers should treat locators as opaque addresses rather than reconstructing them.

A readable Storage may register:

deepLinkId = "team-library";

Production deep link:

llm-space://threads/team-library/project/alpha?revision=latest

Development deep link:

llm-space-dev://threads/team-library/project/alpha?revision=latest

LLM Space uses threads and deepLinkId to select the Storage. Everything after team-library/, including multiple path segments and the query string, is passed to that Storage as the resource identifier. A deepLinkId must be unique across enabled Storages and may contain lowercase letters, digits, dots, underscores, and hyphens.

A Plugin Storage deep link imports into the local workspace. It does not automatically add Web Viewer, public sharing, or shared route support.

12. Runtime and lifecycle

ExtensionRuntime behavior
Skillsmerged into the effective Skills list
MCPconnections managed from mcp.json
Modelsmerged into the effective provider and model lists
Commandsloaded and executed in a Bun runtime
Plugin Toolsloaded in Bun and invoked by the local Agent runtime
Thread Storagesloaded and executed in a Bun runtime

LLM Space imports code files one at a time, invokes their zero-argument constructors, and validates their contracts. An import, constructor, or contract error disables only that Extension. It does not crash the application or disable unrelated Plugins.

Lifecycle events:

  • Discovery: validate paths, metadata, compatibility, and Extension files.
  • Enable: register Extensions and start required MCP or code runtimes.
  • Settings change: save Settings and reload the Plugin.
  • Reload: stop existing connections and instances, then rediscover and activate.
  • Disable: reject new calls, remove Extensions, and close Plugin resources.
  • Shutdown: best-effort dispose() calls and resource cleanup.

13. IDs, read-only sources, and conflicts

Stable Extension IDs are derived from the Plugin ID and a file name or local ID:

plugin:@example/team-tools:mcp:knowledge-base
plugin:@example/team-tools:model-provider:example-cloud
plugin:@example/team-tools:command:open-documentation
plugin:@example/team-tools:tool:read-project-file
plugin:@example/team-tools:thread-storage:team-library

Display names may repeat; canonical IDs may not. When IDs or canonical Skill names conflict, LLM Space does not choose a winner. Conflicting contributions remain inactive and the Plugin details show an Extension error with the files involved.

Plugin contributions and user configuration remain separate:

  • Overall Plugin enablement does not rewrite user MCP or model configuration.
  • Per-Skill visibility overrides are stored in skills.json, but Plugin Skill files are not modified.
  • Plugin MCP and model definitions cannot be edited or removed from their respective Settings pages.
  • To change a Plugin definition, edit its files or Settings and then reload it.

14. Trust and security

LLM Space currently has no Plugin trust prompt or permission manifest. Installing and enabling a local Plugin means fully trusting it.

  • Runtime process isolation contains failures; it is not an operating-system sandbox.
  • Plugin code can use Bun and Node APIs and can access files, processes, environment variables, and networks available to the current user.
  • Plugins may ship and load dependencies. LLM Space does not audit their supply chain.
  • Context APIs do not restrict a Plugin from calling system APIs directly.
  • Do not install a Plugin from an untrusted source merely because it can later be disabled.

Plugin authors should:

  • keep secrets in environment variables, not source, logs, or example files;
  • validate remote JSON, file contents, and deep-link resource IDs at runtime;
  • use reasonable network timeouts and respond to cancellation signals;
  • exclude credentials, headers, full prompts, and tool payloads from errors;
  • close connections, timers, and temporary resources in dispose();
  • avoid symlinks and dependencies on paths outside the Plugin directory.

15. Status, errors, and logs

StatusMeaning
activeenabled and all Extensions activated normally
degradedusable, but at least one Extension failed
errorunable to activate normally
incompatibleengines["llm-space"] does not match this app version
disableddisabled by the user

The UI shows a safe summary, an error-id, and the log path. The complete error chain, stack, and sanitized, truncated process output are written to:

LLM_SPACE_HOME/logs/plugins/<encoded-plugin-name>/<timestamp>-<stage>-<error-id>.log

Discovery failures without a valid Plugin ID are written to:

LLM_SPACE_HOME/logs/plugins/_invalid/<timestamp>-discovery-<error-id>.log

Use Reveal log or Copy path from Settings → Plugins.

To troubleshoot startup while skipping all third-party Plugins for one run:

LLM_SPACE_DISABLE_PLUGINS=1 mise run dev

16. Minimal Plugin example

This example contributes one Command:

plugins/hello-space/
├── package.json
└── commands/
    └── hello.ts

package.json:

{
  "name": "hello-space",
  "version": "1.0.0",
  "displayName": "Hello Space",
  "description": "A minimal LLM Space Plugin example.",
  "engines": {
    "llm-space": ">=4.9.0"
  }
}

commands/hello.ts:

export default class HelloCommand {
  displayName = "Say hello";
  description = "Show a local notification.";

  async execute(context) {
    const audience = String(context.settings.audience ?? "LLM Space");
    return context.createResult({
      level: "success",
      message: `Hello, ${audience}!`,
    });
  }
}

Select Refresh plugins after copying the directory. The General tab should show one Command Extension, and Say hello should appear in the Command Palette.

Optionally add config.schema.json:

{
  "type": "object",
  "properties": {
    "audience": {
      "type": "string",
      "title": "Audience",
      "default": "LLM Space"
    }
  }
}

The Settings tab now displays an Audience field. Editing it saves automatically and reloads the Plugin.

16.1 Complete Plugin example: Atlas

For a copyable example that combines every supported Extension point, see the Atlas Plugin. It contributes two Skills, two MCP servers with four MCP Tools, two model providers with four models, two local Plugin Tools, two Commands, and two Thread Storages. Settings is the only singleton Extension point, so Atlas demonstrates it with one schema containing seven fields.

The example also includes two runnable stdio MCP servers, typed TypeScript contracts, bilingual documentation, and installation instructions. Copy it as a real directory rather than a symlink, install its bundled server dependencies with Bun, and set its absolute MCP server directory before testing it.

17. Development checklist

Before distributing a Plugin, verify that:

  • package.json.name exactly matches the installation directory;
  • version is valid SemVer and engines["llm-space"] covers tested versions;
  • icon.png satisfies the format and size limits;
  • Extension files are direct children of their discovery directories;
  • all runtime dependencies ship with the Plugin;
  • the Settings schema accepts defaults and previously saved values;
  • secrets come from environment variables;
  • MCP commands, working directories, and remote URLs work in a clean environment;
  • models use supported adapters and include all required fields;
  • Command, Plugin Tool, and Thread Storage constructors require no arguments;
  • every Plugin Tool has a stable snake_case name, an accurate description, a restrictive parameter Schema, and JSON-compatible or rich-content output;
  • Commands validate palette arguments, use report() for meaningful long-run phases, and return an explicit terminal result when custom copy is useful;
  • controlled Command failures and exceptions do not commit staged Thread writes;
  • parallel Tool calls and distinct concurrent Commands do not corrupt shared instance state;
  • readable Storages return valid Threads and writable Storages return valid locators;
  • deep links use a unique, stable deepLinkId and validate arbitrary suffixes;
  • one failed Extension does not prevent other Extensions from working;
  • reload, disable, enable, and shutdown clean up resources correctly;
  • logs contain no secrets, full prompts, or sensitive tool payloads.

18. Currently unsupported

The current Plugin system does not include:

  • before/after agent, turn, or model hooks;
  • a Plugin marketplace;
  • automatic installation, update, dependency installation, or rollback;
  • project-scoped Plugins;
  • Remote Runtime Plugins;
  • permission manifests or trust review;
  • per-Extension enablement except for Plugin Skills;
  • custom Plugin Settings pages or other custom UI;
  • automatic Web Viewer or public-sharing integration for Plugin Thread Storages.

These boundaries keep discovery predictable, failures diagnosable, and startup resilient while leaving room for compatible additions in future versions.