Extensions
Extensions are TypeScript modules that extend Elyra's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, build custom UI, and more.
Overview
elyra.registerTool().
ctx.ui (select,
confirm, input, notify).
ctx.ui.custom().
/mycommand via
elyra.registerCommand().
elyra.appendEntry().
Example use cases: permission gates, git checkpointing, path protection, custom compaction, conversation summaries, interactive tools, stateful tools, external integrations.
Quick Start
Create
~/.elyra/agent/extensions/my-extension.ts:
import type { ExtensionAPI } from "@elyracode/coding-agent";
import { Type } from "typebox";
export default function (elyra: ExtensionAPI) {
// React to events
elyra.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Extension loaded!", "info");
});
elyra.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
// Register a custom tool
elyra.registerTool({
name: "greet",
label: "Greet",
description: "Greet someone by name",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: {},
};
},
});
// Register a command
elyra.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => {
ctx.ui.notify(`Hello ${args || "world"}!`, "info");
},
});
}
Test with the --extension (or -e)
flag:
elyra -e ./my-extension.ts
Extension Locations
Extensions are auto-discovered from these locations and can
be hot-reloaded with /reload:
| Location | Scope |
|---|---|
~/.elyra/agent/extensions/*.ts |
Global (all projects) |
~/.elyra/agent/extensions/*/index.ts
|
Global (subdirectory) |
.elyra/extensions/*.ts |
Project-local |
.elyra/extensions/*/index.ts |
Project-local (subdirectory) |
Additional paths via settings.json:
{
"packages": [
"npm:@foo/bar@1.0.0",
"git:github.com/user/repo@v1"
],
"extensions": [
"/path/to/local/extension.ts",
"/path/to/local/extension/dir"
]
}
Available Imports
| Package | Purpose |
|---|---|
@elyracode/coding-agent |
Extension types (ExtensionAPI,
ExtensionContext, events)
|
typebox |
Schema definitions for tool parameters |
@elyracode/ai |
AI utilities (StringEnum for
Google-compatible enums)
|
@elyracode/tui |
TUI components for custom rendering |
npm dependencies work too. Add a
package.json next to your extension, run
npm install, and imports from
node_modules/ resolve automatically. Node.js
built-ins (node:fs, node:path,
etc.) are also available.
Writing an Extension
An extension exports a default factory function that
receives ExtensionAPI. The factory can be
synchronous or asynchronous. Extensions are loaded via
jiti, so
TypeScript works without compilation.
Sync factory
import type { ExtensionAPI } from "@elyracode/coding-agent";
export default function (elyra: ExtensionAPI) {
elyra.on("event_name", async (event, ctx) => {
// ...
});
}
Async factory
Use an async factory for one-time startup work such as fetching remote configuration or dynamically discovering models:
import type { ExtensionAPI } from "@elyracode/coding-agent";
export default async function (elyra: ExtensionAPI) {
const response = await fetch("http://localhost:1234/v1/models");
const payload = await response.json();
elyra.registerProvider("local-openai", {
baseUrl: "http://localhost:1234/v1",
apiKey: "LOCAL_OPENAI_API_KEY",
api: "openai-completions",
models: payload.data.map((model) => ({
id: model.id,
name: model.name ?? model.id,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: model.context_window ?? 128000,
maxTokens: model.max_tokens ?? 4096,
})),
});
}
Extension Styles
Single file — simplest, for small extensions:
~/.elyra/agent/extensions/
my-extension.ts
Directory with index.ts — for multi-file extensions:
~/.elyra/agent/extensions/
my-extension/
index.ts # Entry point
tools.ts
utils.ts
Package with dependencies — for extensions that need npm packages:
~/.elyra/agent/extensions/
my-extension/
package.json
node_modules/
src/
index.ts
// package.json
{
"name": "my-extension",
"dependencies": {
"zod": "^3.0.0",
"chalk": "^5.0.0"
},
"elyra": {
"extensions": ["./src/index.ts"]
}
}
Events
Extensions subscribe to events via
elyra.on(event, handler). Handlers receive the
event data and an ExtensionContext.
Lifecycle Overview
elyra starts
|
+-- session_start { reason: "startup" }
+-- resources_discover { reason: "startup" }
|
user sends prompt
|
+-- input (can intercept, transform, or handle)
+-- before_agent_start (can inject message, modify system prompt)
+-- agent_start
+-- message_start / message_update / message_end
|
| +-- turn_start
| +-- context (can modify messages)
| +-- before_provider_request
| +-- after_provider_response
| |
| | LLM responds, may call tools:
| | +-- tool_execution_start
| | +-- tool_call (can block)
| | +-- tool_result (can modify)
| | +-- tool_execution_end
| |
| +-- turn_end
|
+-- agent_end
/new or /resume
+-- session_before_switch (can cancel)
+-- session_shutdown
+-- session_start { reason: "new" | "resume" }
/compact or auto-compaction
+-- session_before_compact (can cancel)
+-- session_compact
exit
+-- session_shutdown
Key Events
| Event | Description |
|---|---|
session_start |
Session started or restored. Reason:
startup, new,
resume, fork.
|
session_shutdown |
Session is ending (exit, switch, fork). |
before_agent_start |
Can inject messages or modify the system prompt before the agent starts. |
agent_start /
agent_end
|
Agent turn lifecycle. |
turn_start / turn_end
|
Individual LLM call turns within an agent run. |
message_start /
message_update /
message_end
|
Streaming message events with usage/cost data. |
tool_call |
Tool is about to execute. Return
{ block: true, reason } to prevent
it.
|
tool_result |
Tool finished. Return modified content/details to alter the result. |
context |
Messages about to be sent to provider. Can filter or modify. |
input |
User input received. Can intercept, transform, or handle. |
model_select |
Model was changed. |
thinking_level_select |
Thinking level was changed. |
ExtensionContext
All handlers receive ctx: ExtensionContext with
these properties:
| Property | Description |
|---|---|
ctx.ui |
UI methods: select(),
confirm(), input(),
notify(), setStatus(),
setWidget(), custom().
|
ctx.hasUI |
true in interactive and RPC mode,
false in print/JSON mode.
|
ctx.cwd |
Current working directory. |
ctx.sessionManager |
Read-only access to session state, entries, and branch. |
ctx.model |
Current model info. |
ctx.signal |
Agent abort signal (defined during active turns). |
ctx.isIdle() |
Check if agent is idle. |
ctx.abort() |
Abort the current operation. |
ctx.shutdown() |
Request graceful shutdown. |
ctx.compact() |
Trigger compaction. |
ctx.getSystemPrompt() |
Returns the current system prompt string. |
ctx.getContextUsage() |
Returns current context token usage. |
Command handlers receive
ExtensionCommandContext, which extends
ExtensionContext with session control methods:
waitForIdle(), newSession(),
fork(), switchSession(),
navigateTree(), reload().
elyra.on(event, handler)
Subscribe to events. See Events for event types and return values.
elyra.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
elyra.registerTool(definition)
Register a custom tool callable by the LLM. Works during extension load and after startup.
import { Type } from "typebox";
import { StringEnum } from "@elyracode/ai";
elyra.registerTool({
name: "my_tool",
label: "My Tool",
description: "What this tool does",
promptSnippet: "Summarize or transform text",
promptGuidelines: [
"Use my_tool when the user asks to summarize text."
],
parameters: Type.Object({
action: StringEnum(["list", "add"] as const),
text: Type.Optional(Type.String()),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
return {
content: [{ type: "text", text: "Done" }],
details: { result: "..." },
};
},
renderCall(args, theme, context) { /* ... */ },
renderResult(result, options, theme, context) { /* ... */ },
});
Use promptSnippet for a one-line entry in the
Available tools system prompt section. Use
promptGuidelines to add tool-specific bullets
to the Guidelines section.
elyra.registerCommand(name, options)
Register a slash command. If multiple extensions register the same name, Elyra assigns numeric suffixes.
elyra.registerCommand("stats", {
description: "Show session statistics",
handler: async (args, ctx) => {
const count = ctx.sessionManager.getEntries().length;
ctx.ui.notify(`${count} entries`, "info");
},
});
// With argument auto-completion
elyra.registerCommand("deploy", {
description: "Deploy to an environment",
getArgumentCompletions: (prefix) => {
const envs = ["dev", "staging", "prod"];
const items = envs.map((e) => ({ value: e, label: e }));
const filtered = items.filter((i) => i.value.startsWith(prefix));
return filtered.length > 0 ? filtered : null;
},
handler: async (args, ctx) => {
ctx.ui.notify(`Deploying: ${args}`, "info");
},
});
elyra.sendMessage(message, options?)
Inject a custom message into the session.
elyra.sendMessage({
customType: "my-extension",
content: "Message text",
display: true,
details: { ... },
}, {
triggerTurn: true,
deliverAs: "steer", // "steer" | "followUp" | "nextTurn"
});
elyra.sendUserMessage(content, options?)
Send a user message as if typed by the user. Always triggers a turn when idle.
elyra.sendUserMessage("What is 2+2?");
// During streaming, must specify delivery mode
elyra.sendUserMessage("Focus on error handling", { deliverAs: "steer" });
elyra.registerProvider(name, config)
Register a custom provider with models, base URL, and API configuration.
elyra.registerProvider("my-provider", {
name: "My Provider",
baseUrl: "http://localhost:1234/v1",
apiKey: "MY_API_KEY",
api: "openai-completions",
models: [
{
id: "my-model",
name: "My Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
},
],
});
Providers can also include OAuth configuration for
subscription-based auth with login(),
refreshToken(), and
getApiKey() methods.
Other Methods
| Method | Description |
|---|---|
elyra.appendEntry(type, data?) |
Persist extension state (not in LLM context). |
elyra.setSessionName(name) |
Set session display name. |
elyra.getSessionName() |
Get current session name. |
elyra.setLabel(entryId, label) |
Set/clear a label on a session entry. |
elyra.registerShortcut(key, opts)
|
Register a keyboard shortcut. |
elyra.registerFlag(name, opts) |
Register a custom flag. |
elyra.exec(cmd, args, opts?) |
Execute a shell command. |
elyra.getActiveTools() |
Get currently active tool names. |
elyra.getAllTools() |
Get all registered tools. |
elyra.setActiveTools(names) |
Enable/disable tools at runtime. |
elyra.setModel(model) |
Switch model programmatically. |
elyra.getThinkingLevel() |
Get current thinking level. |
elyra.setThinkingLevel(level) |
Set thinking level. |
elyra.getCommands() |
Get all available slash commands. |
elyra.registerMessageRenderer(type,
fn)
|
Custom rendering for message types. |
elyra.unregisterProvider(name) |
Remove a registered provider. |
Custom Tools
Tools are defined with a name, description, typed parameters
(via TypeBox), and an execute function.
import { Type } from "typebox";
import { StringEnum } from "@elyracode/ai";
elyra.registerTool({
name: "my_tool",
label: "My Tool",
description: "What this tool does (shown to LLM)",
parameters: Type.Object({
action: StringEnum(["list", "add"] as const),
text: Type.Optional(Type.String()),
}),
prepareArguments(args) {
// Optional: normalize legacy arguments before validation
return args;
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
// Stream progress
onUpdate?.({
content: [{ type: "text", text: "Working..." }],
details: { progress: 50 },
});
return {
content: [{ type: "text", text: "Done" }], // Sent to LLM
details: { data: "..." }, // For rendering and state
terminate: true, // Optional: stop after this batch
};
},
});
withFileMutationQueue() so it participates
in the same per-file queue as built-in edit and
write. This prevents parallel tool calls from
overwriting each other's changes.
State Management
Extensions with state should store it in tool result
details for proper branching support, then
reconstruct on session_start:
export default function (elyra: ExtensionAPI) {
let items: string[] = [];
// Reconstruct state from session
elyra.on("session_start", async (_event, ctx) => {
items = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "toolResult") {
if (entry.message.toolName === "my_tool") {
items = entry.message.details?.items ?? [];
}
}
}
});
elyra.registerTool({
name: "my_tool",
// ...
async execute(toolCallId, params, signal, onUpdate, ctx) {
items.push("new item");
return {
content: [{ type: "text", text: "Added" }],
details: { items: [...items] }, // Store for reconstruction
};
},
});
}
Custom UI
Extensions can interact with users through
ctx.ui methods:
Dialogs
// Selection
const choice = await ctx.ui.select("Pick one", ["Option A", "Option B"]);
// Confirmation
const ok = await ctx.ui.confirm("Title", "Are you sure?");
// Text input
const name = await ctx.ui.input("Enter name");
// Multi-line editor
const text = await ctx.ui.editor("Write a description");
Status and Widgets
// Footer status text
ctx.ui.setStatus("my-ext", "Processing...");
// Widget lines above the editor
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]);
// Notifications
ctx.ui.notify("Done!", "success"); // "info" | "success" | "error" | "warn"
Custom Components
For complex interactions, use
ctx.ui.custom() to render a full TUI component
with keyboard input. Overlay mode is also available for
non-blocking UI.
Autocomplete Providers
Extend the editor with custom autocomplete via
ctx.ui.setAutocompleteProvider().
Message Rendering
Custom message types registered via
elyra.registerMessageRenderer() control how
messages appear in the TUI.