# How to Create Custom OfficeCLI Plugins: A Complete Developer Guide

> Learn to create custom OfficeCLI plugins. This developer guide explains how to build standalone executables that communicate via JSONL streams and implement the --info flag for manifest descriptions.

- Repository: [OfficeAI/OfficeCLI](https://github.com/iofficeai/OfficeCLI)
- Tags: how-to-guide
- Published: 2026-07-10

---

**OfficeCLI plugins are standalone executables that communicate via JSONL streams over stdout, discovered through environment variables or specific directory paths, and activated by implementing the `--info` flag to return a valid manifest describing capabilities and supported extensions.**

OfficeCLI extends its core document processing capabilities for .docx, .xlsx, and .pptx files through a well-defined plugin protocol. Creating custom OfficeCLI plugins allows developers to add support for legacy formats, custom exporters, or specialized format handlers by implementing a simple JSONL-based IPC contract defined in the [`plugins/plugin-protocol.md`](https://github.com/iOfficeAI/OfficeCLI/blob/main/plugins/plugin-protocol.md) file of the iOfficeAI/OfficeCLI repository.

## Understanding the OfficeCLI Plugin Architecture

The plugin system in OfficeCLI follows a strict separation between the main binary and external executables. According to the source code in the [`plugins/plugin-protocol.md`](https://github.com/iOfficeAI/OfficeCLI/blob/main/plugins/plugin-protocol.md) specification, the main binary parses commands, discovers plugins, and manages their lifecycle through JSONL batch streaming and idle timeout watchdogs.

### Plugin Discovery Mechanism

The main binary searches for plugins in a specific hierarchy defined in the protocol specification. Discovery occurs in the following order:

1. Environment variables
2. User-local directory at `~/.officecli/plugins/<kind>/<ext>/plugin`
3. Bundled `plugins/` directory within the installation
4. System PATH using the naming convention `officecli-<kind>-<ext>`

Each plugin must reside in a directory structure matching its kind (such as `dump-reader`, `exporter`, or `format-handler`) and the file extension it handles.

### Plugin Kinds and Lifecycle

OfficeCLI supports three distinct plugin kinds, each with a fixed lifecycle and I/O contract:

- **dump-reader**: Imports legacy formats by streaming `add` and `set` commands as JSONL lines
- **exporter**: Renders native documents to target formats like PDF or EPUB by writing output files
- **format-handler**: Maintains long-lived stdin/stdout sessions for interactive document manipulation

Each kind expects specific sub-commands (`dump`, `export`, or `open`) and uses a strict message envelope requiring `protocol` and `msg_type` fields in every JSON object.

## Steps to Create a Custom OfficeCLI Plugin

### Choose Your Plugin Kind

Determine which interaction pattern fits your use case. Most custom development starts with either a `dump-reader` to import legacy formats or an `exporter` to render documents to new formats. The `format-handler` kind is reserved for scenarios requiring persistent document sessions.

### Implement the --info Manifest

Every plugin must respond to the `--info` flag by emitting a single JSON object to stdout. This manifest declares the plugin name, version, protocol version, supported kinds, extensions, runtime, and idle timeout budget. The main binary validates this manifest against protocol version 1 during discovery.

Required manifest fields include:

- `name`: Unique identifier for the plugin
- `version`: Semantic version string
- `protocol`: Integer protocol version (currently 1)
- `kinds`: Array of supported kinds (e.g., `["dump-reader"]`)
- `extensions`: Array of file extensions (e.g., `[".doc"]`)
- `idle_timeout_seconds`: Object with default timeout values

### Handle the IPC Protocol

Plugins communicate via JSONL (JSON Lines) over stdout, with each line representing a single message. The protocol enforces UTF-8 encoding without BOM. For `dump-reader` plugins, the main binary sends a `dump` command with the source file path, and the plugin streams batch items back. For `exporter` plugins, the main binary sends an `export` command with source and target paths.

### Manage the Idle Timeout Watchdog

The main binary terminates plugins that exceed their declared `idle_timeout_seconds`. Plugins performing long operations must emit heartbeat messages to stderr to remain alive. The heartbeat format is `{"heartbeat":true}`.

## Code Examples for Custom OfficeCLI Plugins

### Dump-Reader Example in C#

This minimal dump-reader converts legacy `.doc` files by emitting document structure commands:

```csharp
using System.Text.Json;

if (args.Length > 0 && args[0] == "--info")
{
    Console.WriteLine(JsonSerializer.Serialize(new {
        name = "officecli-doc-minimal",
        version = "0.0.1",
        protocol = 1,
        kinds = new[] { "dump-reader" },
        extensions = new[] { ".doc" },
        target = "docx",
        runtime = "dotnet",
        idle_timeout_seconds = new { @default = 30 }
    }));
    return 0;
}

// args: dump <source-file>
string sourcePath = args[1];

// Emit a paragraph to the document body
var stdout = Console.Out;
stdout.WriteLine(JsonSerializer.Serialize(new {
    command = "add",
    parent = "/body",
    type = "paragraph",
    props = new { text = "Hello from .doc" }
}));
stdout.Flush();
return 0;

```

### Exporter Example in Go

This exporter converts `.docx` to PDF using LibreOffice while maintaining heartbeat messages to prevent timeout:

```go
package main

import (
    "encoding/json"
    "fmt"
    "os"
    "os/exec"
    "time"
)

func main() {
    if len(os.Args) > 1 && os.Args[1] == "--info" {
        json.NewEncoder(os.Stdout).Encode(map[string]any{
            "name":       "officecli-pdf-min",
            "version":    "0.0.1",
            "protocol":   1,
            "kinds":      []string{"exporter"},
            "extensions": []string{".pdf"},
            "runtime":    "go",
            "idle_timeout_seconds": map[string]any{
                "default": 60,
                "verbs":   map[string]int{"export": 120},
            },
        })
        return
    }

    // args: export <source> --out <target>
    source := os.Args[2]
    var target string
    for i, a := range os.Args {
        if a == "--out" && i+1 < len(os.Args) {
            target = os.Args[i+1]
        }
    }

    // Send heartbeat every 20 seconds to stderr
    go func() {
        for {
            fmt.Fprintln(os.Stderr, `{"heartbeat":true}`)
            time.Sleep(20 * time.Second)
        }
    }()

    cmd := exec.Command("soffice", "--headless", "--convert-to", "pdf", "--outdir", "/tmp/officecli-pdf", source)
    if err := cmd.Run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(3) // Exit code 3 indicates unsupported feature
    }
    // Move the produced PDF to the requested target location...
}

```

### Format-Handler Example in C#

This sketch demonstrates a long-lived session handling `open`, `save`, and `close` commands:

```csharp
// args: open <file>
var stdin  = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
var stdout = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false))
{
    NewLine = "\n",
    AutoFlush = true,
};

while (true)
{
    var line = stdin.ReadLine();
    if (line == null) break;
    var msg = JsonNode.Parse(line)!;
    switch ((string)msg["msg_type"]!)
    {
        case "open":
            // Load file and reply with capabilities
            stdout.WriteLine(JsonSerializer.Serialize(new {
                protocol = 1,
                msg_type = "ok",
                result = new {
                    capabilities = new {
                        commands = new[] { "get", "set", "save" },
                        features = Array.Empty<string>()
                    },
                    vocabulary = new {}
                }
            }));
            break;

        case "save":
            // Flush pending writes
            File.WriteAllBytes(filePath, currentBytes);
            stdout.WriteLine("""{"protocol":1,"msg_type":"ok","result":null}""");
            break;

        case "close":
            stdout.WriteLine("""{"protocol":1,"msg_type":"ok","result":null}""");
            return;
    }
}

```

## Installing Your Custom OfficeCLI Plugin

### Manual Installation

Place your compiled binary in the user plugins directory following the required structure:

```bash

# Make the binary executable

chmod +x officecli-doc-minimal

# Create the directory structure for kind and extension

mkdir -p ~/.officecli/plugins/dump-reader/.doc

# Move the binary to the plugin location

mv officecli-doc-minimal ~/.officecli/plugins/dump-reader/.doc/plugin

# Verify discovery

officecli plugins list

```

### Registry Installation

For distribution, publish your plugin to the public registry at `https://officecli.ai/plugins/registry.json`. Users can then install via the built-in installer:

```bash
officecli plugins install officecli-doc-minimal

```

The installer validates the SHA-256 hash, downloads the binary, and places it in the appropriate `~/.officecli/plugins` subdirectory.

## Summary

- **OfficeCLI plugins** are standalone executables that extend the CLI's support for document formats through a JSONL-based protocol.
- **Three plugin kinds** exist: `dump-reader` for imports, `exporter` for rendering, and `format-handler` for interactive sessions.
- **Discovery** follows a hierarchy from environment variables to `~/.officecli/plugins/<kind>/<ext>/plugin` to PATH entries named `officecli-<kind>-<ext>`.
- **Required implementation** includes the `--info` flag for manifest output and proper JSONL message envelopes with `protocol` and `msg_type` fields.
- **Heartbeat messages** on stderr prevent termination by the idle watchdog during long operations.
- **Installation** can be manual or via `officecli plugins install` from the registry.

## Frequently Asked Questions

### What programming languages can I use to create OfficeCLI plugins?

You can use any programming language that can write to stdout and read from stdin. The protocol only requires that your executable outputs valid JSON when called with `--info` and streams JSONL lines during operation. The repository includes examples in C# and Go, but Python, Rust, or Node.js work equally well as long as they handle the UTF-8 JSONL contract correctly.

### How does the plugin discovery mechanism work?

The main binary searches for plugins in a specific order: first checking environment variables, then the user-local `~/.officecli/plugins` directory, then the bundled `plugins/` directory, and finally the system PATH. For PATH discovery, the binary looks for executables matching the pattern `officecli-<kind>-<ext>`. The discovery mechanism is documented in [`plugins/plugin-protocol.md`](https://github.com/iOfficeAI/OfficeCLI/blob/main/plugins/plugin-protocol.md) under the plugin discovery section.

### Why does my plugin need to send heartbeat messages?

The main binary implements an idle watchdog that terminates plugins exceeding their declared `idle_timeout_seconds` budget. If your plugin performs long-running operations like converting large documents, it must emit `{"heartbeat":true}` JSON objects to stderr at regular intervals to signal it is still active. This prevents the watchdog from killing the process during legitimate busy periods.

### How do I debug a plugin that OfficeCLI doesn't recognize?

First, verify your plugin executable responds correctly to `--info` by running it manually and checking the JSON output matches the protocol version 1 schema. Ensure the binary is placed in the correct directory structure (`~/.officecli/plugins/<kind>/<ext>/plugin`) or follows the PATH naming convention (`officecli-<kind>-<ext>`). Run `officecli plugins list` to see discovered plugins, and check that file permissions allow execution.