NOETIC
Code Agent CLI

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-tools/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)
  pluginStorage: (scope: 'project' | 'user') => StorageAdapter;
}
  • 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.
  • pluginStorage(scope) — plugin-scoped key-value storage. 'project' is backed by <cwd>/.noetic/<plugin-name>/; 'user' by ~/.noetic/<plugin-name>/. Created on first use.

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-tools/core';

const weatherInput = z.object({ city: z.string() });

const weatherTool: Tool<typeof weatherInput> = {
  name: 'get_weather',
  description: 'Look up current weather for a city.',
  input: weatherInput,
  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.

A Command is a CommandBase (name, description, optional aliases/visibility) plus a discriminated implementation: type: 'local' for commands that return text, or type: 'local-jsx' for commands that render an Ink modal. The implementation is lazyload() returns the module containing the call function, so heavy command code isn't loaded until first use.

import type { Command, NoeticPlugin } from '@noetic-tools/cli';

const commands: NoeticPlugin['commands'] = (ctx) => [
  {
    type: 'local',
    name: 'deploy-preview',
    description: 'Build and deploy a preview environment for the current branch.',
    load: async () => ({
      call: async (_args, _cmdCtx) => {
        // read from ctx.config, write via ctx.pluginStorage('project'), etc.
        void ctx;
        return { type: 'text', value: 'Preview deployed.' };
      },
    }),
  } satisfies Command,
];

call receives the raw argument string and a CommandContext (config, cwd, session controls) and returns { type: 'text', value } to post output, { type: 'skip' } for silent commands, or { type: 'prompt', value } to submit value as the next user turn.

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-tools/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 type { AgentConfig } from '@noetic-tools/cli';
import designDeck from '@noetic/plugin-design-deck';
import powerline from '@noetic/plugin-powerline';

export default {
  model: 'anthropic/claude-sonnet-4.5',
  cwd: process.cwd(),
  apiKey: process.env.OPENROUTER_API_KEY ?? '',
  maxTurns: 50,
  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;

(model, cwd, apiKey, and maxTurns are required fields on 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-tools/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 | undefined }): 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 { Command, NoeticPlugin } from '@noetic-tools/cli';

declare function parseOptions(input: unknown): unknown;
declare function deckCommand(args: unknown): Command;
declare const deckDiscoverCommand: typeof deckCommand;

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

deckCommand builds a type: 'local-jsx' command whose lazily loaded call(onDone, ctx, args) renders the deck modal and resolves through onDone when the user picks a design.

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-tools/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) => [{
      type: 'local',
      name: 'tell-joke',
      description: 'Have the model tell a joke about the current cwd.',
      load: async () => ({
        call: async (_args, _cmdCtx) => {
          const joke = await ctx.callModel({
            messages: [
              { role: 'system', content: 'You tell terse, dry developer jokes.' },
              { role: 'user', content: `Tell a joke about working in ${ctx.config.cwd}.` },
            ],
          });
          return { type: 'text', value: joke.text };
        },
      }),
    }],
  };
}

Then in your noetic.config.ts:

import type { AgentConfig } from '@noetic-tools/cli';
import myPlugin from './my-plugin/src/index.ts';

export default {
  model: 'anthropic/claude-sonnet-4.5',
  cwd: process.cwd(),
  apiKey: process.env.OPENROUTER_API_KEY ?? '',
  maxTurns: 50,
  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