# Write a JSON-RPC plugin

Canonical HTML: [Write a JSON-RPC plugin](https://getred.dev/docs/plugins/json-rpc). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Build a live Markdown outline and table of contents plugin in TypeScript or Python with Red's JSON-RPC host API.

<a id="what-youll-build"></a>

## What you'll build

> **Available in Red v0.9.0 and later**
>
> Use **Red v0.9.0 or later** for standalone JSON-RPC plugins and the `0.19.0` host API surface. They are not included in Red v0.8.2.

This guide builds **Markdown Outline**, a plugin that shows a live outline of the Markdown file you're editing and keeps its table of contents up to date. Along the way it uses most of Red's host API: document snapshots and watches, text panels with buttons, transactional edits and undo, the statusline, the clipboard, pickers, configuration, storage, and editor events.

```text
README.md                      │ Outline  [r TOC] [c Copy] [u Undo] [x]
# Bucket                       │ • Bucket
                               │   • Install
A token bucket for Rust.       │   • Usage
                               │     • Configure
<!-- toc -->                   │   • License
- [Install](#install)          │
- [Usage](#usage)              │
  - [Configure](#configure)    │
- [License](#license)          │
<!-- tocstop -->               │
                               │
## Install                     │
NORMAL  README.md                               § 5 headings · 39 words
```

A JSON-RPC plugin is a package with a `red-plugin.toml` manifest and a program that Red starts as a child process. They talk in JSON-RPC 2.0 over standard input and output. Red keeps ownership of buffers, undo history, focus, panels, and sessions; your process asks for each operation through a versioned `red/*` host method.

| Rule                    | Protocol version 1 behavior                                                                                                                                                                                             |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Framing                 | One compact UTF-8 JSON object per line (`\n`) on `stdin` and `stdout`. Do **not** use LSP `Content-Length` headers or JSON-RPC batch arrays.                                                                            |
| Standard output         | Reserved strictly for JSON-RPC messages. Write logs and diagnostics to `stderr`; Red retains the last 16 KiB of `stderr` when reporting a failure.                                                                      |
| Host API & protocol     | Declare `red_api = "^0.19.0"` in `red-plugin.toml` and return `"protocol_version": 1` from `initialize`.                                                                                                                |
| Handshake agreement     | `initialize` must return the exact commands and events declared under `[activation]` in `red-plugin.toml`, with no missing or extra entries. Wait for the `initialized` notification before calling any `red/*` method. |
| Identifiers & revisions | Document revisions, `document_id`, and `transaction_id` are **decimal strings** on the wire so JavaScript does not lose precision above `Number.MAX_SAFE_INTEGER`. Keep them as strings.                                |
| Text coordinates        | Zero-based lines and **Unicode-scalar** character offsets (half-open ranges), neither UTF-8 byte offsets nor JavaScript UTF-16 code units.                                                                              |

Every step shows **TypeScript** and **Python**. The TypeScript version runs directly with `node plugin.ts`: Node.js 22.18 and later strip the types when loading the file, so there is no build step and no dependency to install. The Python version needs Python 3.9 or later and uses only the standard library. Pick a language once; the tabs stay in sync.

<a id="1-create-the-package-and-a-first-command"></a>

## 1. Create the package and a first command

A package is a directory with `red-plugin.toml` and your program. Red runs `[rpc].command` with the package directory as its working directory and passes `args` directly, without a shell. Start with one command that reads the current document and counts its headings:

**TypeScript**

```text
markdown-outline/
├── red-plugin.toml
└── plugin.ts
```

`markdown-outline/red-plugin.toml`:

```toml
schema_version = 1

[plugin]
id = "markdown-outline"
name = "Markdown Outline"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
command = "node"
args = ["plugin.ts"]

[activation]
commands = ["Outline"]
```

`markdown-outline/plugin.ts`:

```typescript
#!/usr/bin/env node
import { createInterface } from "node:readline";

const send = (message: Record<string, unknown>) =>
  process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);

let nextRequest = 1;
const waiting = new Map<string, (reply: any) => void>();

// Send a request to Red and resolve with its response.
function callHost(method: string, params = {}): Promise<any> {
  const id = `outline-${nextRequest++}`;
  send({ id, method, params });
  return new Promise((resolve, reject) => {
    waiting.set(id, (reply) => {
      if (reply.error) {
        reject(new Error(reply.error.message));
      } else {
        resolve(reply.result);
      }
    });
  });
}

async function outline(): Promise<string> {
  const document = await callHost("red/DocumentSnapshot");
  const headings = document.text.match(/^#{1,6} .+$/gm) ?? [];
  const name = document.path?.split(/[\\/]/).pop() ?? "This buffer";
  return `${name} has ${headings.length} headings`;
}

createInterface({ input: process.stdin }).on("line", async (line) => {
  const message = JSON.parse(line);
  // A response from Red.
  if (message.method === undefined) {
    waiting.get(message.id)?.(message);
    return;
  }
  const { id, method, params } = message;
  if (method === "initialize") {
    const commands = [
      { name: "Outline", metadata: { title: "Show the Markdown outline" } },
    ];
    send({ id, result: { protocol_version: 1, commands, events: [] } });
  } else if (method === "command/execute" && params.name === "Outline") {
    try {
      send({ id, result: { message: await outline() } });
    } catch (error) {
      send({ id, error: { code: -32000, message: (error as Error).message } });
    }
  } else if (method === "shutdown") {
    send({ id, result: null });
  } else if (method === "exit") {
    process.exit(0);
  }
});
```

**Python**

```text
markdown-outline/
├── red-plugin.toml
└── plugin.py
```

`markdown-outline/red-plugin.toml`:

```toml
schema_version = 1

[plugin]
id = "markdown-outline"
name = "Markdown Outline"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
command = "python3"
args = ["plugin.py"]

[rpc.commands]
aarch64-pc-windows-msvc = "python"
x86_64-pc-windows-msvc = "python"

[activation]
commands = ["Outline"]
```

`markdown-outline/plugin.py`:

```python
#!/usr/bin/env python3
import json
import os
import re
import sys

next_request = 1


def send(message):
    print(json.dumps({"jsonrpc": "2.0", **message}), flush=True)


def call_host(method, params=None):
    """Send a request to Red and wait for its response."""
    global next_request
    request_id = f"outline-{next_request}"
    next_request += 1
    send({"id": request_id, "method": method, "params": params or {}})
    reply = json.loads(sys.stdin.readline())
    if "error" in reply:
        raise RuntimeError(reply["error"]["message"])
    return reply["result"]


def outline():
    document = call_host("red/DocumentSnapshot")
    headings = re.findall(r"^#{1,6} .+$", document["text"], re.MULTILINE)
    path = document["path"]
    name = os.path.basename(path) if path else "This buffer"
    return f"{name} has {len(headings)} headings"


COMMANDS = [
    {"name": "Outline", "metadata": {"title": "Show the Markdown outline"}},
]

while line := sys.stdin.readline():
    message = json.loads(line)
    request_id, method = message.get("id"), message.get("method")
    if method == "initialize":
        result = {"protocol_version": 1, "commands": COMMANDS, "events": []}
        send({"id": request_id, "result": result})
    elif method == "command/execute":
        try:
            send({"id": request_id, "result": {"message": outline()}})
        except RuntimeError as error:
            failure = {"code": -32000, "message": str(error)}
            send({"id": request_id, "error": failure})
    elif method == "shutdown":
        send({"id": request_id, "result": None})
    elif method == "exit":
        sys.exit(0)
```

Red sends `initialize` first. The plugin answers with protocol version 1 and the commands it declared under `[activation]`. Each `:Outline` arrives as a `command/execute` request. To read the document, the plugin sends its own request, `red/DocumentSnapshot`, with a fresh `id`, and Red answers with that same `id`. On `shutdown` the plugin replies, and on `exit` it stops.

Install the package from its parent directory and check that Red lists it:

```shell
red plugin install --path ./markdown-outline
red plugin list
```

Open a Markdown file such as a README and run `:Outline`. Red shows a message like `README.md has 7 headings`. If the file has a fenced shell example, that count is probably one too high: a `# comment` inside a code block looks like a heading to a simple pattern. The next step fixes that.

Red reloads a path install when `red-plugin.toml` changes. After editing the program, save the manifest again (or `touch` it) to pick up the change without restarting Red.

<a id="2-find-the-headings"></a>

## 2. Find the headings

Markdown headings start with one to six `#` characters and a space, except inside fenced code blocks. The parser tracks fences (three or more backticks or tildes, closed by the same character at least as many times), removes optional closing `#` characters, and records each heading's line number. Step 4 uses those line numbers to place the table of contents.

**TypeScript**

```typescript
function parseHeadings(text: string): Heading[] {
  const headings: Heading[] = [];
  let fence: string | null = null;
  text.split("\n").forEach((raw, line) => {
    const row = raw.replace(/\r$/, "");
    const marker = /^ {0,3}(`{3,}|~{3,})/.exec(row)?.[1];
    if (marker) {
      if (!fence) {
        fence = marker;
      } else if (marker[0] === fence[0] && marker.length >= fence.length) {
        fence = null;
      }
      return;
    }
    const heading =
      !fence && /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/.exec(row);
    if (heading) {
      headings.push({ level: heading[1].length, title: heading[2], line });
    }
  });
  return headings;
}

function nestedList(
  headings: Heading[],
  item: (heading: Heading, index: number) => string,
): string {
```

**Python**

```python
FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")

HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$")

def parse_headings(text):
    headings, fence = [], None
    for line, raw in enumerate(text.split("\n")):
        row = raw.rstrip("\r")
        marker = FENCE.match(row)
        if marker:
            marker = marker.group(1)
            if not fence:
                fence = marker
            elif marker[0] == fence[0] and len(marker) >= len(fence):
                fence = None
            continue
        heading = None if fence else HEADING.match(row)
        if heading:
            headings.append(
                {
                    "level": len(heading.group(1)),
                    "title": heading.group(2),
                    "line": line,
                }
            )
    return headings

def nested_list(headings, item):
    top = min(heading["level"] for heading in headings)
    return "\n".join(
        f"{'  ' * (heading['level'] - top)}- {item(heading)}"
        for heading in headings
    )
```

Splitting on `\n` gives the same zero-based line numbers that Red uses for edits. `nestedList` renders headings as a Markdown list, indented relative to the shallowest heading, which the panel in the next step displays.

<a id="3-show-a-live-outline-in-a-panel"></a>

## 3. Show a live outline in a panel

Now `:Outline` opens a panel. `red/TextPanelCreate` creates it with an ID that only your connection can use, and `red/TextPanelSetContent` replaces its blocks; a block with `"format": "markdown"` renders the nested list. `red/StatuslineSet` sets the plugin's single statusline item. When the plugin stops or reloads, Red removes its panels and statusline item for you.

To keep the outline current, the plugin watches the document. Ask for the snapshot with `"include_document_id": true`, then call `red/DocumentWatch` with that ID. Red collects changes for 50 ms and sends one `document/event` notification with `"event": "changed"`. The event only says that something changed, so the plugin reads the document again. Install the watch before reading, so an edit made in between still produces an event. A plugin can watch two documents at a time; this one follows a single file and releases the previous watch when you switch.

**TypeScript**

```typescript
async function snapshot(selector: Params = {}): Promise<Snapshot> {
  return callHost<Snapshot>("red/DocumentSnapshot", {
    ...selector,
    include_document_id: true,
  });
}

async function openOutline(selector: Params = {}) {
  const document = await snapshot(selector);
  if (!isMarkdown(document.path)) {
    throw new Error("Outline works on Markdown files (.md or .markdown)");
  }
  if (following !== document.document_id) {
    if (following) {
      await callHost("red/DocumentUnwatch", { document_id: following });
    }
    // Watch before reading, so changes made meanwhile still send an event.
    await callHost("red/DocumentWatch", { document_id: document.document_id });
    following = document.document_id;
  }
  await callHost("red/TextPanelCreate", {
    id: PANEL,
    side: "right",
    width: 40,
    title: "Outline",
    actions: panelActions(),
  });
  await refresh();
}

async function refresh() {
  if (!following) return;
  const document = await snapshot({ document_id: following });
  const headings = parseHeadings(document.text).filter(
    (heading) => heading.level <= depth,
  );
  const words =
    document.text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)?.length ?? 0;
  const text = headings.length
    ? nestedList(headings, (heading) => heading.title)
    : "_No headings yet._";
  await callHost("red/TextPanelSetContent", {
    id: PANEL,
    blocks: [{ id: "outline", format: "markdown", text }],
  });
  const count = words.toLocaleString("en-US");
  await callHost("red/StatuslineSet", {
    text: `§ ${headings.length} headings · ${count} words`,
  });
}

async function closeOutline() {
  if (following) {
    await callHost("red/DocumentUnwatch", { document_id: following });
  }
  following = null;
  // The user may have closed the panel already.
  await callHost("red/TextPanelDispose", { id: PANEL }).catch(() => {});
  await callHost("red/StatuslineClear");
}
```

Then route the notification in `dispatch()`:

```typescript
case "document/event":
  if (params.document_id !== following) break;
  enqueue(params.event === "closed" ? closeOutline : refresh);
  break;
```

**Python**

```python
def snapshot(selector=None):
    return call_host(
        "red/DocumentSnapshot",
        {**(selector or {}), "include_document_id": True},
    )

def open_outline(selector=None):
    global following
    document = snapshot(selector)
    if not is_markdown(document["path"]):
        raise ValueError("Outline works on Markdown files (.md or .markdown)")
    document_id = document["document_id"]
    if following != document_id:
        if following:
            call_host("red/DocumentUnwatch", {"document_id": following})
        # Watch before reading, so changes made meanwhile still send an event.
        call_host("red/DocumentWatch", {"document_id": document_id})
        following = document_id
    call_host(
        "red/TextPanelCreate",
        {
            "id": PANEL,
            "side": "right",
            "width": 40,
            "title": "Outline",
            "actions": panel_actions(),
        },
    )
    refresh()

def refresh():
    if not following:
        return
    document = snapshot({"document_id": following})
    headings = [
        h for h in parse_headings(document["text"]) if h["level"] <= depth
    ]
    words = len(re.findall(r"[^\W_][\w'’-]*", document["text"]))
    if headings:
        text = nested_list(headings, lambda heading: heading["title"])
    else:
        text = "_No headings yet._"
    blocks = [{"id": "outline", "format": "markdown", "text": text}]
    call_host("red/TextPanelSetContent", {"id": PANEL, "blocks": blocks})
    status = f"§ {len(headings)} headings · {words:,} words"
    call_host("red/StatuslineSet", {"text": status})

def close_outline():
    global following
    if following:
        call_host("red/DocumentUnwatch", {"document_id": following})
    following = None
    try:
        call_host("red/TextPanelDispose", {"id": PANEL})
    except HostError:
        pass  # the user closed the panel already
    call_host("red/StatuslineClear")
```

Then route the notification in `handle()`:

```python
elif method == "document/event":
    if params["document_id"] == following:
        if params["event"] == "closed":
            close_outline()
        else:
            refresh()
```

Watching changes how the program reads messages. In step 1, nothing could arrive while the plugin waited for a reply. Now a `document/event` can arrive at any moment, including while a handler is waiting for Red. The TypeScript version delivers each response as soon as it arrives and runs handlers one at a time through a queue. The Python version keeps reading while it waits and handles everything else afterward:

**TypeScript**

```typescript
function callHost<T = null>(method: string, params: Params = {}): Promise<T> {
  const id = `outline-${nextRequest++}`;
  send({ id, method, params });
  return new Promise((resolve, reject) => waiting.set(id, { resolve, reject }));
}

let work = Promise.resolve();

function enqueue(task: () => Promise<void>) {
  work = work.then(task).catch((error) => log(error));
}
```

**Python**

```python
def call_host(method, params=None):
    global next_request
    request_id = f"outline-{next_request}"
    next_request += 1
    send({"id": request_id, "method": method, "params": params or {}})
    # Keep reading until our response arrives; handle anything else afterward.
    while True:
        message = receive()
        if "method" in message:
            deferred.append(message)
        elif message.get("id") == request_id:
            if "error" in message:
                raise HostError(
                    message["error"]["code"], message["error"]["message"]
                )
            return message.get("result")

def respond(request_id, result):
    send({"id": request_id, "result": result})

def fail(request_id, code, message):
    send({"id": request_id, "error": {"code": code, "message": message}})

def main():
    while True:
        message = deferred.popleft() if deferred else receive()
        if "method" not in message:
            continue  # a late response to a call we stopped waiting for
        try:
            handle(message)
        except HostError as error:
            log(error)
```

Run `:Outline` and add a `## heading`. The panel and the statusline update as you type. Closing the file sends `"event": "closed"`, and the plugin closes the panel.

<a id="4-write-the-table-of-contents"></a>

## 4. Write the table of contents

Plugins never change buffer memory directly. `red/DocumentApply` takes the snapshot's `document_id` and `expected_revision` and a list of edits. Each edit has a range in zero-based lines and Unicode-scalar characters and an optional `expected_text`, the exact text it replaces. Red checks every range and preimage before changing anything, then applies the batch as one undo step attributed to the plugin, keeping dirty state, undo history, and language servers in sync.

The first `:OutlineToc` inserts a list between `<!-- toc -->` and `<!-- tocstop -->` markers, just before the first section. Later runs replace what is between the markers and pass the current list as `expected_text`, so Red rejects the edit if the list changed after the snapshot. Anchors follow GitHub's rules: lowercase, punctuation removed, spaces turned into hyphens, and repeated titles numbered.

**TypeScript**

```typescript
function slugs(headings: Heading[]): string[] {
  const seen = new Map<string, number>();
  return headings.map(({ title }) => {
    const base = title
      .toLowerCase()
      .trim()
      .replace(/[^\p{L}\p{N}\s_-]/gu, "")
      .replace(/\s/g, "-");
    const count = seen.get(base) ?? 0;
    seen.set(base, count + 1);
    return count ? `${base}-${count}` : base;
  });
}

function tocEntries(headings: Heading[], depth: number): Heading[] {
  const titled = headings.filter((heading) => heading.level === 1).length === 1;
  return headings.filter(
    (heading) => heading.level <= depth && !(titled && heading.level === 1),
  );
}

function tableOfContents(headings: Heading[], depth: number): string {
  const anchors = new Map(
    headings.map((heading, index) => [heading, slugs(headings)[index]]),
  );
  const entries = tocEntries(headings, depth);
  return entries.length
    ? nestedList(
        entries,
        (heading) => `[${heading.title}](#${anchors.get(heading)})`,
      )
    : "";
}

async function updateToc(selector: Params): Promise<string> {
  const document = await snapshot(selector);
  const headings = parseHeadings(document.text);
  const toc = tableOfContents(headings, depth);
  if (!toc) throw new Error("Add a few ## headings first");
  const first = tocEntries(headings, depth)[0].line;
  const lines = document.text.split(/(?<=\n)/); // keep each line's ending
  const start = lines.findIndex((line) => line.trim() === "<!-- toc -->");
  const stop = lines.findIndex(
    (line, index) => index > start && line.trim() === "<!-- tocstop -->",
  );
  let edit;
  if (start >= 0 && stop > start) {
    // Replace the list between the markers. Red rejects the edit if that
    // text changed after the snapshot.
    edit = {
      range: {
        start: { line: start + 1, character: 0 },
        end: { line: stop, character: 0 },
      },
      text: `${toc}\n`,
      expected_text: lines.slice(start + 1, stop).join(""),
    };
  } else {
    // The first time, insert the markers just before the first section.
    const at = { line: first, character: 0 };
    edit = {
      range: { start: at, end: at },
      text: `<!-- toc -->\n${toc}\n<!-- tocstop -->\n\n`,
      expected_text: "",
    };
  }
  const applied = await callHost<{ transaction_id: string; revision: string }>(
    "red/DocumentApply",
    {
      document_id: document.document_id,
      expected_revision: document.revision,
      // The panel may have focus instead of the document.
      allow_inactive: true,
      label: "Update table of contents",
      edits: [edit],
    },
  );
  lastToc = {
    documentId: document.document_id,
    transactionId: applied.transaction_id,
  };
  if (following) {
    await callHost("red/TextPanelSetActions", {
      id: PANEL,
      actions: panelActions(),
    });
  }
  return "Table of contents updated";
}

async function undoToc(): Promise<string> {
  if (!lastToc) throw new Error("There is no table of contents change to undo");
  // Red only undoes it while it is still the document's latest change.
  await callHost("red/DocumentUndo", {
    document_id: lastToc.documentId,
    transaction_id: lastToc.transactionId,
    allow_inactive: true,
  });
  lastToc = null;
  if (following) {
    await callHost("red/TextPanelSetActions", {
      id: PANEL,
      actions: panelActions(),
    });
  }
  return "Table of contents change undone";
}
```

**Python**

```python
def slugs(headings):
    seen, result = {}, []
    for heading in headings:
        base = re.sub(r"[^\w\s-]", "", heading["title"].lower().strip())
        base = re.sub(r"\s", "-", base)
        count = seen.get(base, 0)
        seen[base] = count + 1
        result.append(f"{base}-{count}" if count else base)
    return result

def toc_entries(headings, depth):
    # The table of contents leaves out a lone H1, which is the document's title.
    titled = sum(1 for heading in headings if heading["level"] == 1) == 1
    return [
        h
        for h in headings
        if h["level"] <= depth and not (titled and h["level"] == 1)
    ]

def table_of_contents(headings, depth):
    anchors = {
        heading["line"]: slug
        for heading, slug in zip(headings, slugs(headings))
    }
    entries = toc_entries(headings, depth)
    if not entries:
        return ""
    return nested_list(
        entries,
        lambda heading: f"[{heading['title']}](#{anchors[heading['line']]})",
    )

def line_index(lines, marker, after=-1):
    """Index of the first line after `after` that is exactly `marker`, or -1."""
    matches = (
        index
        for index, line in enumerate(lines)
        if index > after and line.strip() == marker
    )
    return next(matches, -1)

def update_toc(selector):
    global last_toc
    document = snapshot(selector)
    headings = parse_headings(document["text"])
    toc = table_of_contents(headings, depth)
    if not toc:
        raise ValueError("Add a few ## headings first")
    first = toc_entries(headings, depth)[0]["line"]
    # Keep each line's ending.
    lines = [line + "\n" for line in document["text"].split("\n")]
    start = line_index(lines, "<!-- toc -->")
    stop = line_index(lines, "<!-- tocstop -->", after=start)
    if start >= 0 and stop > start:
        # Replace the list between the markers. Red rejects the edit if that
        # text changed after the snapshot.
        edit = {
            "range": {
                "start": {"line": start + 1, "character": 0},
                "end": {"line": stop, "character": 0},
            },
            "text": f"{toc}\n",
            "expected_text": "".join(lines[start + 1 : stop]),
        }
    else:
        # The first time, insert the markers just before the first section.
        at = {"line": first, "character": 0}
        edit = {
            "range": {"start": at, "end": at},
            "text": f"<!-- toc -->\n{toc}\n<!-- tocstop -->\n\n",
            "expected_text": "",
        }
    applied = call_host(
        "red/DocumentApply",
        {
            "document_id": document["document_id"],
            "expected_revision": document["revision"],
            # The panel may have focus instead of the document.
            "allow_inactive": True,
            "label": "Update table of contents",
            "edits": [edit],
        },
    )
    last_toc = {
        "document_id": document["document_id"],
        "transaction_id": applied["transaction_id"],
    }
    if following:
        call_host(
            "red/TextPanelSetActions",
            {"id": PANEL, "actions": panel_actions()},
        )
    return "Table of contents updated"

def undo_toc():
    global last_toc
    if not last_toc:
        raise ValueError("There is no table of contents change to undo")
    # Red only undoes it while it is still the document's latest change.
    call_host("red/DocumentUndo", {**last_toc, "allow_inactive": True})
    last_toc = None
    if following:
        call_host(
            "red/TextPanelSetActions",
            {"id": PANEL, "actions": panel_actions()},
        )
    return "Table of contents change undone"
```

Run `:OutlineToc` in a README:

```text
<!-- toc -->
- [Install](#install)
- [Usage](#usage)
  - [Configure](#configure)
- [License](#license)
<!-- tocstop -->
```

The change is an ordinary undo step, so `u` in the document reverts it. `red/DocumentUndo` lets the plugin revert its own transaction, and Red allows that only while it is still the document's latest change. Both calls pass `"allow_inactive": true` so they still reach the document when the panel has focus. If Red answers `-32002`, the document changed after the snapshot; read it again and retry. `-32003` means a range or `expected_text` did not match.

<a id="5-add-panel-buttons"></a>

## 5. Add panel buttons

The `actions` passed to `red/TextPanelCreate` become header buttons, panel shortcuts, and `F1` entries. When someone uses one, Red sends a `panel/event` notification with the action ID, and it sends `"action": "close"` for the panel's own close gesture too. `red/TextPanelSetActions` updates the buttons without resetting the panel's content or scroll position; the plugin uses it to enable Undo after a table of contents change. Red's panels already use Vim-style keys, such as `y` to copy the selected block and `f`, `F`, `t`, and `T` to move to a character, so choose others for your actions.

Copy uses `red/ClipboardWrite`, which can only write and is meant for an explicit action like a button. `red/MessageShow` confirms the result with a short message.

**TypeScript**

```typescript
function panelActions(): Params[] {
  return [
    {
      id: "toc",
      label: "TOC",
      key: "r",
      description: "Write or refresh the table of contents",
    },
    {
      id: "copy",
      label: "Copy",
      key: "c",
      description: "Copy the table of contents",
    },
    {
      id: "undo",
      label: "Undo",
      key: "u",
      description: "Undo the last table of contents change",
      enabled: lastToc !== null,
    },
    { id: "close", label: "Close", compact_label: "x", key: "q" },
  ];
}

async function copyToc() {
  const document = await snapshot({ document_id: following });
  await callHost("red/ClipboardWrite", {
    text: `${tableOfContents(parseHeadings(document.text), depth)}\n`,
  });
  await callHost("red/MessageShow", {
    message: "Table of contents copied",
    severity: "success",
  });
}

async function onPanelAction(action: string) {
  try {
    if (action === "toc") {
      const message = await updateToc({ document_id: following });
      await callHost("red/MessageShow", { message });
    } else if (action === "undo") {
      await callHost("red/MessageShow", { message: await undoToc() });
    } else if (action === "copy") {
      await copyToc();
    } else if (action === "close") {
      await closeOutline();
    }
  } catch (error) {
    await callHost("red/MessageShow", {
      message: (error as Error).message,
      severity: "warning",
    });
  }
}
```

Then route the notification in `dispatch()`:

```typescript
case "panel/event":
  if (params.id === PANEL) enqueue(() => onPanelAction(params.action));
  break;
```

**Python**

```python
def panel_actions():
    return [
        {
            "id": "toc",
            "label": "TOC",
            "key": "r",
            "description": "Write or refresh the table of contents",
        },
        {
            "id": "copy",
            "label": "Copy",
            "key": "c",
            "description": "Copy the table of contents",
        },
        {
            "id": "undo",
            "label": "Undo",
            "key": "u",
            "description": "Undo the last table of contents change",
            "enabled": last_toc is not None,
        },
        {"id": "close", "label": "Close", "compact_label": "x", "key": "q"},
    ]

def copy_toc():
    document = snapshot({"document_id": following})
    toc = table_of_contents(parse_headings(document["text"]), depth)
    call_host("red/ClipboardWrite", {"text": toc + "\n"})
    copied = {"message": "Table of contents copied", "severity": "success"}
    call_host("red/MessageShow", copied)

def on_panel_action(action):
    try:
        if action == "toc":
            message = update_toc({"document_id": following})
            call_host("red/MessageShow", {"message": message})
        elif action == "undo":
            call_host("red/MessageShow", {"message": undo_toc()})
        elif action == "copy":
            copy_toc()
        elif action == "close":
            close_outline()
    except (HostError, ValueError) as error:
        warning = {"message": str(error), "severity": "warning"}
        call_host("red/MessageShow", warning)
```

Then route the notification in `handle()`:

```python
elif method == "panel/event":
    if params["id"] == PANEL:
        on_panel_action(params["action"])
```

Move focus to the panel with `Ctrl-w l`, then press `r` to write or refresh the table of contents, `c` to copy it, `u` to undo the change, or `q` to close the panel.

<a id="6-add-settings-a-picker-and-editor-events"></a>

## 6. Add settings, a picker, and editor events

Users configure plugins in Red's `config.toml`, under `[plugin_config.OWNER]`, where `OWNER` is the name the plugin was installed under (its `[plugin].id` by default). `red/ConfigGet` returns that table, or `null` when there is none:

`~/.config/red/config.toml`:

```toml
[plugin_config.markdown-outline]
max_depth = 3
auto_open = true
```

`:OutlineDepth` opens a picker with `red/PickerOpen`. The request returns right away; the choice arrives later as a `picker/event` notification, either `selected` with the item or `cancelled`. The plugin saves the chosen depth with `red/StorageSet`. Stored values are JSON, survive restarts, and here take precedence over `max_depth`.

With `auto_open`, the outline opens by itself when you open a Markdown file. The plugin declares the `file:opened` event in `[activation].events` and in its `initialize` response, and Red delivers it as an `event/notify` notification. Red starts the plugin on demand for a declared event, so a plugin that listens to `file:opened` starts the first time you open any file. Version 1 also offers `editor:ready` and `file:saved`.

**TypeScript**

```typescript
async function loadSettings() {
  settings = (await callHost<Settings | null>("red/ConfigGet")) ?? {};
  depth =
    (await callHost<number | null>("red/StorageGet", { key: "depth" })) ??
    settings.max_depth ??
    3;
}

async function runCommand(name: string): Promise<string | null> {
  if (name === "Outline") {
    await openOutline();
    return null;
  }
  if (name === "OutlineToc") return updateToc({});
  if (name === "OutlineDepth") {
    const items = [1, 2, 3, 4, 5, 6].map((level) => ({
      id: String(level),
      label: `Up to H${level}`,
      ...(level === depth ? { detail: "current" } : {}),
    }));
    await callHost("red/PickerOpen", {
      id: "depth",
      title: "Outline depth",
      items,
    });
    return null;
  }
  throw new Error(`Unknown command ${name}`);
}

async function onPicked(item: { id: string }) {
  depth = Number(item.id);
  await callHost("red/StorageSet", { key: "depth", value: depth });
  await refresh();
  await callHost("red/MessageShow", {
    message: `Outline shows headings up to H${depth}`,
  });
}
```

Then route the notification in `dispatch()`:

```typescript
case "event/notify": // file:opened, declared in red-plugin.toml
  enqueue(async () => {
    const file = params.payload.file;
    if (settings.auto_open && isMarkdown(file)) {
      await openOutline({ path: file });
    }
  });
  break;
```

**Python**

```python
def load_settings():
    global settings, depth
    settings = call_host("red/ConfigGet") or {}
    stored = call_host("red/StorageGet", {"key": "depth"})
    depth = stored if stored is not None else settings.get("max_depth", 3)

def run_command(name):
    if name == "Outline":
        open_outline()
        return None
    if name == "OutlineToc":
        return update_toc({})
    if name == "OutlineDepth":
        items = [
            {
                "id": str(level),
                "label": f"Up to H{level}",
                **({"detail": "current"} if level == depth else {}),
            }
            for level in range(1, 7)
        ]
        call_host(
            "red/PickerOpen",
            {"id": "depth", "title": "Outline depth", "items": items},
        )
        return None
    raise ValueError(f"Unknown command {name}")

def on_picked(item):
    global depth
    depth = int(item["id"])
    call_host("red/StorageSet", {"key": "depth", "value": depth})
    refresh()
    message = f"Outline shows headings up to H{depth}"
    call_host("red/MessageShow", {"message": message})
```

Then route the notification in `handle()`:

```python
elif method == "event/notify":  # file:opened, declared in red-plugin.toml
    file = params["payload"]["file"]
    if settings.get("auto_open") and is_markdown(file):
        open_outline({"path": file})
```

Here is the complete package. These are the same files the website's test suite runs against a scripted stand-in for Red on every build, and you can also [view the source on GitHub](https://github.com/codersauce/red-website/tree/main/examples/markdown-outline).

**TypeScript**

`markdown-outline/red-plugin.toml`:

```toml
schema_version = 1

[plugin]
id = "markdown-outline"
name = "Markdown Outline"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
command = "node"
args = ["plugin.ts"]

[activation]
commands = ["Outline", "OutlineToc", "OutlineDepth"]
events = ["file:opened"]
```

`markdown-outline/plugin.ts`:

```typescript
#!/usr/bin/env node
// Markdown Outline: a live outline panel and table of contents for Red.
// Runs with Node.js 22.18 or later, which strips the types when loading it.
import { createInterface } from "node:readline";

// --- Protocol types used by this plugin ---

type Params = Record<string, unknown>;
type Message = {
  jsonrpc: "2.0";
  id?: string | number;
  method?: string;
  params?: any;
  result?: any;
  error?: { code: number; message: string };
};
type Snapshot = {
  path: string | null;
  revision: string;
  text: string;
  document_id: string;
};
type Heading = { level: number; title: string; line: number };
type Settings = { max_depth?: number; auto_open?: boolean };

// --- Talking to Red ---
// One JSON message per line. stdout carries only protocol messages, so logs
// go to stderr.

const send = (message: Params) =>
  process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
const log = (...args: unknown[]) =>
  console.error("[markdown-outline]", ...args);

class HostError extends Error {
  code: number;
  constructor(code: number, message: string) {
    super(message);
    this.code = code;
  }
}

let nextRequest = 1;
const waiting = new Map<
  string,
  { resolve: (value: any) => void; reject: (error: Error) => void }
>();

function callHost<T = null>(method: string, params: Params = {}): Promise<T> {
  const id = `outline-${nextRequest++}`;
  send({ id, method, params });
  return new Promise((resolve, reject) => waiting.set(id, { resolve, reject }));
}

// Handlers run one at a time. Responses are delivered as soon as they arrive,
// so a handler can await a host call without blocking the message loop.
let work = Promise.resolve();
function enqueue(task: () => Promise<void>) {
  work = work.then(task).catch((error) => log(error));
}

// --- Markdown ---

function parseHeadings(text: string): Heading[] {
  const headings: Heading[] = [];
  let fence: string | null = null;
  text.split("\n").forEach((raw, line) => {
    const row = raw.replace(/\r$/, "");
    const marker = /^ {0,3}(`{3,}|~{3,})/.exec(row)?.[1];
    if (marker) {
      if (!fence) {
        fence = marker;
      } else if (marker[0] === fence[0] && marker.length >= fence.length) {
        fence = null;
      }
      return;
    }
    const heading =
      !fence && /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/.exec(row);
    if (heading) {
      headings.push({ level: heading[1].length, title: heading[2], line });
    }
  });
  return headings;
}

function slugs(headings: Heading[]): string[] {
  const seen = new Map<string, number>();
  return headings.map(({ title }) => {
    const base = title
      .toLowerCase()
      .trim()
      .replace(/[^\p{L}\p{N}\s_-]/gu, "")
      .replace(/\s/g, "-");
    const count = seen.get(base) ?? 0;
    seen.set(base, count + 1);
    return count ? `${base}-${count}` : base;
  });
}

function nestedList(
  headings: Heading[],
  item: (heading: Heading, index: number) => string,
): string {
  const top = Math.min(...headings.map((heading) => heading.level));
  return headings
    .map(
      (heading, index) =>
        `${"  ".repeat(heading.level - top)}- ${item(heading, index)}`,
    )
    .join("\n");
}

// The table of contents leaves out a lone H1, which is the document's title.
function tocEntries(headings: Heading[], depth: number): Heading[] {
  const titled = headings.filter((heading) => heading.level === 1).length === 1;
  return headings.filter(
    (heading) => heading.level <= depth && !(titled && heading.level === 1),
  );
}

function tableOfContents(headings: Heading[], depth: number): string {
  const anchors = new Map(
    headings.map((heading, index) => [heading, slugs(headings)[index]]),
  );
  const entries = tocEntries(headings, depth);
  return entries.length
    ? nestedList(
        entries,
        (heading) => `[${heading.title}](#${anchors.get(heading)})`,
      )
    : "";
}

// --- Plugin state ---

const PANEL = "outline";
let following: string | null = null; // document ID the panel follows
let lastToc: { documentId: string; transactionId: string } | null = null;
let settings: Settings = {};
let depth = 3;

const isMarkdown = (path: string | null) =>
  !!path && /\.(md|markdown)$/i.test(path);

function panelActions(): Params[] {
  return [
    {
      id: "toc",
      label: "TOC",
      key: "r",
      description: "Write or refresh the table of contents",
    },
    {
      id: "copy",
      label: "Copy",
      key: "c",
      description: "Copy the table of contents",
    },
    {
      id: "undo",
      label: "Undo",
      key: "u",
      description: "Undo the last table of contents change",
      enabled: lastToc !== null,
    },
    { id: "close", label: "Close", compact_label: "x", key: "q" },
  ];
}

async function loadSettings() {
  settings = (await callHost<Settings | null>("red/ConfigGet")) ?? {};
  depth =
    (await callHost<number | null>("red/StorageGet", { key: "depth" })) ??
    settings.max_depth ??
    3;
}

async function snapshot(selector: Params = {}): Promise<Snapshot> {
  return callHost<Snapshot>("red/DocumentSnapshot", {
    ...selector,
    include_document_id: true,
  });
}

// --- The outline panel ---

async function openOutline(selector: Params = {}) {
  const document = await snapshot(selector);
  if (!isMarkdown(document.path)) {
    throw new Error("Outline works on Markdown files (.md or .markdown)");
  }
  if (following !== document.document_id) {
    if (following) {
      await callHost("red/DocumentUnwatch", { document_id: following });
    }
    // Watch before reading, so changes made meanwhile still send an event.
    await callHost("red/DocumentWatch", { document_id: document.document_id });
    following = document.document_id;
  }
  await callHost("red/TextPanelCreate", {
    id: PANEL,
    side: "right",
    width: 40,
    title: "Outline",
    actions: panelActions(),
  });
  await refresh();
}

async function refresh() {
  if (!following) return;
  const document = await snapshot({ document_id: following });
  const headings = parseHeadings(document.text).filter(
    (heading) => heading.level <= depth,
  );
  const words =
    document.text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)?.length ?? 0;
  const text = headings.length
    ? nestedList(headings, (heading) => heading.title)
    : "_No headings yet._";
  await callHost("red/TextPanelSetContent", {
    id: PANEL,
    blocks: [{ id: "outline", format: "markdown", text }],
  });
  const count = words.toLocaleString("en-US");
  await callHost("red/StatuslineSet", {
    text: `§ ${headings.length} headings · ${count} words`,
  });
}

async function closeOutline() {
  if (following) {
    await callHost("red/DocumentUnwatch", { document_id: following });
  }
  following = null;
  // The user may have closed the panel already.
  await callHost("red/TextPanelDispose", { id: PANEL }).catch(() => {});
  await callHost("red/StatuslineClear");
}

// --- The table of contents ---

async function updateToc(selector: Params): Promise<string> {
  const document = await snapshot(selector);
  const headings = parseHeadings(document.text);
  const toc = tableOfContents(headings, depth);
  if (!toc) throw new Error("Add a few ## headings first");
  const first = tocEntries(headings, depth)[0].line;
  const lines = document.text.split(/(?<=\n)/); // keep each line's ending
  const start = lines.findIndex((line) => line.trim() === "<!-- toc -->");
  const stop = lines.findIndex(
    (line, index) => index > start && line.trim() === "<!-- tocstop -->",
  );
  let edit;
  if (start >= 0 && stop > start) {
    // Replace the list between the markers. Red rejects the edit if that
    // text changed after the snapshot.
    edit = {
      range: {
        start: { line: start + 1, character: 0 },
        end: { line: stop, character: 0 },
      },
      text: `${toc}\n`,
      expected_text: lines.slice(start + 1, stop).join(""),
    };
  } else {
    // The first time, insert the markers just before the first section.
    const at = { line: first, character: 0 };
    edit = {
      range: { start: at, end: at },
      text: `<!-- toc -->\n${toc}\n<!-- tocstop -->\n\n`,
      expected_text: "",
    };
  }
  const applied = await callHost<{ transaction_id: string; revision: string }>(
    "red/DocumentApply",
    {
      document_id: document.document_id,
      expected_revision: document.revision,
      // The panel may have focus instead of the document.
      allow_inactive: true,
      label: "Update table of contents",
      edits: [edit],
    },
  );
  lastToc = {
    documentId: document.document_id,
    transactionId: applied.transaction_id,
  };
  if (following) {
    await callHost("red/TextPanelSetActions", {
      id: PANEL,
      actions: panelActions(),
    });
  }
  return "Table of contents updated";
}

async function undoToc(): Promise<string> {
  if (!lastToc) throw new Error("There is no table of contents change to undo");
  // Red only undoes it while it is still the document's latest change.
  await callHost("red/DocumentUndo", {
    document_id: lastToc.documentId,
    transaction_id: lastToc.transactionId,
    allow_inactive: true,
  });
  lastToc = null;
  if (following) {
    await callHost("red/TextPanelSetActions", {
      id: PANEL,
      actions: panelActions(),
    });
  }
  return "Table of contents change undone";
}

async function copyToc() {
  const document = await snapshot({ document_id: following });
  await callHost("red/ClipboardWrite", {
    text: `${tableOfContents(parseHeadings(document.text), depth)}\n`,
  });
  await callHost("red/MessageShow", {
    message: "Table of contents copied",
    severity: "success",
  });
}

// --- Commands, events, and notifications ---

const command = (name: string, title: string) => ({
  name,
  metadata: { title, category: "Markdown" },
});

const commands = [
  command("Outline", "Show the Markdown outline"),
  command("OutlineToc", "Write or refresh the table of contents"),
  command("OutlineDepth", "Choose the outline depth"),
];

async function runCommand(name: string): Promise<string | null> {
  if (name === "Outline") {
    await openOutline();
    return null;
  }
  if (name === "OutlineToc") return updateToc({});
  if (name === "OutlineDepth") {
    const items = [1, 2, 3, 4, 5, 6].map((level) => ({
      id: String(level),
      label: `Up to H${level}`,
      ...(level === depth ? { detail: "current" } : {}),
    }));
    await callHost("red/PickerOpen", {
      id: "depth",
      title: "Outline depth",
      items,
    });
    return null;
  }
  throw new Error(`Unknown command ${name}`);
}

async function onPanelAction(action: string) {
  try {
    if (action === "toc") {
      const message = await updateToc({ document_id: following });
      await callHost("red/MessageShow", { message });
    } else if (action === "undo") {
      await callHost("red/MessageShow", { message: await undoToc() });
    } else if (action === "copy") {
      await copyToc();
    } else if (action === "close") {
      await closeOutline();
    }
  } catch (error) {
    await callHost("red/MessageShow", {
      message: (error as Error).message,
      severity: "warning",
    });
  }
}

async function onPicked(item: { id: string }) {
  depth = Number(item.id);
  await callHost("red/StorageSet", { key: "depth", value: depth });
  await refresh();
  await callHost("red/MessageShow", {
    message: `Outline shows headings up to H${depth}`,
  });
}

function dispatch(message: Message) {
  if (message.method === undefined) {
    // A response to one of our host calls.
    const call = waiting.get(String(message.id));
    waiting.delete(String(message.id));
    if (message.error) {
      call?.reject(new HostError(message.error.code, message.error.message));
    } else {
      call?.resolve(message.result ?? null);
    }
    return;
  }
  const { id, method, params } = message;
  switch (method) {
    case "initialize":
      send({
        id,
        result: { protocol_version: 1, commands, events: ["file:opened"] },
      });
      break;
    case "initialized":
      enqueue(loadSettings);
      break;
    case "command/execute":
      enqueue(async () => {
        try {
          const text = await runCommand(params.name);
          send({ id, result: text ? { message: text } : null });
        } catch (error) {
          send({
            id,
            error: { code: -32000, message: (error as Error).message },
          });
        }
      });
      break;
    case "event/notify": // file:opened, declared in red-plugin.toml
      enqueue(async () => {
        const file = params.payload.file;
        if (settings.auto_open && isMarkdown(file)) {
          await openOutline({ path: file });
        }
      });
      break;
    case "document/event":
      if (params.document_id !== following) break;
      enqueue(params.event === "closed" ? closeOutline : refresh);
      break;
    case "panel/event":
      if (params.id === PANEL) enqueue(() => onPanelAction(params.action));
      break;
    case "picker/event":
      if (params.event === "selected") enqueue(() => onPicked(params.item));
      break;
    case "shutdown":
      send({ id, result: null });
      break;
    case "exit":
      process.exit(0);
    default:
      if (id !== undefined) {
        const error = { code: -32601, message: `Unknown method ${method}` };
        send({ id, error });
      }
  }
}

createInterface({ input: process.stdin, crlfDelay: Infinity })
  .on("line", (line) => line && dispatch(JSON.parse(line)))
  .on("close", () => process.exit(0));
```

**Python**

`markdown-outline/red-plugin.toml`:

```toml
schema_version = 1

[plugin]
id = "markdown-outline"
name = "Markdown Outline"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
command = "python3"
args = ["plugin.py"]

[rpc.commands]
aarch64-pc-windows-msvc = "python"
x86_64-pc-windows-msvc = "python"

[activation]
commands = ["Outline", "OutlineToc", "OutlineDepth"]
events = ["file:opened"]
```

`markdown-outline/plugin.py`:

```python
#!/usr/bin/env python3
"""Markdown Outline: a live outline panel and table of contents for Red."""
import json
import re
import sys
from collections import deque

# --- Talking to Red ---
# One JSON message per line. stdout carries only protocol messages, so logs
# go to stderr.

sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")


def send(message):
    message = {"jsonrpc": "2.0", **message}
    line = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
    sys.stdout.write(line + "\n")
    sys.stdout.flush()


def respond(request_id, result):
    send({"id": request_id, "result": result})


def fail(request_id, code, message):
    send({"id": request_id, "error": {"code": code, "message": message}})


def log(*args):
    print("[markdown-outline]", *args, file=sys.stderr)


def receive():
    line = sys.stdin.readline()
    if not line:
        sys.exit(0)  # Red closed the connection
    return json.loads(line)


class HostError(Exception):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code


next_request = 1
deferred = deque()  # messages that arrived while waiting for a host response


def call_host(method, params=None):
    global next_request
    request_id = f"outline-{next_request}"
    next_request += 1
    send({"id": request_id, "method": method, "params": params or {}})
    # Keep reading until our response arrives; handle anything else afterward.
    while True:
        message = receive()
        if "method" in message:
            deferred.append(message)
        elif message.get("id") == request_id:
            if "error" in message:
                raise HostError(
                    message["error"]["code"], message["error"]["message"]
                )
            return message.get("result")


# --- Markdown ---

FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$")


def parse_headings(text):
    headings, fence = [], None
    for line, raw in enumerate(text.split("\n")):
        row = raw.rstrip("\r")
        marker = FENCE.match(row)
        if marker:
            marker = marker.group(1)
            if not fence:
                fence = marker
            elif marker[0] == fence[0] and len(marker) >= len(fence):
                fence = None
            continue
        heading = None if fence else HEADING.match(row)
        if heading:
            headings.append(
                {
                    "level": len(heading.group(1)),
                    "title": heading.group(2),
                    "line": line,
                }
            )
    return headings


def slugs(headings):
    seen, result = {}, []
    for heading in headings:
        base = re.sub(r"[^\w\s-]", "", heading["title"].lower().strip())
        base = re.sub(r"\s", "-", base)
        count = seen.get(base, 0)
        seen[base] = count + 1
        result.append(f"{base}-{count}" if count else base)
    return result


def nested_list(headings, item):
    top = min(heading["level"] for heading in headings)
    return "\n".join(
        f"{'  ' * (heading['level'] - top)}- {item(heading)}"
        for heading in headings
    )


def toc_entries(headings, depth):
    # The table of contents leaves out a lone H1, which is the document's title.
    titled = sum(1 for heading in headings if heading["level"] == 1) == 1
    return [
        h
        for h in headings
        if h["level"] <= depth and not (titled and h["level"] == 1)
    ]


def table_of_contents(headings, depth):
    anchors = {
        heading["line"]: slug
        for heading, slug in zip(headings, slugs(headings))
    }
    entries = toc_entries(headings, depth)
    if not entries:
        return ""
    return nested_list(
        entries,
        lambda heading: f"[{heading['title']}](#{anchors[heading['line']]})",
    )


# --- Plugin state ---

PANEL = "outline"
following = None  # document ID the panel follows
last_toc = None
settings = {}
depth = 3


MARKDOWN = re.compile(r"\.(md|markdown)$", re.IGNORECASE)


def is_markdown(path):
    return bool(path and MARKDOWN.search(path))


def panel_actions():
    return [
        {
            "id": "toc",
            "label": "TOC",
            "key": "r",
            "description": "Write or refresh the table of contents",
        },
        {
            "id": "copy",
            "label": "Copy",
            "key": "c",
            "description": "Copy the table of contents",
        },
        {
            "id": "undo",
            "label": "Undo",
            "key": "u",
            "description": "Undo the last table of contents change",
            "enabled": last_toc is not None,
        },
        {"id": "close", "label": "Close", "compact_label": "x", "key": "q"},
    ]


def load_settings():
    global settings, depth
    settings = call_host("red/ConfigGet") or {}
    stored = call_host("red/StorageGet", {"key": "depth"})
    depth = stored if stored is not None else settings.get("max_depth", 3)


def snapshot(selector=None):
    return call_host(
        "red/DocumentSnapshot",
        {**(selector or {}), "include_document_id": True},
    )


# --- The outline panel ---


def open_outline(selector=None):
    global following
    document = snapshot(selector)
    if not is_markdown(document["path"]):
        raise ValueError("Outline works on Markdown files (.md or .markdown)")
    document_id = document["document_id"]
    if following != document_id:
        if following:
            call_host("red/DocumentUnwatch", {"document_id": following})
        # Watch before reading, so changes made meanwhile still send an event.
        call_host("red/DocumentWatch", {"document_id": document_id})
        following = document_id
    call_host(
        "red/TextPanelCreate",
        {
            "id": PANEL,
            "side": "right",
            "width": 40,
            "title": "Outline",
            "actions": panel_actions(),
        },
    )
    refresh()


def refresh():
    if not following:
        return
    document = snapshot({"document_id": following})
    headings = [
        h for h in parse_headings(document["text"]) if h["level"] <= depth
    ]
    words = len(re.findall(r"[^\W_][\w'’-]*", document["text"]))
    if headings:
        text = nested_list(headings, lambda heading: heading["title"])
    else:
        text = "_No headings yet._"
    blocks = [{"id": "outline", "format": "markdown", "text": text}]
    call_host("red/TextPanelSetContent", {"id": PANEL, "blocks": blocks})
    status = f"§ {len(headings)} headings · {words:,} words"
    call_host("red/StatuslineSet", {"text": status})


def close_outline():
    global following
    if following:
        call_host("red/DocumentUnwatch", {"document_id": following})
    following = None
    try:
        call_host("red/TextPanelDispose", {"id": PANEL})
    except HostError:
        pass  # the user closed the panel already
    call_host("red/StatuslineClear")


# --- The table of contents ---


def line_index(lines, marker, after=-1):
    """Index of the first line after `after` that is exactly `marker`, or -1."""
    matches = (
        index
        for index, line in enumerate(lines)
        if index > after and line.strip() == marker
    )
    return next(matches, -1)


def update_toc(selector):
    global last_toc
    document = snapshot(selector)
    headings = parse_headings(document["text"])
    toc = table_of_contents(headings, depth)
    if not toc:
        raise ValueError("Add a few ## headings first")
    first = toc_entries(headings, depth)[0]["line"]
    # Keep each line's ending.
    lines = [line + "\n" for line in document["text"].split("\n")]
    start = line_index(lines, "<!-- toc -->")
    stop = line_index(lines, "<!-- tocstop -->", after=start)
    if start >= 0 and stop > start:
        # Replace the list between the markers. Red rejects the edit if that
        # text changed after the snapshot.
        edit = {
            "range": {
                "start": {"line": start + 1, "character": 0},
                "end": {"line": stop, "character": 0},
            },
            "text": f"{toc}\n",
            "expected_text": "".join(lines[start + 1 : stop]),
        }
    else:
        # The first time, insert the markers just before the first section.
        at = {"line": first, "character": 0}
        edit = {
            "range": {"start": at, "end": at},
            "text": f"<!-- toc -->\n{toc}\n<!-- tocstop -->\n\n",
            "expected_text": "",
        }
    applied = call_host(
        "red/DocumentApply",
        {
            "document_id": document["document_id"],
            "expected_revision": document["revision"],
            # The panel may have focus instead of the document.
            "allow_inactive": True,
            "label": "Update table of contents",
            "edits": [edit],
        },
    )
    last_toc = {
        "document_id": document["document_id"],
        "transaction_id": applied["transaction_id"],
    }
    if following:
        call_host(
            "red/TextPanelSetActions",
            {"id": PANEL, "actions": panel_actions()},
        )
    return "Table of contents updated"


def undo_toc():
    global last_toc
    if not last_toc:
        raise ValueError("There is no table of contents change to undo")
    # Red only undoes it while it is still the document's latest change.
    call_host("red/DocumentUndo", {**last_toc, "allow_inactive": True})
    last_toc = None
    if following:
        call_host(
            "red/TextPanelSetActions",
            {"id": PANEL, "actions": panel_actions()},
        )
    return "Table of contents change undone"


def copy_toc():
    document = snapshot({"document_id": following})
    toc = table_of_contents(parse_headings(document["text"]), depth)
    call_host("red/ClipboardWrite", {"text": toc + "\n"})
    copied = {"message": "Table of contents copied", "severity": "success"}
    call_host("red/MessageShow", copied)


# --- Commands, events, and notifications ---


def command(name, title):
    return {"name": name, "metadata": {"title": title, "category": "Markdown"}}


COMMANDS = [
    command("Outline", "Show the Markdown outline"),
    command("OutlineToc", "Write or refresh the table of contents"),
    command("OutlineDepth", "Choose the outline depth"),
]


def run_command(name):
    if name == "Outline":
        open_outline()
        return None
    if name == "OutlineToc":
        return update_toc({})
    if name == "OutlineDepth":
        items = [
            {
                "id": str(level),
                "label": f"Up to H{level}",
                **({"detail": "current"} if level == depth else {}),
            }
            for level in range(1, 7)
        ]
        call_host(
            "red/PickerOpen",
            {"id": "depth", "title": "Outline depth", "items": items},
        )
        return None
    raise ValueError(f"Unknown command {name}")


def on_panel_action(action):
    try:
        if action == "toc":
            message = update_toc({"document_id": following})
            call_host("red/MessageShow", {"message": message})
        elif action == "undo":
            call_host("red/MessageShow", {"message": undo_toc()})
        elif action == "copy":
            copy_toc()
        elif action == "close":
            close_outline()
    except (HostError, ValueError) as error:
        warning = {"message": str(error), "severity": "warning"}
        call_host("red/MessageShow", warning)


def on_picked(item):
    global depth
    depth = int(item["id"])
    call_host("red/StorageSet", {"key": "depth", "value": depth})
    refresh()
    message = f"Outline shows headings up to H{depth}"
    call_host("red/MessageShow", {"message": message})


def handle(message):
    request_id = message.get("id")
    method = message["method"]
    params = message.get("params") or {}
    if method == "initialize":
        events = ["file:opened"]
        result = {
            "protocol_version": 1,
            "commands": COMMANDS,
            "events": events,
        }
        respond(request_id, result)
    elif method == "initialized":
        load_settings()
    elif method == "command/execute":
        try:
            text = run_command(params["name"])
            respond(request_id, {"message": text} if text else None)
        except (HostError, ValueError) as error:
            fail(request_id, -32000, str(error))
    elif method == "event/notify":  # file:opened, declared in red-plugin.toml
        file = params["payload"]["file"]
        if settings.get("auto_open") and is_markdown(file):
            open_outline({"path": file})
    elif method == "document/event":
        if params["document_id"] == following:
            if params["event"] == "closed":
                close_outline()
            else:
                refresh()
    elif method == "panel/event":
        if params["id"] == PANEL:
            on_panel_action(params["action"])
    elif method == "picker/event":
        if params["event"] == "selected":
            on_picked(params["item"])
    elif method == "shutdown":
        respond(request_id, None)
    elif method == "exit":
        sys.exit(0)
    elif request_id is not None:
        fail(request_id, -32601, f"Unknown method {method}")


def main():
    while True:
        message = deferred.popleft() if deferred else receive()
        if "method" not in message:
            continue  # a late response to a call we stopped waiting for
        try:
            handle(message)
        except HostError as error:
            log(error)


main()
```

<a id="7-install-reload-and-distribute"></a>

## 7. Install, reload, and distribute

A path install runs your files in place. While Red is running, saving or touching `red-plugin.toml` reloads the plugin: Red starts and validates the new process first, swaps it in, and disposes the panels, pickers, statusline item, and document watches the old process owned.

```shell
red plugin install --path ./markdown-outline
red plugin install owner/repository
red plugin update markdown-outline
red plugin disable markdown-outline
red plugin enable markdown-outline
red plugin remove markdown-outline
```

### Environment filtering and explicit grants

Red starts RPC child processes with a cleared environment and inherits only baseline OS variables (`HOME`, `PATH`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`/`TMP`/`TEMP`, `LANG`, `LC_ALL`, `TERM`, `SSH_AUTH_SOCK`, `XDG_*`, and Windows system paths). Users who want a plugin to see another variable grant it in their own configuration:

```toml
[plugin_permissions.markdown-outline]
inherit_env = ["MY_SERVICE_TOKEN"]
```

A package manifest cannot grant itself environment variables. RPC plugins run as supervised native child processes: environment filtering prevents accidental secret leakage, not operating-system sandboxing.

### Compiled plugins and release artifacts

For plugins that should not need a language runtime on the user's machine, omit `command` and declare per-target GitHub release binaries with SHA-256 digests under `[rpc.artifacts.TARGET]`:

```toml
[plugin]
id = "document-tools"
name = "Document Tools"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
args = []

[rpc.artifacts.aarch64-apple-darwin]
url = "https://github.com/example/document-tools/releases/download/v0.1.0/document-tools-aarch64-apple-darwin"
sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
```

Red selects `[rpc.commands.TARGET]` first, then `rpc.command`, then `[rpc.artifacts.TARGET]`. GitHub installs and updates verify the SHA-256 digest and 128 MiB ceiling before swapping the package; local `--path` installs use local commands without downloading release artifacts.

<a id="use-another-language"></a>

## Use another language

The protocol doesn't depend on TypeScript or Python. Any program that reads and writes JSON lines can be a Red plugin, as long as it:

- reads `stdin` line by line and writes one compact JSON object per line to `stdout`, flushing after each, with logs on `stderr`;
- answers `initialize` with `"protocol_version": 1` and exactly its declared commands and events, then waits for `initialized` before calling `red/*` methods;
- gives every request a unique `id` and keeps reading while it waits, because notifications can arrive before the response;
- answers `shutdown` and exits on `exit`; and
- keeps revisions and IDs as strings and counts characters as Unicode scalars.

Compiled languages such as Rust or Go can ship per-target binaries as release artifacts, as described in step 7. The [protocol reference](https://github.com/codersauce/red/blob/eb5e2b6be15325abd12adbfac816f7844afff42c/docs/RPC_PLUGIN_PROTOCOL.md) lists every method, error code, and limit.

<a id="8-troubleshoot-common-failures"></a>

## 8. Troubleshoot common failures

| Symptom                                                                           | Cause and fix                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A command does nothing the first time, then reports `unknown command`             | Red stopped the plugin for this session, which it calls quarantine. Common causes: `initialize` did not answer within 5 seconds, or its commands did not match the manifest. Red writes the reason to `red.log` in its configuration directory (for example `~/.config/red/red.log`). Fix the cause, then save `red-plugin.toml` to reload or restart Red. Node.js version managers whose shims download a runtime on first use can exceed the 5-second limit; run `node --version` once in a terminal first. |
| Plugin fails to start or the executable is not found                              | A bare `command` such as `node` or `python3` resolves on `PATH`; a command containing `/` or `\` resolves relative to the package root, without a shell or `~` expansion. On Windows, add `[rpc.commands]` entries such as `x86_64-pc-windows-msvc = "python"`.                                                                                                                                                                                                                                               |
| Node.js reports an unknown `.ts` extension or a syntax error at a type annotation | Running `.ts` files directly needs Node.js 22.18 or later. Upgrade Node.js, or on 22.6 through 22.17 set `args = ["--experimental-strip-types", "plugin.ts"]`.                                                                                                                                                                                                                                                                                                                                                |
| Plugin disconnects right after printing debug text                                | Standard output is reserved for JSON-RPC messages. Any `console.log(...)` or `print(...)` on `stdout` breaks the framing. Log to `stderr` with `console.error(...)` or `print(..., file=sys.stderr)`.                                                                                                                                                                                                                                                                                                         |
| A panel shortcut doesn't reach the plugin                                         | Red's panels reserve Vim-style keys for themselves, such as `y` to copy the selected block and `f`, `F`, `t`, and `T` to move to a character. Choose a different `key` for the action.                                                                                                                                                                                                                                                                                                                        |
| Host method returns `-32601` (method not found) or `-32602` (invalid params)      | The protocol exposes the typed version 1 `red/*` methods listed in `initialize.params.host_methods`, not Red's internal commands. Unknown parameter keys are rejected; check names against the protocol reference.                                                                                                                                                                                                                                                                                            |
| `red/DocumentApply` fails with `-32002` or `-32003`                               | `-32002` means the document changed after `red/DocumentSnapshot`; take a fresh snapshot before retrying. `-32003` means a range or `expected_text` did not match. In JavaScript, count characters with `Array.from(text).length`, not `text.length`.                                                                                                                                                                                                                                                          |
| Command times out after 30 seconds                                                | The handler called a `red/*` method before `initialized`, or it waited for a response without reading `stdin`. Keep reading while you wait, as in step 3.                                                                                                                                                                                                                                                                                                                                                     |

[Open the complete JSON-RPC protocol specification](https://github.com/codersauce/red/blob/eb5e2b6be15325abd12adbfac816f7844afff42c/docs/RPC_PLUGIN_PROTOCOL.md)
