Plugins

Extend the noetic CLI with custom tools, memory layers, skills, slash commands, footer UI, sub-agent presets, reminder triggers, and language servers.

A plugin is an npm-publishable extension that contributes to the CLI's runtime. Plugins are first-class — every extension point the built-in CLI uses is available to a plugin. You can publish a plugin, install it, register it in noetic.config.ts, and your sessions inherit its behavior.

The NoeticPlugin interface

import type { NoeticPlugin, PluginContext } from '@noetic/cli';

interface NoeticPlugin {
  name: string;
  version: string;

  // Extension points (all optional)
  tools?: (ctx: PluginContext) => ReadonlyArray<Tool> | Promise<ReadonlyArray<Tool>>;
  memoryLayers?: (ctx: PluginContext) => ReadonlyArray<MemoryLayer> | Promise<ReadonlyArray<MemoryLayer>>;
  skills?: (ctx: PluginContext) => ReadonlyArray<SkillDefinition> | Promise<ReadonlyArray<SkillDefinition>>;
  commands?: (ctx: PluginContext) => ReadonlyArray<Command> | Promise<ReadonlyArray<Command>>;
  subagentPresets?: () => Record<string, SubagentPreset> | Promise<Record<string, SubagentPreset>>;
  reminderTriggers?: (ctx: PluginContext) => ReadonlyArray<ReminderTrigger> | Promise<ReadonlyArray<ReminderTrigger>>;
  lspServers?: (ctx: PluginContext) => ReadonlyArray<LspServerContribution> | Promise<ReadonlyArray<LspServerContribution>>;
  footer?: () => ReactNode;
  loadingMessages?: () => ReadonlyArray<string> | Promise<ReadonlyArray<string>>;
  initialize?: (ctx: PluginContext) => Promise<void>;
  dispose?: () => Promise<void>;
}

Pick the hooks that fit your contribution; leave the rest off. Adding fields to NoeticPlugin is additive — existing plugins keep compiling.

Plugin context

Every hook receives a PluginContext:

interface PluginContext {
  config: AgentConfig;                  // parsed agent config
  callModel: CallModel;                 // one-shot LLM generation (no tool loops)
  dataDir: (scope: 'project' | 'user') => string;
}
  • config — the parsed noetic.config.ts (model, apiKey, cwd, ...).
  • callModel — issue a one-shot LLM call. Plugins that need tool loops should register tools instead and let the harness drive the turn.
  • dataDir('project') returns <cwd>/.noetic/<plugin-name>/; dataDir('user') returns ~/.noetic/<plugin-name>/. Created on first call.

Extension points

tools

Contribute new tools to the harness. Tools follow the framework's Tool API:

import { z } from 'zod';
import type { Tool } from '@noetic/core';

const weatherTool: Tool = {
  name: 'get_weather',
  description: 'Look up current weather for a city.',
  input: z.object({ city: z.string() }),
  output: z.object({ tempC: z.number(), summary: z.string() }),
  execute: async (args) => {
    const res = await fetch(`https://wttr.in/${args.city}?format=j1`);
    const json = await res.json();
    return { tempC: json.current_condition[0].temp_C, summary: json.current_condition[0].weatherDesc[0].value };
  },
};

memoryLayers

Contribute custom memory layers. Layers participate in budget allocation and recall just like the built-ins.

skills

Contribute skills programmatically — useful when your skill content is generated rather than file-backed. Plugin-contributed skills appear in /skills with source: 'plugin'.

commands

Contribute slash commands. Plugin commands merge into the CLI's built-in registry after built-ins, so a plugin can't shadow /help, /context, or /clear.

import type { NoeticPlugin } from '@noetic/cli';

const commands: NoeticPlugin['commands'] = (ctx) => [
  {
    name: 'deploy-preview',
    description: 'Build and deploy a preview environment for the current branch.',
    execute: async (_input, _session) => {
      // read from ctx.config, write to ctx.dataDir('project'), call shell, etc.
      void ctx;
    },
  },
];

subagentPresets

Register named sub-agent presets the model can invoke via plan-mode flow JSON subagent nodes (see Custom flows). Presets carry a system prompt, allowed tools, model override, and step caps.

reminderTriggers

Contribute developer-style reminders that emit <system-reminder>-wrapped messages based on session state or cadence. Used by built-ins for hints like "you haven't run tests in N turns".

lspServers

Contribute language servers for the lsp tool. Built-in servers (TypeScript, Python, Go, Swift) ship with the CLI; plugins can add more or override a built-in by reusing its id. Three launch strategies are supported: path (binary on $PATH), bunx (npm package), and githubRelease (download a tagged release artifact).

Render a React/Ink component between the chat area and the prompt. The component reads live session state via useFooterContext():

import { useFooterContext } from '@noetic/cli';

const StatusFooter = () => {
  const { model, status, lastLayerUsage, contextLimit } = useFooterContext();
  // ... render an Ink Box
};

If multiple plugins provide a footer, the first one wins.

loadingMessages

Provide a pool of strings the spinner cycles through instead of the default verb.

initialize / dispose

Run once at plugin load (after the context is built, before the TUI mounts) and once at session shutdown. Use initialize to cache callModel results, warm caches, or seed dataDir files.

Registration

In noetic.config.ts:

import designDeck from '@noetic/plugin-design-deck';
import powerline from '@noetic/plugin-powerline';

export default {
  // ...
  plugins: [
    designDeck({ generateCount: 3 }),                      // factory call → NoeticPlugin object
    '@noetic/plugin-powerline',                            // string → dynamic import
    { name: 'my-plugin', path: './local/plugin.ts' },      // path spec → resolve + import
  ],
} satisfies AgentConfig;

Three valid forms:

FormBehavior
String ('@scope/pkg' or './local.ts')Dynamically imported. Default export must be a NoeticPlugin object or a factory returning one.
Inline NoeticPlugin objectUsed directly. Detected by presence of name, version, and at least one hook.
{ name, path?, options? } specpath is resolved (relative to the config file dir) and imported; options is passed to the factory.

Initialization runs in array order. Disposal runs in reverse order at session shutdown.

Walkthrough: the shipped plugins

Two reference plugins ship in this repo:

@noetic/plugin-powerline

Adds a powerline-style footer + themed working-vibes loading messages.

import type { ReactNode } from 'react';
import type { NoeticPlugin } from '@noetic/cli';

declare const Footer: (props: { segments: unknown; theme: unknown }) => ReactNode;
declare function parseOptions(input: unknown): {
  segments: unknown;
  theme: unknown;
  vibe: unknown;
};
declare function resolveVibes(args: { options: unknown; apiKey: string }): Promise<string[]>;
declare function loadTheme(theme: unknown): unknown;

export default function powerline(userInput: unknown = {}): NoeticPlugin {
  const options = parseOptions(userInput);
  let vibes: ReadonlyArray<string> = [];

  return {
    name: '@noetic/plugin-powerline',
    version: '0.1.0',
    initialize: async (ctx) => {
      vibes = await resolveVibes({ options: options.vibe, apiKey: ctx.config.apiKey });
    },
    loadingMessages: () => vibes,
    footer: () => <Footer segments={options.segments} theme={loadTheme(options.theme)} />,
  };
}

Demonstrates initialize, loadingMessages, and footer.

@noetic/plugin-design-deck

Adds an interactive visual-decision modal as a slash command.

import type { NoeticPlugin } from '@noetic/cli';

export default function designDeck(userInput = {}): NoeticPlugin {
  const options = parseOptions(userInput);
  return {
    name: '@noetic/plugin-design-deck',
    version: '0.1.0',
    commands: (ctx) => [
      deckCommand({ ctx, options }),
      deckDiscoverCommand({ ctx, options }),
    ],
  };
}

Demonstrates commands.

Building your own plugin

A minimal plugin that contributes one tool and one slash command:

// my-plugin/src/index.ts
import type { NoeticPlugin } from '@noetic/cli';
import { z } from 'zod';

export default function myPlugin(): NoeticPlugin {
  return {
    name: 'my-plugin',
    version: '0.1.0',
    tools: () => [{
      name: 'word_count',
      description: 'Count words in a string.',
      input: z.object({ text: z.string() }),
      output: z.object({ count: z.number() }),
      execute: async ({ text }) => ({ count: text.trim().split(/\s+/).length }),
    }],
    commands: (ctx) => [{
      name: 'tell-joke',
      description: 'Have the model tell a joke about the current cwd.',
      execute: async () => {
        const joke = await ctx.callModel({
          system: 'You tell terse, dry developer jokes.',
          user: `Tell a joke about working in ${ctx.config.cwd}.`,
        });
        return { kind: 'message', content: joke };
      },
    }],
  };
}

Then in your noetic.config.ts:

import myPlugin from './my-plugin/src/index.ts';

export default {
  // ...
  plugins: [myPlugin()],
} satisfies AgentConfig;

The new tool appears in the agent's tool pool; /tell-joke appears in the slash-command palette.

On this page