Skip to content

Writing plugins ​

A plugin is an ES module whose default export is a Plugin object. The contract lives in @alisio/sdk: types plus two helpers (definePlugin, textResult), with no runtime dependencies and no provider SDKs.

ts
import { definePlugin, textResult } from "@alisio/sdk";

export default definePlugin({
  id: "acme.hello",
  name: "Acme Hello",
  description: "Adds a friendly greeting tool",
  version: "0.1.0",
  apiVersion: 1,
  setup(api) {
    api.tools.register({
      name: "hello",
      description: "Greets the user.",
      effect: "read",
      inputSchema: { type: "object", properties: {}, additionalProperties: false },
      async execute() {
        return textResult("Hello!");
      },
    });
  },
});

Plugins are trusted code

Plugins run in-process with the full privileges of the Alisio process. A plugin manifest or a subprocess is not a sandbox, and the effect field does not isolate anything. Only load code you trust.

The Plugin object ​

MemberDescription
idUnique ID matching ^[a-z0-9][a-z0-9.-]{0,63}$, for example acme.hello
versionSemVer, for example 0.1.0 or 0.1.0-beta.1
apiVersionAlways 1
name, descriptionOptional human-friendly catalog text used by /plugins
categoriesOptional catalog categories (see Plugin categories). The host also derives model-provider from provider registrations
setup(api)Registers everything; may be async. If it fails, partial registrations are rolled back
extensionsOptional; declarative providers for extension points, registered at priority 0
dispose()Optional; releases resources when Alisio closes. It runs only on close, never when a plugin is disabled or on /reload, and is limited by pluginHooks.disposeTimeoutMs (see Shutdown)

definePlugin is an identity function that only adds typing. Duplicate plugin IDs are rejected.

Plugin categories ​

categories is a list of catalog groupings. The TUI groups /plugins rows by the first declared category and falls back to "General" when a plugin declares none; the detail view lists every category. Plugin authors may declare any accepted value; the host only ever derives model-provider by itself, from provider registrations. Categories describe what a plugin does, so a single plugin may declare several.

CategoryMeaningUsed by built-ins
model-providerRegisters model providersopenai-compatible
methodology-harnessBundles a development-methodology workflow—
memoryPersistent memory, recollection and session summariesmemory
subagentsDelegation, child sessions and agent managementsubagents
searchWeb or vector search providers—
toolsGeneral-purpose tool collections—
securityAudit, sandbox or permission tooling—
analyticsUsage/metrics instrumentation (session stats, cost tracking)—
mcpMCP-server management or bundling helpers—
storageDurable storage backends beyond the default SQLite state—
uiTUI presentation providers (startup screens, mascots, panels)—
decisionsDecision Intelligence providers (api.decisions.registerProvider)—

The only built-in model provider is openai-compatible. Dedicated providers (DeepSeek, OpenCode Console (Zen), OpenCode Go) are separate plugins published from the alisio-plugins monorepo and installed with alisio install npm:@alisio/plugin-{deepseek,opencode,opencode-go}; once installed they register as model-provider plugins and appear in /connect and /model.

Managing plugins in the TUI ​

Run /plugins to open the filterable catalog. [x], [ ], [!] and [*] mean active, inactive, failed and restart required; rows also distinguish built-ins from safe external source labels. Select a row for its full description, category and available action.

Toggles update the current project's .alisio/config.json atomically and preserve unrelated JSON fields. They deliberately take effect on the next Alisio start: live registrations, hooks, storage and provider clients are not partially removed. Returning a toggle to its original runtime state clears restart-required. External plugins require a trusted project and an explicit confirmation because they execute with full process privileges. Alisio blocks disabling a model-provider plugin while the active provider or any live routed session retains it, and blocks plugins that own current session resources. Switch provider and close retained sessions, or restart Alisio, first. Provider profiles and credentials remain global settings and are never copied into plugin configuration.

PluginAPI reference ​

Every register/on method returns an unregister function. Everything a plugin registers is removed automatically when it is unloaded.

MemberDescription
tools.register(tool)Registers a tool (ToolDefinition)
commands.register(name, handler, options?)Registers a command; handler(args: string, context?: { sessionId? }) => Promise<string> returns the text shown to the user (sessionId is the interactive UI's current session, when known). options: { description?, argumentHint? }, shown in /help and autocompletion
events.on(handler)Observes versioned run events (RunEvent: schemaVersion, runId, sessionId, seq, type, timestamp, data, plus the optional eventId and correlationId). KnownRunEvent types the data of every event the core emits
context.register(provider)() => Promise<string>; adds text to the model context
resources.skills(path)Adds an Agent Skills root (relative to the plugin file)
resources.agents(path)Adds a directory of agent definitions for delegation plugins (see Subagents)
resources.list(kind)Directories registered by every plugin for skills, prompts or agents, with the plugin ID
resources.prompts(path)Adds a directory of prompt templates (*.md, relative to the plugin file)
state.get(key) / state.set(key, value)Small JSON state per plugin, persisted in the session database
storage.sqlite(path)Opens a private (0600) SQLite file, creating parent directories (0700). Returns the storage port SqlDatabase
views.register(view)Registers a named, read-only data view hosts such as the web UI can read. Absent on a core that predates it: use api.views?.register(...)
apps.register(app)Registers a full-page plugin app shown beside the projects panel. Absent on a core that predates it: use api.apps?.register(...)
proof.registerVerifier(verifier)Registers a Proof verifier ({ id, describe, run(input, ctx) }) that criteria bind to as <pluginId>:<id>. Absent on a core that predates Proof: use api.proof?.registerVerifier(...)
decisions.registerProvider(provider)Registers a Decision Intelligence provider (it does not activate it); also available(), activeProvider(), tryDecide(request, options?) and the optional activate(providerId, options?) (options.recommend pre-selects Yes after an explicit user action). Absent on a core that predates it: use api.decisions?.registerProvider(...)
paths{ state, config, cache }: per-plugin directories resolved by the host and created with mode 0700 on first read. Absent on a core that predates it
optionsRead-only pluginOverrides[id].options (or {}), frozen when setup runs. Absent on a core that predates it
compaction.register({ beforeCompact, afterCompact })Compaction hooks, see below
session.onStart(handler)Returned text is injected once at the start of a new, empty session (persisted in the session)
session.onEnd(handler)Called when an interactive session ends (/clear, /exit, quit)
model.complete(request)Provider-agnostic text completion: { system, messages, maxTokens?, model?, signal? } → Promise<string>
models.list() / models.resolve(reference)Lists credential-free configured models and resolves canonical provider/model or a unique bare ID
providers.register(provider)Adds provider metadata, configuration/auth fields, capabilities and a factory. Registrations coexist; /connect selects one
extensions.register(point, provider, options?)Provides an implementation for an extension point (mascot, startup-screen); options: { priority? }
ui.status(key, text, detail?)Short status text in the TUI status bar; detail appears in /stats; text: undefined clears it
ui.panel(id, provider)A collapsible tree panel (PanelProvider), interactive UIs only
ui.select(request)Asks the user to choose (SelectRequest: title, options with value, label, description?); resolves undefined without an interactive UI
ui.open(sessionId)Opens a session in a read-only view; returns false without an interactive UI
ui.interactive()true when an interactive UI can answer select
sessions.*Child sessions, see below

Provider plugins depend only on @alisio/sdk. Their factory receives separate non-secret profile, secret credentials, and optional legacy values. Core owns persistence and activation. Registration cleanup participates in plugin rollback; failed provider creation leaves the active provider intact.

Tools ​

ts
interface ToolDefinition {
  name: string;
  description: string;
  inputSchema: JsonSchema;
  effect?: "read" | "write" | "process" | "external" | "internal";
  paths?: (input: Record<string, unknown>) => string[];
  concurrent?: boolean;
  execute(input: Record<string, unknown>, context: ToolContext): Promise<ToolResult>;
}

concurrent: true lets a call run concurrently with other read or concurrent calls of the same turn (the delegation tools use it). ToolContext provides signal, workspace, emit(data) and session. Return textResult(text, isError?). The effect controls permissions (Tools & permissions): unknown or plugin operations default to external; only declare read for side-effect-free tools.

Rich results (ui blocks) ​

A tool result may add { type: "ui", block } parts next to its text. A UiBlock is plain data; plugins never ship rendering code. Always keep a text part too: providers, compaction and headless output only see the text projection, and the block is a display hint.

ts
return {
  content: [
    { type: "text", text: "2 passed, 1 failed" },
    {
      type: "ui",
      block: {
        kind: "test-results",
        framework: "vitest",
        suites: [{ name: "math", cases: [{ name: "adds", status: "passed" }] }],
      },
    },
  ],
};
KindFieldsTUI rendering
tablecolumns, rows, caption?Aligned columns
key-valueentries, caption?Two columns
treenodes (label, children?, meta?)Branch glyphs
codecode, lang?, caption?Highlighted code
markdowntextMarkdown
diffpatch? or before?/after?, path?, lang?, caption?Unified patch as diff code; otherwise labeled before/after
terminaloutput, command?, cwd?, exitCode?, durationMs?, truncated?Output without ANSI plus exit <code> · <time>
mermaidsource, title?Source as mermaid code
mathlatex, display?Literal LaTeX
jsonvalue, collapsedDepth?, caption?Indented JSON, truncated
test-resultssuites (name, file?, cases), framework?, durationMs?Table of suite, case, status and time, plus failures
progresssteps (label, status, detail?), title?One line per step with a status glyph

UI_BLOCK_KINDS lists every kind. Keep payloads bounded (about 200 KB for a diff and 256 KB for terminal output or serialized JSON). A kind that the running Alisio does not know, or a malformed block, is shown as labeled JSON instead of failing, so older builds can replay newer transcripts.

Naming and prefix rules ​

ItemExternal pluginBuilt-in plugin
Tool namep_<hash>_<name> (10 hex characters of the SHA-256 of the plugin ID), avoiding collisions and meeting provider name limitsUnprefixed
Command<plugin id>:<name>, invoked as /command acme.hello:name args or /acme.hello:name; also from alisio run "/acme.hello:name args", which prints the result without calling the model (--json prints one command_result line), and from the web paletteUnprefixed (for example /memory)
internal effectDowngraded to externalAllowed

Compaction hooks ​

ts
export default definePlugin({
  id: "my.notes",
  version: "0.1.0",
  apiVersion: 1,
  setup(api) {
    api.compaction.register({
      async beforeCompact() {
        return { outputFields: { notes: "array of short strings worth keeping" } };
      },
      async afterCompact({ extracted }) {
        const notes = Array.isArray(extracted.notes) ? extracted.notes : [];
        return { report: { summary: `notes: ${notes.length}` } };
      },
    });
  },
});
  • beforeCompact(input) receives sessionId, reason (manual or auto), the messages about to be replaced, focus and signal. It may return instructions (appended to the summarizer instructions) and outputFields (extra top-level JSON fields, name → description, requested in the same summarizer call).
  • afterCompact(result) also receives model, replaced, structured, checkpoint, checkpointText and extracted (this plugin's fields, unvalidated: validate them yourself). It may return injectContext (text appended after the checkpoint; keep it budgeted) and report (summary is displayed verbatim).

Session hooks and model completions ​

ts
api.session.onStart(async (info) => `Project notes for ${info.workspace}`);
api.session.onEnd(async (info) => {
  const summary = await api.model.complete({
    system: "Summarize the session in one sentence.",
    messages: [{ role: "user", text: `${info.messages.length} messages` }],
    maxTokens: 200,
    signal: info.signal,
  });
  api.state.set("lastSummary", summary);
});

SessionInfo contains sessionId, model, workspace, reason (start, clear or exit), messages and signal. model.complete uses the configured provider (the session model when the plugin passes it) and does not count against limits.maxTokens.

Storage port ​

ts
const db = api.storage.sqlite("/path/to/notes.sqlite");
db.exec("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT)");
db.transaction(() => db.prepare("INSERT INTO notes (text) VALUES (?)").run("hello"));
const rows = db.prepare("SELECT * FROM notes").all();

SqlDatabase is synchronous: exec(sql), prepare(sql) (statements are cached per SQL text and offer run, get, all), transaction(fn) (immediate transaction; nested calls join the outer one) and close(). FTS5 is available. The host provides the driver, so plugins never depend on a specific runtime.

Data views ​

A data view is a named function that returns JSON for one session, which hosts read without running a tool. alisio serve exposes them to the web UI (the built-in memory plugin feeds the Memory tab this way).

ts
import { definePlugin } from "@alisio/sdk";

export default definePlugin({
  id: "notes",
  version: "1.0.0",
  apiVersion: 1,
  setup(api) {
    // `views` is absent on an older core: feature-detect it and keep working without it.
    api.views?.register({
      id: "recent",
      description: "Recent notes of the session",
      params: {
        type: "object",
        properties: { limit: { type: "integer", minimum: 1, maximum: 50, default: 20 } },
      },
      // `params` is already validated and coerced; `loadNotes` is your own storage code.
      handler: (params, { sessionId }) => ({ items: loadNotes(sessionId, Number(params.limit)) }),
    });
  },
});
  • id is lowercase letters, digits and dashes (up to 40 characters) and unique within the plugin; description is required. params is a JSON Schema object of primitives (string, integer, number, boolean; no $ref) whose unknown keys are rejected. Query strings are coerced to the declared types and defaults are applied before handler(params, { sessionId, workspace, signal }) runs. ViewParamsError turns a parameter the schema cannot express into a 400.
  • Over HTTP a view is GET /api/sessions/:sid/views/:plugin/:view?<params>, behind the same session cookie, Host and Origin rules as every other route. Only enabled plugins answer, the session must exist and its workspace must still exist, and a view of another plugin or a disabled one is a 404. At most 16 query parameters of 512 characters each are accepted.
  • The response is JSON of at most 1 MiB (view_too_large, 502) and the handler has 5 seconds (view_timeout, 504; signal aborts). Any other failure is a generic view_failed (502): its message and the parameters are never sent or logged, because they may carry project data.
  • Views are read-only by contract, not by isolation. The host only controls the method, the validated parameters, the time and the size; it cannot stop a plugin's code from writing, and a handler that blocks the event loop is not interrupted by the timeout. A plugin is not a sandbox.

Plugin apps ​

A plugin can contribute a full-page app: a small tool or a complete multi-page UI that lives next to the projects panel and is independent of the chat. Register it during setup; the host removes it when the plugin closes or when setup fails.

ts
import { definePlugin } from "@alisio/sdk";

export default definePlugin({
  id: "acme.notes",
  version: "1.0.0",
  apiVersion: 1,
  setup(api) {
    // `apps` is absent on an older core: say so instead of failing silently.
    if (!api.apps) throw new Error("This plugin needs an Alisio host with plugin apps");
    api.apps.register({
      id: "notes",
      label: { en: "Notes", es: "Notas" },
      html: () => PAGE, // self-contained HTML, at most 8 MiB
      actions: {
        save: {
          effect: "write",
          inputSchema: { type: "object", properties: { text: { type: "string" } } },
          handler: async (input, context) => saveNote(context.sessionId, input),
        },
      },
      busy: () => hasBackgroundJobs(), // keeps the workspace from idle eviction
      dispose: () => cancelOwnedJobs(), // synchronous and idempotent
    });
  },
});
  • Ids and labels. id follows the view rules (^[a-z][a-z0-9-]{0,39}$) and is unique per plugin; a duplicate throws. label is a string or { en, es }; the web picks the active language. Action names match ^[A-Za-z][A-Za-z0-9_.-]{0,63}$.
  • Where it appears. The web UI lists the apps of the enabled plugins of the open project in an Apps group just above Projects (the group is not shown when there are none) and refreshes the list every 15 seconds. A long list shows five apps and a Show all (N) button. Opening an app replaces the whole chat area; the projects panel stays, and Back to chat returns to the conversation, its draft and pending attachments. The app frame stays mounted while you switch, so unsaved edits survive. Another project is another instance. The app runs in the current session of the project (else its most recent one, else a new one) and keeps it.
  • The frame. The page is served by GET /api/sessions/:sid/apps/:plugin/:app/frame into a sandboxed iframe (allow-scripts allow-downloads allow-modals allow-popups, no allow-same-origin, so it has no cookies or access to the host DOM). Every inline <script> must carry nonce="__ALISIO_NONCE__"; the server replaces the marker with a fresh nonce per request. The CSP is default-src 'none', nonce scripts, inline styles, data:/blob: images, fonts and media, connect-src blob:, worker-src blob:, base-uri 'none', form-action 'none': the page cannot reach the network, only the host.
  • Messages. On load the host transfers a MessagePort to the frame with { type: "alisio.app.connect", locale, visible } (accept the first channel that comes from window.parent). Over the port the frame sends { type: "request", id, action, input } and gets { type: "response", id, result } or { type: "response", id, error: <code> }; the host also sends { type: "locale", locale } and { type: "visibility", visible }. Error codes are not_found, invalid_input, not_permitted, timeout, failed, too_large, too_many_requests, unauthorized and network; they never carry inputs or secrets. At most 8 requests run at once per frame. The wire types (PluginAppConnectMessage, PluginAppRequestMessage, PluginAppHostMessage, PluginAppErrorCode) are in @alisio/sdk.
  • Actions. Each action declares an effect (read, write or process) and an inputSchema (an object schema; $ref, $id and $async are rejected). Over HTTP an action is POST /api/sessions/:sid/apps/:plugin/:app/actions/:action with { input, idempotencyKey? }. The host validates the input, then applies the same effect gate as a tool call with the session's effective capabilities. An effect the session already grants runs. When the chat would ask for it (a write or process action in a session that is not read-only), the host raises the same approval as a chat tool call, with the same choices (allow once, allow for the session, deny): the prompt reads "Plugin · App wants to write files (action)", shows over the app (the chat is hidden while an app is open) and never includes the input. "Allow for the session" widens the session policy exactly like the chat, so later app actions and chat tools with that effect stop asking. Deny, no answer, no open web client (the approval is denied after the usual grace) or an effect the session forbids outright (a read-only session) is rejected with app_action_denied (403). The 30 second action timeout starts when the handler starts, not while the approval is pending; the wait for the answer is capped at the chat's approval timeout (10 minutes) and closing the request cancels the pending approval. A rejection names the effect so the app can tell the user what to enable: { type: "response", id, error: "not_permitted", effect: "write" | "process" } over the port and error.details.effect in the HTTP error body. The session comes from the route, never from the input (context.sessionId, context.workspace). Whether the plugin is enabled is checked on every call. Limits: 37 MiB request, 90 MiB result (app_result_too_large), 30 seconds (app_action_timeout, 504; context.signal aborts). Long work should return a job quickly instead of holding the request. Failures are a generic app_action_failed (502); inputs and results are never logged.
  • Models and agents. Handlers reuse api.models, api.model.complete and api.sessions; they need no provider SDK or separate keys.
  • A browser sandbox is not a Node sandbox. The frame is isolated in the browser, but the plugin's backend is trusted in-process code with the privileges of Alisio, and a promise timeout does not stop CPU-bound code. Disabling a plugin blocks new actions at once, but an already open port is not revoked immediately.

See examples/plugins/hello-app for a minimal app.

Decision Intelligence, paths and options ​

api.decisions, api.paths and api.options are optional: a core that predates them leaves them out, so feature-detect each one and keep working without it.

ts
setup(api) {
  api.decisions?.registerProvider(myProvider); // Registering never activates it.
  const cache = api.paths?.cache; // Per-plugin directory, created on first read.
  const device = api.options?.device ?? "cpu"; // From `pluginOverrides[id].options`.
}
  • api.decisions registers a decision provider and lets a plugin tool ask for decisions. The user activates a provider with decisions.provider in the global configuration; installing the plugin is not enough. The contract, the errors and the lifecycle are in Decision Intelligence. Tools receive the consumer side as context.decisions?.tryDecide(...), bound to the current run. The optional api.decisions?.activate?.(providerId) lets a plugin ask the host, from a user-initiated step, to make a provider it registered the active one: the user confirms once and the host saves decisions.provider globally (see asking for activation).
  • api.paths gives state (<state root>/plugins/<id>), config (<config home>/plugins/<id>) and cache (<state root>/plugins/<id>/cache). The state root is the one analysis and artifacts use, so it follows --db. Use these instead of resolving XDG or ALISIO_* paths yourself.
  • api.options holds the JSON you or the user put in pluginOverrides[id].options of the global configuration (see pluginOverrides). It is global only, at most 8 KB, and a snapshot taken at setup: changing it needs a restart. Do not keep secrets there.

api.semantic and context.semantic are optional: a core that predates them leaves them out, so feature-detect and keep working without them. The core defines two independent ports and a service that composes them; it ships no engine (no vector database, no inference runtime, no model). Semantic search exists only when the user installs a plugin that provides one.

ts
// Consumer: index and search text without knowing the engine.
async execute(input, context) {
  const semantic = context.semantic;
  if (!semantic?.available()) return fallbackToFullTextSearch(input); // e.g. SQLite FTS5
  await semantic.index("notes", [{ id: "n1", text: "Quarterly budget review" }]);
  const matches = await semantic.trySearch("notes", input.query, { topK: 5, minScore: 0.3 });
  return matches ?? fallbackToFullTextSearch(input); // null: infrastructure problem
}

// Provider: register the ports (registering never activates them).
setup(api) {
  api.semantic?.registerEmbedding({
    id: "my-embed",
    name: "My embeddings",
    modelId: () => "my-org/my-model@q8", // changes whenever the vectors stop being comparable
    dimensions: () => 384,
    async embed(texts, { kind, signal }) {
      // `kind` is "query" or "document": apply the model's own prefixes here.
      return runModel(texts, kind, signal); // one Float32Array per text, same order
    },
  });
  api.semantic?.registerStore(myVectorStore); // a `VectorStoreProvider`
}
  • Activation is the user's. semantic.embedding and semantic.store in the global configuration pick the active providers (see semantic); a project config cannot. A provider may offer a user-initiated setup step that calls api.semantic?.activate?.({ embedding, store }): the host asks once, saves the ids globally, never overwrites another configured provider, and resolves needs_confirmation when nobody can be asked (headless).
  • Collections are namespaced per plugin. The host stores <pluginId>/<collection>, and collections() lists only your own, without the prefix. This prevents accidental collisions; it is not isolation: plugins are trusted in-process code.
  • Models are tracked. Each collection remembers the modelId and dimensions it was built with. After the user changes the model, index and search reject with model_mismatch or dimension_mismatch: drop the collection and index again from your own source text, because only you still have it. trySearch resolves null in that case so you can fall back.
  • Limits are checked before any provider runs and fail with invalid_request: 1..256 texts per embed (at most 32768 characters each), up to 1000 documents per index, topK 1..100, record ids ^[A-Za-z0-9._:-]{1,128}$, collection names ^[a-z0-9][a-z0-9_-]{0,63}$, metadata of at most 8 KiB with scalar values, and filters that are equality on at most 16 top-level keys.
  • Provider calls are bounded. Each call gets an AbortSignal and a time limit (semantic.embedTimeoutMs, semantic.queryTimeoutMs); a timeout is timeout, an abort by the caller is propagated as the abort, and any provider failure is a provider_error whose message contains no paths and no user text.
  • minScore depends on the model: cosine scores are not comparable across models.
  • /semantic (read-only, every surface) shows the registered and active providers, the model, the dimensions and each provider's health() (1 second limit).

Proof verifiers ​

A plugin can contribute a verifier for Proof acceptance criteria. The member is optional: feature-detect it, and the plugin keeps working on a core without Proof.

ts
setup(api) {
  api.proof?.registerVerifier({
    id: "links",
    describe: "Checks that the docs have no broken links",
    async run(input, ctx) {
      const { callId, result } = await ctx.callTool("read_file", { path: "docs/index.md" });
      const text = result.content.map((p) => (p.type === "text" ? p.text : "")).join("");
      return { ok: !text.includes("](broken)"), outputHash: sha256(text), callId };
    },
  });
}

A user binds a criterion to it with { "kind": "plugin", "verifierId": "<pluginId>:links" }. The result must carry referenceable evidence: outputHash (sha256 hex of what the verdict rests on) and, when you made core tool calls, the callId of the one that backs it. Without a valid outputHash, or with a callId the session never made, the criterion becomes manual_review. If run throws or exceeds 60 s the criterion stays as it was. A verifier gains no permissions: ctx.callTool runs a core tool through the same gate as the agent (a refused call comes back with isError). It is trusted, in-process code, not a sandbox, and it never creates or edits criteria.

Child sessions ​

api.sessions is a generic service for delegated work: a child session is a separate, persisted conversation (fresh context) with a parent link, run by the same runner with narrowed permissions. A child can never gain a capability, tool or approval its parent lacks, and aborting a parent run aborts its running descendants. The core contains no agent logic; the subagents plugin is built on this service.

MemberDescription
spawn(spec)Creates a child session (ChildSessionSpec) and returns ChildSessionInfo
create(spec)Resolves spec.model, if present, then creates a provider-bound child; use this for model overrides
run(id, prompt, { signal? })Runs a turn in the child; resolves a ChildRunResult (status, text, usage, error?)
get(id), children(parentId)Session info (id, parentId, depth, agent, title, status, model, workspace, usage, capabilities, timestamps)
ancestors(id)Ancestor IDs, nearest first
cancel(id)Cancels a run and all running descendants; returns how many were running
enqueue(id, text)Queues a user message for the session's next turn (or next run when idle)
isRunning(id)Whether the session is running
capabilities(id)Effective write, process, approvals and readOnly
model(id), workspace(id)Model a new child would inherit; the session's workspace
setStatus(id, status)Sets a SessionStatus: queued, running, completed, failed, cancelled or interrupted

ChildSessionSpec fields: parentId, id?, title, agent (label shown in UIs and approvals), instructions?, tools?: { allow?, deny? } (* allows every parent tool), model?, readOnly?, permission?: { write?, process? } (allow, ask or deny), workspace? (for example a git worktree), maxTurns?, timeoutMs? and maxTokens?.

sessions.run resolves with { id, status, text, usage, error? }. A child that exhausts its turn cap is NOT a failure: it resolves with status: "completed" and turnsExceeded: true, and text holds a usable partial report (only real errors, cancellations and timeouts set a failure status).

ts
api.tools.register({
  name: "second_opinion",
  description: "Ask a read-only child session to review a file.",
  effect: "external",
  concurrent: true,
  inputSchema: {
    type: "object",
    properties: { path: { type: "string" } },
    required: ["path"],
    additionalProperties: false,
  },
  async execute(input, ctx) {
    if (!ctx.session) return textResult("needs a session", true);
    const child = api.sessions.spawn({
      parentId: ctx.session,
      title: `Review ${String(input.path)}`,
      agent: "reviewer",
      instructions: "Review the file and report bugs only.",
      tools: { allow: ["read_file", "search_text"] },
      readOnly: true,
      maxTurns: 10,
    });
    const result = await api.sessions.run(child.id, `Review ${String(input.path)}`, {
      signal: ctx.signal,
    });
    return textResult(result.text || result.error || result.status, result.status !== "completed");
  },
});

PanelProvider has a title, nodes({ sessionId }) returning PanelNodes (id, parentId?, label, color?, status, startedAt?, endedAt?, tokens?, detail?, sessionId?) and an optional action("cancel" | "background", nodeId, { sessionId }).

Hook timeouts and failure isolation ​

  • Compaction and session-start hooks run with pluginHooks.timeoutMs (default 15 000 ms); session-end hooks with pluginHooks.sessionEndTimeoutMs (default 10 000 ms).
  • Each hook receives an AbortSignal that is aborted when the timeout expires.
  • A failure or timeout is recorded as a plugin_hook_failed event and the core continues without that plugin's contribution.
  • Hooks run in-process: a timeout stops the wait and signals the AbortSignal, but it cannot stop blocking synchronous code.

Shutdown ​

  • dispose() runs when Alisio closes: leaving the terminal UI, SIGINT, SIGTERM and SIGHUP (terminal UI, alisio serve and alisio run) and the end of a run. It does not run when a plugin is disabled or on /reload, because both need a restart.
  • Plugins are disposed in parallel and each one is limited by pluginHooks.disposeTimeoutMs (default 2000 ms, 100 to 10000); a slow or hung dispose() does not delay the others, and its failure is reported without stopping them. Finish well within that limit.
  • A sudden death (SIGKILL, a crash) cannot be covered: a plugin that owns an operating-system process must clean up stale ones itself.

Extension points ​

Extension points let a plugin replace a piece of the host with its own provider. They are typed in ExtensionPoints; new points are added there without breaking existing ones.

PointProvider typeDefault
mascotMascotProvider: { id, render(ctx: MascotContext) } returning a string or string[]DefaultAlisioMascot (alisio.default)
startup-screenStartupScreenProvider: { id, render(ctx: StartupContext): string[] }DefaultStartupScreen (alisio.default)
websearchSearchProvider: { id, search(query, options?: { signal? }): Promise<SearchResult[]> }, SearchResult = { title, url, snippet }The built-in chain: a configured websearch.provider, else a public SearXNG instance (see Tools & permissions)

Register a provider imperatively, or declare it on the plugin object:

ts
import { definePlugin } from "@alisio/sdk";
import { kiteMascot } from "./kite.js";

// Imperative, with a priority
export default definePlugin({
  id: "kite-mascot",
  version: "0.1.0",
  apiVersion: 1,
  setup(api) {
    const dispose = api.extensions.register("mascot", kiteMascot, { priority: 10 });
    // dispose() unregisters it; everything is also removed when the plugin unloads.
  },
});

// Declarative, at priority 0
export const declarative = definePlugin({
  id: "kite-mascot-declarative",
  version: "0.1.0",
  apiVersion: 1,
  extensions: { mascot: kiteMascot },
  setup() {},
});
  • api.extensions.register(point, provider, { priority }) returns a disposer that unregisters it. priority is a finite number, default 0; a provider needs an id (that is the one thing every point shares — a renderable point like mascot additionally needs a working render, checked when the host actually calls it, the same way a throwing render already falls back today).
  • The declarative Plugin.extensions field is sugar for api.extensions.register(point, provider) at priority 0, applied before setup runs.

Resolution ​

For each point the host picks exactly one provider, deterministically:

  1. highest priority;
  2. then plugin id, lexicographically (plugins are identified by id, not by package name);
  3. then registration order within the same plugin.

Load order across plugins never matters. The built-in defaults are only used when nothing is registered, or as a replacement for a failing provider. When several providers tie at the winning priority, the host reports an extension_conflict diagnostic with point, winner and losers (as plugin/provider IDs). Conflicts and the resolved providers are shown in /stats and in alisio plugins doctor:

sh
alisio plugins doctor --plugin ./dist/index.js

Typed contexts ​

TypeFields
TerminalCapabilitiescolor (ANSI SGR allowed), unicode (non-ASCII allowed), columns, interactive
MascotContextterminal, version
PluginMetadataid, version, builtin
StartupFactlabel, value
StartupContextversion, cwd (already shortened), model?, provider? (host only, never credentials), userName?, terminal, plugins (PluginMetadata[]), mascot (the resolved, validated mascot), tips, facts? (host facts such as access and memory state)

StartupContext.mascot is always safe to call: a custom screen can reuse whichever mascot won.

Safety rules ​

  • Providers receive only their context: they must not read globals, environment variables or the filesystem. Honor terminal.color, terminal.unicode and terminal.columns.
  • A provider that throws, returns something other than a string or an array of strings, returns too many lines (12 for a mascot, 60 for a screen) or takes longer than 250 ms is replaced by the default, and a plugin_hook_failed diagnostic is recorded. Rendering is synchronous, so a slow provider cannot be preempted: its output is discarded afterwards.
  • Output is sanitized: only SGR color sequences are kept, and only when color is true; other escape sequences and control characters are removed; tabs become spaces; non-ASCII characters become ? when unicode is false. Every line is clamped to columns.
  • The default screen is built from reusable section functions exported by @alisio/core: titleSection, welcomeSection, infoSection, pluginsSection, tipsSection, mascotSection, composeSideBySide, shortenPath and startupTips (deterministic for a given seed). It shows the mascot side by side with the information when the terminal is wide enough, and stacked otherwise.

Example: kite mascot ​

The repository ships an example package in examples/plugins/custom-mascot (alisio-plugin-kite-mascot, plugin id kite-mascot). It depends only on @alisio/sdk (peer dependency) and ships JavaScript.

ts
import { definePlugin, type MascotProvider, type StartupScreenProvider } from "@alisio/sdk";

/** A kite riding the trade winds. Honors unicode/color/columns from the context only. */
export const kiteMascot: MascotProvider = {
  id: "kite",
  render({ terminal }) {
    if (terminal.columns < 40) return terminal.unicode ? "◇~ kite" : "<>~ kite";
    const art = terminal.unicode
      ? ["   ◢◣", "  ◢██◣", "  ◥██◤", "   ◥◤", "    ╲", "     ∿∿"]
      : ["   /\\", "  /  \\", "  \\  /", "   \\/", "    \\", "     ~~"];
    return terminal.color ? art.map((line) => `\u001b[35m${line}\u001b[0m`) : art;
  },
};

/** Optional compact screen that reuses whichever mascot won resolution. */
export const compactScreen: StartupScreenProvider = {
  id: "kite.compact",
  render(ctx) {
    const mascot = [ctx.mascot.render({ terminal: ctx.terminal, version: ctx.version })].flat();
    return [
      ...mascot,
      `Alisio ${ctx.version} · ${ctx.model ?? "no model"} · ${ctx.plugins.length} plugin(s)`,
      ...ctx.tips.slice(0, 1),
    ];
  },
};

export default definePlugin({
  id: "kite-mascot",
  version: "0.1.0",
  apiVersion: 1,
  setup(api) {
    // Priority 10 beats plugins registering at the default priority 0.
    api.extensions.register("mascot", kiteMascot, { priority: 10 });
    // Uncomment to also replace the whole startup screen:
    // api.extensions.register("startup-screen", compactScreen);
  },
});
sh
npm run build
alisio --plugin ./examples/plugins/custom-mascot/dist/index.js

Provider sign-in (auth) ​

A provider registration may declare auth: ProviderAuthMethod[] so users sign in with an account instead of pasting a key. The host runs the flow, stores the session, renews it and redacts it; the plugin only talks to its own service.

ts
api.providers.register({
  id: "my-service",
  name: "My service",
  fields: [], // no required secret field: the provider is sign-in only
  auth: [
    {
      id: "browser", // ^[a-z0-9_-]{1,40}$, unique within the registration
      label: "My service account",
      kind: "oauth",
      description: "Opens the sign-in page of My service.",
      async start(ctx) {
        const loopback = await ctx.loopback?.({ port: 0, path: "/callback", state }).catch(() => undefined);
        ctx.show({ type: "auth_url", url: authorizationUrl, instructions: "Approve the access." });
        const code = loopback ? (await loopback.wait()).code : await ctx.prompt({ message: "Paste the address you were sent to", secret: true });
        const tokens = await exchange(code, ctx.signal);
        return { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expiresAt };
      },
      async refresh(credential, signal) {
        /* return the renewed credential; throw an error with retryable = true for a temporary failure */
      },
    },
  ],
  create: (request) => makeClient(() => request.oauth?.get()), // request.oauth exists only when `auth` is declared
});
  • start(ctx) resolves with the credential or rejects; ctx.signal aborts on cancel, replacement or expiry (15 minutes). ctx.show accepts auth_url, device_code and progress steps; ctx.prompt asks the user for a value and resolves undefined where nobody can answer (alisio run). ctx.loopback exists only when the host can assume the browser is on the same machine (Alisio Desktop, or a server bound to loopback); otherwise fall back to prompt. A tunnel can break that assumption, so offer the paste fallback too.
  • URLs in steps must be https: (or http: on localhost/127.0.0.1) without credentials, or the flow ends with unsafe_url.
  • Set unofficial: true when the provider has not officially authorized the method: the Web UI shows a fixed risk notice and asks for confirmation. Do not set it for an official method.
  • label, description, instructions and prompt.message are shown as plain text. Keep any provider-specific wording in them: the host adds only generic buttons and states.
  • Without refresh the session is not renewable: when it expires the profile needs a new sign-in. A rejection with retryable === true is a temporary failure; any other is permanent. Renewal metadata is merged over the stored one.
  • request.oauth.get() returns a valid credential (renewing first), and invalidate("sign_in_required") tells the host the provider rejected the session.
  • A plugin is trusted code running in the host process: it can read the stored session. The host reduces the surface (the handle exists only with auth and is bound to the profile) but a plugin is never a sandbox.

Example: custom websearch provider ​

examples/plugins/custom-websearch (alisio-plugin-brave-websearch, plugin id brave-websearch) wraps Brave Search with a user-supplied API key behind the websearch extension point — a copyable reference for "bring your own search engine":

ts
import { definePlugin, type SearchProvider, type SearchResult } from "@alisio/sdk";

export function braveSearchProvider(apiKey: string): SearchProvider {
  return {
    id: "brave-example",
    async search(query, options): Promise<SearchResult[]> {
      const url = new URL("https://api.search.brave.com/res/v1/web/search");
      url.searchParams.set("q", query);
      const res = await fetch(url, {
        headers: { "X-Subscription-Token": apiKey, Accept: "application/json" },
        signal: options?.signal,
      });
      if (!res.ok) throw new Error(`Brave Search request failed: HTTP ${res.status}`);
      const body = await res.json();
      return (body.web?.results ?? []).map((r: { title?: string; url?: string; description?: string }) => ({
        title: r.title ?? "",
        url: r.url ?? "",
        snippet: r.description ?? "",
      }));
    },
  };
}

export default definePlugin({
  id: "brave-websearch",
  version: "0.1.0",
  apiVersion: 1,
  setup(api) {
    const apiKey = process.env.BRAVE_SEARCH_API_KEY;
    if (!apiKey) throw new Error("brave-websearch: set BRAVE_SEARCH_API_KEY");
    api.extensions.register("websearch", braveSearchProvider(apiKey), { priority: 10 });
  },
});
sh
npm run build
export BRAVE_SEARCH_API_KEY=...
alisio --plugin ./examples/plugins/custom-websearch/dist/index.js --allow-external

Loading plugins ​

Plugins are only loaded from explicitly trusted sources. Alisio installs a plugin only when you ask: the alisio install command, the install buttons of the Web UI and the desktop app, or the plugin_install tool after you approve it.

SourceTrust
--plugin <path>, --plugin <package>Explicit trust for that entry and its dependencies
plugins in the configuration fileThe file itself is trusted (global config, --config or --trust-project)
<config home>/plugins/ (files or directories)Global plugins: personal trusted code
<workspace>/.alisio/plugins/Only with --trust-project
sh
alisio --plugin ./my-plugin.js
alisio --plugin alisio-plugin-foo
json
{ "plugins": ["alisio-plugin-foo"] }

--read-only disables executable (external) plugins. Built-in plugins are not affected.

Resolution. An entry that looks like a path (starts with ., is absolute, contains \, ends in a JS/TS extension, or contains / without a leading @) is a file or directory. Anything else is an npm package name, looked up in node_modules from the project directory upwards, then in the global roots: NODE_PATH entries and the global prefix of the running Node/npm.

The alisio-plugin keyword is required. A package without "keywords": ["alisio-plugin"] in its package.json is rejected, so a typo cannot load an unrelated package. The entry is taken from exports["."] (import, node or default conditions), then main, then ./index.js.

Plugin icon (optional). The Web UI and the desktop app show an icon next to each plugin. A plugin in the catalog uses the catalog's icon. Any other npm plugin can ship its own by declaring a path relative to the package in package.json, and listing the file in files: "alisio": { "icon": "assets/icon.svg" }. The file must be an SVG, PNG, WebP or JPEG of at most 256 KB and must stay inside the package (a path that leaves it, an absolute path or a symlink out of the package is ignored); the server serves it as an image only, never as a page. Without an icon (or if it fails to load) the default plugin icon is shown. Built-in, project and file plugins have the default unless they are npm packages that declare one.

A directory given as a path must contain an alisio-plugin.json manifest declaring its entry (a package may ship one too); the entry must stay inside the directory:

json
{ "apiVersion": 1, "entry": "./index.js" }

Check what a plugin registers with:

sh
alisio plugins list
alisio plugins doctor --plugin ./my-plugin.js

Installing plugins from npm ​

The CLI can install an npm package into Alisio's GLOBAL plugins directory (<config home>/plugins, for example ~/.config/alisio/plugins) and record its npm NAME in the global configuration's plugins array — the same entry you can write by hand today:

sh
alisio install npm:plugin-openrouter          # latest version
alisio install npm:@scope/plugin-x@1.2.3      # a pinned version
alisio install plugin-openrouter              # bare names are the same as npm:
alisio install npm:plugin-openrouter --update # refresh an installed plugin to @latest
alisio install --update                       # refresh ALL installed plugins in one npm call

The spec is validated before any network operation: npm:<package>[@<version>], or a bare package name; only letters, digits, ., _, - are allowed, plus / for scoped names and @ for a version. Unknown prefixes (git:, file:, registry: URLs, ...) are rejected with a clear error. A failed install never reruns npm silently and a failed npm output is sanitized (no tokens/secrets); the error names the package and the exact retry command.

Where it lands. The package is installed with npm install --prefix <config home>/plugins into <config home>/plugins/node_modules/<package>, and its NAME (never the resolved filesystem path) is added to the plugins array of <config home>/config.json (atomic write, unrelated fields preserved, no duplicates). alisio plugins list shows installed packages alongside file/plugin directories.

AspectBehavior
Global scopeOne install serves every project the user runs Alisio from
LoadingThe plugin loads as in-process, executable code wherever global plugins load; it must declare the alisio-plugin keyword to be loadable at all
Project trustA project's OWN configuration and plugins still load only in trusted projects (--trust-project or the one-time trust prompt)
--read-onlyInstalling is refused and the plugin never loads (executable plugins are disabled)
ReinstallIf the package is already installed, the command reports it and suggests --update instead of rerunning npm
Scriptsnpm install may run the package's lifecycle scripts with your privileges — Alisio warns and requires confirmation on an interactive terminal
Headless / --jsonNever prompts: without an explicit --yes (or --trust-plugin) it fails with an actionable error before running npm
ErrorsSanitized npm output, package name and exact retry command in the final message
--update without a specRefreshes every plugin installed in the global plugins directory to @latest in a single npm install --prefix call; same confirmation, --yes and --read-only rules; with no installed plugins it says so and exits 0

The install is a global, per-user action: it grants nothing to any project. Loading follows the existing executable-plugin policy — the one-time trust prompt (or --trust-project) is what lets a project load its own configuration and plugins, and --read-only disables executable plugins entirely.

Peer dependency conflicts (ERESOLVE) ​

Older installed plugins can pin an older @alisio/sdk through their peer range, so npm refuses to install a newer plugin that needs a newer SDK (ERESOLVE ... Conflicting peer dependency). Alisio then explains the cause, lists the installed plugins involved and does not show the generic retry line, because the retry would fail the same way. Update all installed plugins together (updating them one by one keeps hitting the same conflict), then run the original install again:

sh
alisio install --update
alisio install npm:@alisio/plugin-laya

Alisio never suggests --force or --legacy-peer-deps for this.

In the web UI (Settings → Plugins → Installed) this is automatic. Update all runs one combined npm install of every outdated catalog plugin. Update on a single plugin first tries that plugin alone; if npm reports ERESOLVE, Alisio retries exactly once as one install of that plugin plus the other installed catalog plugins that have a newer compatible release (local .tgz plugins and up-to-date ones are left alone) and lists the plugins that were updated along with it. If the conflict remains, nothing else is attempted and the error names the plugins involved.

The agent can install a plugin for you ​

In the TUI the model can install a plugin on request through the host-owned plugin_install tool: it validates the spec, runs the same install routine as alisio install, and answers with the package name, installed version, config entry and path, plus the trust remark. The tool uses the process effect, so it goes through the ordinary permission gate — the approval prompt in the TUI, or --allow-process headless — and is never available under --read-only. See Tools & permissions.

text
You:   install the plugin plugin-openrouter
Agent: (calls plugin_install with spec "npm:plugin-openrouter", you approve it)
Agent: Installed plugin "plugin-openrouter" v1.2.3 at
       ~/.config/alisio/plugins/node_modules/plugin-openrouter and added it to the
       "plugins" array of ~/.config/alisio/config.json. The plugin loads as trusted
       personal code; --read-only keeps it from loading. Run `alisio plugins list`
       to inspect.

Installing from the web and from a file ​

The Web UI and the desktop app install, update and remove plugins in Settings → Plugins, with the same security warning as the CLI. See Plugin catalog, install and updates for the screens.

  • From the catalog. A catalog plugin installs in one click, at the catalog's version (the registry's latest when npm does not serve that version yet). Only packages listed in the catalog can be installed this way, and --read-only refuses it.
  • Update in place. Installing a package that is already installed replaces it (update, reinstall or downgrade) and keeps a single entry in the plugins array. A failed update restores the previous version.
  • From a file (private plugins). A plugin that is never published is distributed as a tarball. Make it from the plugin's folder (or let the plugin creator build and package it for you):
sh
pnpm pack
npm pack

Then choose the .tgz in Install from file. Alisio reads only package/package.json (a built-in gzip and tar reader: no tar binary is needed) and checks that it is an Alisio plugin: the alisio-plugin keyword, a valid npm name and a semver version, and an entry point (exports or main). The file may be up to 50 MB and package.json up to 1 MB; an entry whose path could leave the package folder rejects the file. An @alisio/sdk peer range that does not match the installed SDK is a warning, and declared preinstall, install or postinstall scripts are called out.

  • Where a file plugin lives. The tarball is copied to <config home>/plugins/local-packages/<name>-<version>.tgz and npm installs from that copy, so the file:local-packages/... dependency in <config home>/plugins/package.json never points at a file that can move. Updating replaces the stored copy; uninstalling removes it; a plugin that was installed from a path outside local-packages is migrated on its next update. The upload itself is staged in <state home>/tmp/plugin-uploads/ and deleted after the install, a failure or a cancel, and leftovers older than one hour are swept. A failed install is rolled back: the stored copy, a partial package and the configuration entry are restored.
  • Restart after an update. Node keeps the code of a plugin it already loaded, so an update of a loaded plugin only runs after a restart. The result says so; the desktop app can restart itself and alisio serve must be restarted by you.
  • npm. The CLI uses the npm on your PATH. The desktop app bundles npm (ALISIO_NPM_CLI points at it), so it installs plugins without a system Node.js. Without either, the error reads: npm was not found: install Node.js, or use the desktop app, which bundles it.
  • Uninstall. DELETE /api/plugins/package/:name (the Uninstall button) removes an npm plugin from the global plugins directory and its name from the configuration; built-in plugins and project or local plugins cannot be uninstalled from the web.

Plugin creator ​

The plugin creator of the Web UI builds plugins from a plain-language request. It is a tool for non-technical users; this section is the reference for what it generates and exposes.

Projects and the creator agent ​

Projects live under <config home>/plugin-creator/projects/<id>/ (<id> is 12 hex characters); the metadata record, the last package and the bundled skill are kept next to them in records/, builds/ and skills/. Each project is also the workspace of its creator session, which runs the plugin-creator agent with the ask permission preset and the bundled alisio-plugin-creator skill. The agent has two narrow tools instead of a shell: plugin_project (internal: static validation, stats and progress steps) and plugin_project_run (process, so it goes through the normal approval: the project's node --test suite or the full build, with a scrubbed environment and a time limit). Approvals of a creator session show in its page only while it is open.

Generated plugin layout ​

text
package.json          name, version, type: module, alisio.pluginId / icon / cover
src/index.js          plain ESM + JSDoc, default export is the Plugin object
test/harness.js       a fake PluginAPI with a real in-memory node:sqlite database
test/*.test.js        node:test suites (npm test = node --test test/)
assets/icon.svg       64x64 placeholder icon
assets/cover.svg      1280x720 placeholder cover with the plugin name
README.md

The contract: plain ESM JavaScript, no build step, no runtime dependencies, @alisio/sdk as an optional peer only (nothing imports it at runtime), the alisio-plugin keyword, a local. plugin id, and the version only in package.json (src/index.js reads it). Packages are named alisio-plugin-local-<slug>-<id> and are private (UNLICENSED) until you publish them.

Icon and cover ​

package.json points alisio.icon and alisio.cover at files inside the package (and inside files). The scaffold writes an SVG icon and a cover with the XML-escaped display name, shortened past 40 characters. The model may replace them with an optimized SVG (or a PNG, WebP or JPEG); the validation sniffs the content, caps the size (icon 256 KB, cover 512 KB) and rejects unsafe SVG: scripts, event handler attributes, foreignObject, embedded images, outside href or url(...) targets, @import and XML entities.

Validation and compatibility ​

A build validates statically, runs the tests and a probe that imports the plugin, then packs a deterministic .tgz. Findings have stable codes (for example sdk_incompatible, api_member_unknown, forbidden_import, template_example, svg_unsafe, tests_missing). The peerDependencies["@alisio/sdk"] range must accept the installed SDK (new projects use the caret range of its minor, such as ^0.11.0), and the PluginAPI members the sources use are checked against that version. Limits: 500 files, 5 MB per project, 1 MB per file, 8000 characters per request, 120 s per test run. Building runs the project's code with the user's privileges; it is not a sandbox, and node --test is not one either.

HTTP routes ​

All routes are under /api/plugin-creator/projects and every mutation is refused with --read-only.

Method and pathPurpose
GET / and POST /List projects; create one from request, optional displayName and model
GET /:id and GET /:id/statusA project with its status; install state and whether an update or package is ready
DELETE /:idDelete (uninstall: true also uninstalls the package)
POST /:id/sessionOpen or resume the creator session, optionally with a new request and model
GET /:id/files and GET /:id/files/*File list and a text file (read-only, bounded)
GET /:id/iconThe project's icon
POST /:id/buildValidate, test and package; a failure is 422 with the build response
GET /:id/packageDownload the .tgz (plugin_not_built when it is stale)
POST /:id/installRebuild, then install through the local-file install job; needs confirmed: true

TypeScript plugins ​

  • Plugins published to npm must ship JavaScript.
  • Local .ts plugins loaded with --plugin work on Bun (and the standalone binary) and on Node.js >= 22.18 through type stripping, which only supports erasable syntax (no enum, namespace or parameter properties).
  • On older Node.js versions Alisio fails with a clear error asking for Bun, Node.js >= 22.18 or a JavaScript build.

Example plugin package ​

text
alisio-plugin-hello/
├── package.json
├── tsconfig.json
└── src/
    └── index.ts

package.json:

json
{
  "name": "alisio-plugin-hello",
  "version": "0.1.0",
  "description": "Example Alisio plugin",
  "keywords": ["alisio-plugin"],
  "license": "MIT",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "prepublishOnly": "npm run build"
  },
  "peerDependencies": {
    "@alisio/sdk": "^0.2.0"
  },
  "devDependencies": {
    "@alisio/sdk": "^0.2.0",
    "typescript": "^5.9.0"
  }
}

Use the latest published @alisio/sdk version when you scaffold your own plugin — check npm view @alisio/sdk version for the current one. Plugins should declare @alisio/sdk as a peer dependency with ^0.2.0: while Alisio is 0.x, a caret range accepts only its own minor, because a new minor may include breaking changes, so you opt in to each minor explicitly. A plugin that must support both lines can declare "^0.1.0 || ^0.2.0"; plugins published for 0.1 (such as @alisio/plugin-deepseek) declare ^0.1.0 until they are updated.

tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true
  },
  "include": ["src"]
}

src/index.ts:

ts
import { definePlugin, textResult } from "@alisio/sdk";

export default definePlugin({
  id: "acme.hello",
  version: "0.1.0",
  apiVersion: 1,
  setup(api) {
    let greetings = 0;

    api.tools.register({
      name: "hello",
      description: "Greets a person by name.",
      effect: "read",
      inputSchema: {
        type: "object",
        properties: { name: { type: "string" } },
        required: ["name"],
        additionalProperties: false,
      },
      async execute(input) {
        greetings += 1;
        api.ui.status("greetings", `hello ${greetings}`);
        return textResult(`Hello, ${String(input.name)}!`);
      },
    });

    api.commands.register("greetings", async () => `Greetings so far: ${greetings}`, {
      description: "Show how many greetings were sent",
    });
  },
});

Build and try it:

sh
npm install
npm run build
alisio --plugin ./dist/index.js         # local build, as an explicit path
npm publish                             # then: npm i -g alisio-plugin-hello
alisio --plugin alisio-plugin-hello     # or add it to "plugins" in the configuration

In the TUI the command is available as /acme.hello:greetings, and the model sees the tool under its prefixed name.

Released under the MIT License.