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.
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
| Member | Description |
|---|---|
id | Unique ID matching ^[a-z0-9][a-z0-9.-]{0,63}$, for example acme.hello |
version | SemVer, for example 0.1.0 or 0.1.0-beta.1 |
apiVersion | Always 1 |
name, description | Optional human-friendly catalog text used by /plugins |
categories | Optional 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 |
extensions | Optional; 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.
| Category | Meaning | Used by built-ins |
|---|---|---|
model-provider | Registers model providers | openai-compatible |
methodology-harness | Bundles a development-methodology workflow | — |
memory | Persistent memory, recollection and session summaries | memory |
subagents | Delegation, child sessions and agent management | subagents |
search | Web or vector search providers | — |
tools | General-purpose tool collections | — |
security | Audit, sandbox or permission tooling | — |
analytics | Usage/metrics instrumentation (session stats, cost tracking) | — |
mcp | MCP-server management or bundling helpers | — |
storage | Durable storage backends beyond the default SQLite state | — |
ui | TUI presentation providers (startup screens, mascots, panels) | — |
decisions | Decision 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.
| Member | Description |
|---|---|
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 |
options | Read-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
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.
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" }] }],
},
},
],
};| Kind | Fields | TUI rendering |
|---|---|---|
table | columns, rows, caption? | Aligned columns |
key-value | entries, caption? | Two columns |
tree | nodes (label, children?, meta?) | Branch glyphs |
code | code, lang?, caption? | Highlighted code |
markdown | text | Markdown |
diff | patch? or before?/after?, path?, lang?, caption? | Unified patch as diff code; otherwise labeled before/after |
terminal | output, command?, cwd?, exitCode?, durationMs?, truncated? | Output without ANSI plus exit <code> · <time> |
mermaid | source, title? | Source as mermaid code |
math | latex, display? | Literal LaTeX |
json | value, collapsedDepth?, caption? | Indented JSON, truncated |
test-results | suites (name, file?, cases), framework?, durationMs? | Table of suite, case, status and time, plus failures |
progress | steps (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
| Item | External plugin | Built-in plugin |
|---|---|---|
| Tool name | p_<hash>_<name> (10 hex characters of the SHA-256 of the plugin ID), avoiding collisions and meeting provider name limits | Unprefixed |
| 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 palette | Unprefixed (for example /memory) |
internal effect | Downgraded to external | Allowed |
Compaction hooks
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)receivessessionId,reason(manualorauto), themessagesabout to be replaced,focusandsignal. It may returninstructions(appended to the summarizer instructions) andoutputFields(extra top-level JSON fields, name → description, requested in the same summarizer call).afterCompact(result)also receivesmodel,replaced,structured,checkpoint,checkpointTextandextracted(this plugin's fields, unvalidated: validate them yourself). It may returninjectContext(text appended after the checkpoint; keep it budgeted) andreport(summaryis displayed verbatim).
Session hooks and model completions
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
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).
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)) }),
});
},
});idis lowercase letters, digits and dashes (up to 40 characters) and unique within the plugin;descriptionis required.paramsis 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 beforehandler(params, { sessionId, workspace, signal })runs.ViewParamsErrorturns a parameter the schema cannot express into a400.- 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 a404. 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;signalaborts). Any other failure is a genericview_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.
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.
idfollows the view rules (^[a-z][a-z0-9-]{0,39}$) and is unique per plugin; a duplicate throws.labelis 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/frameinto a sandboxediframe(allow-scripts allow-downloads allow-modals allow-popups, noallow-same-origin, so it has no cookies or access to the host DOM). Every inline<script>must carrynonce="__ALISIO_NONCE__"; the server replaces the marker with a fresh nonce per request. The CSP isdefault-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
MessagePortto the frame with{ type: "alisio.app.connect", locale, visible }(accept the first channel that comes fromwindow.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 arenot_found,invalid_input,not_permitted,timeout,failed,too_large,too_many_requests,unauthorizedandnetwork; 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,writeorprocess) and aninputSchema(an object schema;$ref,$idand$asyncare rejected). Over HTTP an action isPOST /api/sessions/:sid/apps/:plugin/:app/actions/:actionwith{ 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 (awriteorprocessaction 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 withapp_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 anderror.details.effectin 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.signalaborts). Long work should return a job quickly instead of holding the request. Failures are a genericapp_action_failed(502); inputs and results are never logged. - Models and agents. Handlers reuse
api.models,api.model.completeandapi.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.
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.decisionsregisters a decision provider and lets a plugin tool ask for decisions. The user activates a provider withdecisions.providerin 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 ascontext.decisions?.tryDecide(...), bound to the current run. The optionalapi.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 savesdecisions.providerglobally (see asking for activation).api.pathsgivesstate(<state root>/plugins/<id>),config(<config home>/plugins/<id>) andcache(<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 orALISIO_*paths yourself.api.optionsholds the JSON you or the user put inpluginOverrides[id].optionsof the global configuration (seepluginOverrides). It is global only, at most 8 KB, and a snapshot taken atsetup: changing it needs a restart. Do not keep secrets there.
Semantic search providers and consumers
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.
// 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.embeddingandsemantic.storein the global configuration pick the active providers (seesemantic); a project config cannot. A provider may offer a user-initiated setup step that callsapi.semantic?.activate?.({ embedding, store }): the host asks once, saves the ids globally, never overwrites another configured provider, and resolvesneeds_confirmationwhen nobody can be asked (headless). - Collections are namespaced per plugin. The host stores
<pluginId>/<collection>, andcollections()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
modelIdanddimensionsit was built with. After the user changes the model,indexandsearchreject withmodel_mismatchordimension_mismatch: drop the collection and index again from your own source text, because only you still have it.trySearchresolvesnullin that case so you can fall back. - Limits are checked before any provider runs and fail with
invalid_request: 1..256 texts perembed(at most 32768 characters each), up to 1000 documents perindex,topK1..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
AbortSignaland a time limit (semantic.embedTimeoutMs,semantic.queryTimeoutMs); a timeout istimeout, an abort by the caller is propagated as the abort, and any provider failure is aprovider_errorwhose message contains no paths and no user text. minScoredepends 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'shealth()(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.
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.
| Member | Description |
|---|---|
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).
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 withpluginHooks.sessionEndTimeoutMs(default 10 000 ms). - Each hook receives an
AbortSignalthat is aborted when the timeout expires. - A failure or timeout is recorded as a
plugin_hook_failedevent 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,SIGTERMandSIGHUP(terminal UI,alisio serveandalisio 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 hungdispose()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.
| Point | Provider type | Default |
|---|---|---|
mascot | MascotProvider: { id, render(ctx: MascotContext) } returning a string or string[] | DefaultAlisioMascot (alisio.default) |
startup-screen | StartupScreenProvider: { id, render(ctx: StartupContext): string[] } | DefaultStartupScreen (alisio.default) |
websearch | SearchProvider: { 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:
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.priorityis a finite number, default0; a provider needs anid(that is the one thing every point shares — a renderable point likemascotadditionally needs a workingrender, checked when the host actually calls it, the same way a throwingrenderalready falls back today).- The declarative
Plugin.extensionsfield is sugar forapi.extensions.register(point, provider)at priority0, applied beforesetupruns.
Resolution
For each point the host picks exactly one provider, deterministically:
- highest
priority; - then plugin
id, lexicographically (plugins are identified byid, not by package name); - 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:
alisio plugins doctor --plugin ./dist/index.jsTyped contexts
| Type | Fields |
|---|---|
TerminalCapabilities | color (ANSI SGR allowed), unicode (non-ASCII allowed), columns, interactive |
MascotContext | terminal, version |
PluginMetadata | id, version, builtin |
StartupFact | label, value |
StartupContext | version, 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.unicodeandterminal.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_faileddiagnostic 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
coloris true; other escape sequences and control characters are removed; tabs become spaces; non-ASCII characters become?whenunicodeis false. Every line is clamped tocolumns. - The default screen is built from reusable section functions exported by
@alisio/core:titleSection,welcomeSection,infoSection,pluginsSection,tipsSection,mascotSection,composeSideBySide,shortenPathandstartupTips(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.
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);
},
});npm run build
alisio --plugin ./examples/plugins/custom-mascot/dist/index.jsProvider 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.
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.signalaborts on cancel, replacement or expiry (15 minutes).ctx.showacceptsauth_url,device_codeandprogresssteps;ctx.promptasks the user for a value and resolvesundefinedwhere nobody can answer (alisio run).ctx.loopbackexists only when the host can assume the browser is on the same machine (Alisio Desktop, or a server bound to loopback); otherwise fall back toprompt. A tunnel can break that assumption, so offer the paste fallback too.- URLs in steps must be
https:(orhttp:onlocalhost/127.0.0.1) without credentials, or the flow ends withunsafe_url. - Set
unofficial: truewhen 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,instructionsandprompt.messageare shown as plain text. Keep any provider-specific wording in them: the host adds only generic buttons and states.- Without
refreshthe session is not renewable: when it expires the profile needs a new sign-in. A rejection withretryable === trueis a temporary failure; any other is permanent. Renewal metadata is merged over the stored one. request.oauth.get()returns a valid credential (renewing first), andinvalidate("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
authand 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":
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 });
},
});npm run build
export BRAVE_SEARCH_API_KEY=...
alisio --plugin ./examples/plugins/custom-websearch/dist/index.js --allow-externalLoading 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.
| Source | Trust |
|---|---|
--plugin <path>, --plugin <package> | Explicit trust for that entry and its dependencies |
plugins in the configuration file | The 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 |
alisio --plugin ./my-plugin.js
alisio --plugin alisio-plugin-foo{ "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:
{ "apiVersion": 1, "entry": "./index.js" }Check what a plugin registers with:
alisio plugins list
alisio plugins doctor --plugin ./my-plugin.jsInstalling 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:
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 callThe 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.
| Aspect | Behavior |
|---|---|
| Global scope | One install serves every project the user runs Alisio from |
| Loading | The plugin loads as in-process, executable code wherever global plugins load; it must declare the alisio-plugin keyword to be loadable at all |
| Project trust | A project's OWN configuration and plugins still load only in trusted projects (--trust-project or the one-time trust prompt) |
--read-only | Installing is refused and the plugin never loads (executable plugins are disabled) |
| Reinstall | If the package is already installed, the command reports it and suggests --update instead of rerunning npm |
| Scripts | npm install may run the package's lifecycle scripts with your privileges — Alisio warns and requires confirmation on an interactive terminal |
Headless / --json | Never prompts: without an explicit --yes (or --trust-plugin) it fails with an actionable error before running npm |
| Errors | Sanitized npm output, package name and exact retry command in the final message |
--update without a spec | Refreshes 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:
alisio install --update
alisio install npm:@alisio/plugin-layaAlisio 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.
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-onlyrefuses it. - Update in place. Installing a package that is already installed replaces it (update, reinstall or downgrade) and keeps a single entry in the
pluginsarray. 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):
pnpm pack
npm packThen 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>.tgzand npm installs from that copy, so thefile:local-packages/...dependency in<config home>/plugins/package.jsonnever points at a file that can move. Updating replaces the stored copy; uninstalling removes it; a plugin that was installed from a path outsidelocal-packagesis 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 servemust be restarted by you. - npm. The CLI uses the
npmon yourPATH. The desktop app bundles npm (ALISIO_NPM_CLIpoints 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
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.mdThe 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 path | Purpose |
|---|---|
GET / and POST / | List projects; create one from request, optional displayName and model |
GET /:id and GET /:id/status | A project with its status; install state and whether an update or package is ready |
DELETE /:id | Delete (uninstall: true also uninstalls the package) |
POST /:id/session | Open 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/icon | The project's icon |
POST /:id/build | Validate, test and package; a failure is 422 with the build response |
GET /:id/package | Download the .tgz (plugin_not_built when it is stale) |
POST /:id/install | Rebuild, then install through the local-file install job; needs confirmed: true |
TypeScript plugins
- Plugins published to npm must ship JavaScript.
- Local
.tsplugins loaded with--pluginwork on Bun (and the standalone binary) and on Node.js >= 22.18 through type stripping, which only supports erasable syntax (noenum,namespaceor 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
alisio-plugin-hello/
├── package.json
├── tsconfig.json
└── src/
└── index.tspackage.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:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"]
}src/index.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:
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 configurationIn the TUI the command is available as /acme.hello:greetings, and the model sees the tool under its prefixed name.
