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

Custom Tools
Register tools the LLM can call via elyra.registerTool().
Event Interception
Block or modify tool calls, inject context, customize compaction.
User Interaction
Prompt users via ctx.ui (select, confirm, input, notify).
Custom UI Components
Full TUI components with keyboard input via ctx.ui.custom().
Custom Commands
Register commands like /mycommand via elyra.registerCommand().
Session Persistence
Store state that survives restarts via elyra.appendEntry().

Example use cases: permission gates, git checkpointing, path protection, custom compaction, conversation summaries, interactive tools, stateful tools, external integrations.

Security: Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.

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
    };
  },
});
File mutations: If your tool mutates files, use 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.