Building a Custom Plugin

A refrakt.md plugin is an npm package that exports a Plugin object. It can define new runes, extend existing core runes with additional attributes, contribute theme config for the identity transform, and optionally participate in the cross-page pipeline.

Package Structure

my-rune-package/
├── package.json
├── src/
   ├── index.ts exports the Plugin object
   ├── game-item.ts rune schema (one file per rune)
   └── game-spell.ts
└── styles/
    ├── game-item.css
    └── game-spell.css

The Plugin Interface

import type { Plugin } from '@refrakt-md/types';

export const myPackage: Plugin = {
  name: 'my-package',           // short ID for namespacing
  displayName: 'My Package',
  version: '1.0.0',
  runes: { /* ... */ },         // rune definitions
  extends: { /* ... */ },       // optional: extend core runes
  theme: { /* ... */ },         // optional: identity transform config + icons
  behaviors: { /* ... */ },     // optional: client-side behavior functions
  pipeline: { /* ... */ },      // optional: cross-page hooks
};

Defining Runes

Each key in runes is the Markdoc tag name. The value is a PluginRune:

import type { Plugin } from '@refrakt-md/types';
import { gameItem } from './game-item.js';

export const myPackage: Plugin = {
  name: 'dnd-5e',
  version: '1.0.0',
  runes: {
    'game-item': {
      transform: gameItem,   // Markdoc Schema
      description: 'A magical item with rarity and properties',
      aliases: ['item', 'magic-item'],
      seoType: 'Product',
      fixture: `{% game-item rarity="rare" %}
## Cloak of Elvenkind

- Weight: 1 lb
- Requires attunement: yes

> Woven from shadowy threads, this cloak renders the wearer nearly invisible.
{% /game-item %}`,
      authoringHints: 'Used for magical items — the rarity attribute sets the item tier (common through legendary).',
      schema: {
        rarity: {
          type: 'string',
          matches: ['common', 'uncommon', 'rare', 'very-rare', 'legendary'],
          default: 'common',
        },
      },
    },
  },
};

PluginRune Fields

FieldRequiredPurpose
transformYesMarkdoc Schema — the rune's parse and transform logic
descriptionRecommendedHuman-readable description shown in the rune catalog
aliasesNoAlternative tag names that resolve to this rune
seoTypeNoSchema.org type for automatic JSON-LD generation
fixtureRecommendedExample Markdoc string for refrakt inspect
authoringHintsNoShort note shown under "Authoring notes" in refrakt reference and included in refrakt write prompts
schemaRecommendedAttribute definitions for tooling and validation

Writing the Rune Schema

Rune schemas are standard Markdoc Schema objects built using createContentModelSchema from @refrakt-md/runes. See the Authoring Guide and Content Models for full documentation.

// src/game-item.ts
import Markdoc from '@markdoc/markdoc';
const { Tag } = Markdoc;
import type { RenderableTreeNode } from '@markdoc/markdoc';
import {
  createContentModelSchema,
  createComponentRenderable,
  RenderableNodeCursor,
  asNodes,
} from '@refrakt-md/runes';

export const gameItem = createContentModelSchema({
  attributes: {
    rarity: { type: String, required: false, matches: ['common', 'uncommon', 'rare', 'very-rare', 'legendary'] },
  },
  contentModel: {
    type: 'sequence',
    fields: [
      { name: 'headline', match: 'heading', optional: true },
      { name: 'properties', match: 'list', optional: true },
      { name: 'lore', match: 'blockquote', optional: true, greedy: true },
    ],
  },
  transform(resolved, attrs, config) {
    const rarityMeta = new Tag('meta', { content: attrs.rarity ?? 'common' });
    const body = new RenderableNodeCursor(
      Markdoc.transform([
        ...asNodes(resolved.headline),
        ...asNodes(resolved.properties),
        ...asNodes(resolved.lore),
      ], config) as RenderableTreeNode[],
    ).wrap('div');

    return createComponentRenderable('GameItem' as any, {
      tag: 'div',
      properties: { rarity: rarityMeta },
      refs: { body: body.tag('div') },
      children: [rarityMeta, body.next()],
    });
  },
});

Exporting typed component interfaces

Plugins should export generic TypeScript interfaces for each rune that uses createComponentRenderable. These interfaces describe the component override contract — scalar property types and named slot names — parameterized over a framework-specific renderable type.

Create a src/props.ts file in your package:

import type { BaseComponentProps } from '@refrakt-md/types';

export interface GameItemProps<R = unknown> extends BaseComponentProps<R> {
  rarity?: 'common' | 'rare' | 'epic' | 'legendary';
  body?: R;
}

Then export the type from your package's index.ts:

export type { GameItemProps } from './props.js';

Component authors consume the interface with their framework's renderable type:

import type { Snippet } from 'svelte';
import type { GameItemProps } from '@my-scope/game-runes';

let { rarity, body, tag }: GameItemProps<Snippet> = $props();

The BaseComponentProps<R> base type provides children?: R and tag?: SerializedTag. Use PageSectionSlots<R> for runes with pageSectionProperties (adds eyebrow, headline, blurb, image slots), and SplitLayoutProperties for runes with split layout support (adds layout, ratio, valign, gap, collapse properties).

Using Content Pipeline Variables

The content pipeline injects several variables into every page's Markdoc transform config. Your rune schemas can access these via config.variables in their transform functions:

VariableTypeDescription
$frontmatterobjectParsed YAML frontmatter
$pageobjectPage metadata: url, filePath, draft
$file.createdstringFile creation date (ISO 8601, from git history)
$file.modifiedstringFile modification date (ISO 8601, from git history)

Timestamps follow a three-tier resolution order: explicit frontmatter values take highest priority, then git commit timestamps, then filesystem stat as a last resort. When no data is available the variable is undefined.

To consume file timestamps in a rune schema's transform function:

transform(resolved, attrs, config) {
  const fileVars = config.variables?.file as
    { created?: string; modified?: string } | undefined;
  const created = attrs.created || fileVars?.created || '';
  // ... use in meta tags or rendered output
}

Theme Config

The theme field contributes identity transform config and icons for your runes:

theme: {
  runes: {
    'GameItem': {                  // keyed by typeof name (PascalCase)
      block: 'game-item',
      modifiers: {
        rarity: { source: 'meta', default: 'common' },
      },
      structure: {
        prepend: [
          {
            tag: 'header',
            'data-name': 'header',
            children: [
              { tag: 'span', 'data-name': 'name' },
              { tag: 'span', 'data-name': 'rarity', text: 'meta:rarity' },
            ],
          },
        ],
      },
    },
  },
  icons: {
    'game-item': {
      common: '<svg ...>',
      rare: '<svg ...>',
    },
  },
},

The theme config uses the same RuneConfig format documented in the Theme Config API.

Referencing Your Package

During Development (Local Files)

Use runes.local in refrakt.config.json to reference your package without publishing it:

{
  "runes": {
    "local": {
      "game-item": "./packages/my-rune-package/src/game-item.ts",
      "game-spell": "./packages/my-rune-package/src/game-spell.ts"
    }
  }
}

After Publishing

Install and register as a standard package:

npm install @my-org/dnd-5e
{
  "plugins": ["@my-org/dnd-5e"]
}

Name Collision Resolution

If your package defines a rune with the same name as a core rune or another package, use prefer to specify which package wins:

{
  "plugins": ["@my-org/custom", "@refrakt-md/marketing"],
  "runes": {
    "prefer": {
      "hero": "@my-org/custom"
    }
  }
}

Use "__core__" to force the built-in core rune to win over any package:

{
  "runes": {
    "prefer": {
      "hint": "__core__"
    }
  }
}

Adding Cross-Page Pipeline Hooks

If your runes need to build site-wide indexes or cross-link content, add a pipeline field. See Cross-Page Pipeline for details.

Adding CLI Commands and MCP Tools

A package can contribute CLI commands and MCP tools by exporting a cli-plugin entry point alongside its main Plugin export. The refrakt CLI dispatches refrakt <namespace> <command> to the matching plugin, and the MCP server (@refrakt-md/mcp) registers each command as a tool under <namespace>.<name>.

Minimal cli-plugin export

// src/cli-plugin.ts
import type { CliPlugin } from '@refrakt-md/types';

const plugin: CliPlugin = {
  namespace: 'mypkg',
  commands: [
    {
      name: 'hello',
      description: 'Say hi',
      handler: (args) => {
        console.log('hello', args);
      },
    },
  ],
};

export default plugin;

Add the export to the package's package.json:

{
  "exports": {
    ".": "./dist/index.js",
    "./cli-plugin": "./dist/cli-plugin.js"
  }
}

That's the minimum for legacy plugins. The handler receives the argv tail (everything after refrakt mypkg hello) and is responsible for parsing and printing.

Making your commands MCP-friendly

To get clean structured I/O when the command runs through @refrakt-md/mcp, declare three additional fields on each CliPluginCommand:

interface CliPluginCommand {
  name: string;
  description: string;
  handler: (args: string[]) => void | Promise<void>;
  inputSchema?: JSONSchema7;            // NEW — used by MCP for input validation
  outputSchema?: JSONSchema7;           // NEW — optional, for structured output declaration
  mcpHandler?: (input: unknown) => Promise<unknown>;  // NEW — bypasses argv parsing
}

inputSchema

A JSON Schema describing the structured input shape. Tools without one fall back to { type: 'object', additionalProperties: true } and surface only the command's description. Schemas with enum values, required fields, and per-field descriptions give MCP clients autocomplete and inline validation.

{
  name: 'create',
  description: 'Scaffold a new entity',
  handler: createArgvHandler,
  inputSchema: {
    type: 'object',
    required: ['type', 'title'],
    properties: {
      type: { type: 'string', enum: ['work', 'spec', 'bug'] },
      title: { type: 'string' },
      id: { type: 'string', description: 'Override the auto-assigned ID.' },
    },
    additionalProperties: false,
  },
}

mcpHandler

A function that accepts the structured input directly and returns the result. When MCP invokes a tool with an mcpHandler, it bypasses argv serialization entirely — the cleanest path. Without an mcpHandler, MCP falls back to argv-shimming: it serializes the input object into argv strings, calls the legacy handler, captures stdout, and tries to parse it as JSON. This works but loses structured I/O.

{
  name: 'create',
  // ... same as above
  mcpHandler: async (input) => {
    const opts = input as { type: string; title: string; id?: string };
    return runCreate(opts);  // returns { id, file, type } directly
  },
}

The argv handler and mcpHandler should call the same underlying logic — typically a runX(opts) function that takes a typed options object — so behavior is consistent across both surfaces.

outputSchema

Optional. JSON Schema describing the tool's structured output. Most plugins omit this and let MCP clients infer the shape from the handler's return value, but it's useful for clients that want to validate or unpack results programmatically.

Linting your plugin export

refrakt package validate includes a cli-plugin lint pass that catches structural issues before publish:

  • Errors: missing namespace, missing commands array, command without name/description/handler, invalid inputSchema (must be a JSON Schema object).
  • Warnings: missing inputSchema (recommended for MCP exposure), inputSchema without mcpHandler (MCP will fall back to argv-shimming), namespace clashes with another installed plugin.

Run it from your package directory:

npx refrakt package validate

Excluding commands from MCP

Some commands don't fit MCP's request/response model — long-running servers, multi-file generators, interactive UIs. The MCP server has a built-in exclusion list for known cases (plan.serve, plan.build). External plugins can effectively opt out of MCP exposure by omitting inputSchema and mcpHandler — those commands appear with the generic schema and run via argv-shim, which works for some but is fragile for long-running ones.

For commands that should never appear as MCP tools, future versions may add an explicit mcpExposed: false flag. For now, file an issue if your plugin needs the exclusion machinery.