Files
iofficeai--officecli/plugins/plugin-protocol.md
T
wehub-resource-sync 8cb1f9f479
Publish SDK (PyPI) / publish (push) Waiting to run
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Waiting to run
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Waiting to run
Publish SDK (npm) / publish (@officecli/sdk) (push) Waiting to run
Publish SDK (npm) / publish (officecli-sdk) (push) Waiting to run
SDK smoke / smoke (windows-latest) (push) Waiting to run
SDK smoke / smoke (macos-latest) (push) Waiting to run
SDK smoke / smoke (ubuntu-latest) (push) Waiting to run
Skill parity / diff (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:09:29 +08:00

34 KiB
Raw Blame History

OfficeCli Plugin Protocol

Status: v1 — final draft. No backward-compatibility goal; all plugins are pre-release and re-align with this document. Audience: Plugin authors and OfficeCli contributors.

1. Motivation

OfficeCli's main repo focuses on three universal Office formats (.docx, .xlsx, .pptx). To extend format support without bloating the main binary or coupling external implementations to the main repo's license, format support is delivered through plugins — independent sidecar processes discovered and invoked by the main binary.

Concrete drivers:

  • Legacy formats (.doc, .rtf, .odt) where some users need migration but the parser is heavy and the format is fading
  • Regional formats (.hwpx, .hwp) maintained by communities outside the main team
  • Export targets (.pdf, .epub) where the renderer library has size, license, or platform constraints that make in-tree bundling undesirable
  • Proprietary implementations that need to stay out of the Apache-licensed main repo

2. Plugin Kinds

A plugin declares its kind in its manifest. Each kind has a fixed responsibility, lifecycle, and IPC pattern. v1 defines three kinds.

2.1 dump-reader — read a foreign format, emit officecli commands

Used to migrate a foreign format into one of main's native formats (.docx/.xlsx/.pptx). The output format is declared by the plugin's manifest target field.

Aspect Value
Lifecycle Short-lived (one shot)
Source file handle Plugin (read-only)
Target file handle Main (replays plugin's batch into a sibling native file)
Vocabulary Main's <target> command vocabulary (no plugin-defined extensions)
IPC None — plugin writes JSONL (one BatchItem per line) to stdout and exits
Output extension Sibling <source-stem>.<target> next to the source

Flow:

  1. User invokes a command that opens a .doc file
  2. Main checks for a sibling <source-stem>.<target> next to the source. If it exists and is newer than the source, main opens it directly and skips steps 35
  3. Main spawns the plugin: <plugin> dump <source>
  4. Plugin parses the source and streams add/set/batch items to stdout as JSONL (one JSON object per line, terminated by \n), then exits 0
  5. Main creates a blank <target> skeleton, replays the batch line-by-line, and moves it to the sibling path. Subsequent invocations reuse the sibling

Edits target the sibling native file, not the original source. Source-side changes invalidate the cache automatically via mtime comparison; delete the sibling to force reconversion.

Streaming requirement: dump-reader plugins MUST emit one batch item per line, flushed individually. Top-level JSON arrays ([{...},{...}]) are rejected by main with corrupt_batch. Streaming gives the host's idle watchdog (§5.6) per-item activity signal and bounds main's memory usage on large source files.

2.2 exporter — convert native format to a foreign target

Used to render native content (.docx/.xlsx/.pptx) into a foreign output file (e.g. .pdf). Single-direction, no editing.

Aspect Value
Lifecycle Short-lived
Source file handle Plugin (reads native file, read-only)
Target file handle Plugin (writes foreign file)
Vocabulary None — no commands exchanged
IPC None — plain CLI invocation, diagnostics on stderr

Flow:

  1. User invokes a view mode that targets a foreign format (e.g. officecli view <file> pdf --out <path>). The mode name maps to the target extension.
  2. Main resolves the (from, to) pair to a plugin
  3. Main spawns the plugin with the source path and target path
  4. Plugin reads the source (using its own libraries), writes the target
  5. Plugin exits 0 if the target was written successfully

Source path is read-only. Exporters MUST NOT write to or modify the source file. This is a hard requirement: main passes the source path directly without snapshotting. Plugins that need a writable working copy MUST create their own temp copy.

2.3 format-handler — own a foreign format end-to-end

Used to support a first-class non-native format (e.g. .hwpx, .hwp). The plugin holds the file open for the entire session and handles all document operations.

Aspect Value
Lifecycle Long-lived (session duration)
Source file handle Plugin (read-write, same file as target)
Target file handle Same as source
Vocabulary Plugin-defined (declared in manifest, snapshotted at session start)
IPC stdin/stdout (long-lived); stderr for diagnostics + heartbeat

Flow:

  1. User invokes a command on a .hwpx file
  2. Main resolves .hwpx to a format-handler plugin
  3. Main spawns the plugin with the file path; main writes requests to the plugin's stdin and reads replies from its stdout
  4. Plugin opens the file and serves JSONL frames on stdin/stdout
  5. Main and plugin exchange the open handshake (§5.3) — plugin replies with its runtime capabilities and vocabulary snapshot
  6. Main wraps the plugin in a FormatHandlerProxy : IDocumentHandler; every operation becomes an IPC message
  7. On session end, main sends close; plugin flushes pending writes (if any) and exits

2.4 Reserved kinds

The following kinds are reserved for future use. Plugins MUST NOT declare them in v1:

  • engine — pluggable backend for an in-tree subsystem (e.g. PDF rendering, field refresh)
  • transformer — converts one native format to another (e.g. .docx → .pptx)

A plugin MAY declare multiple kinds in a single binary (e.g. an exporter that is also a dump-reader). See §4.

3. Plugin Discovery

When main needs a plugin for (kind, ext), it searches in this fixed order. The first match wins.

  1. Environment variable: $OFFICECLI_PLUGIN_<KIND>_<EXT> (absolute path to the plugin executable). Example: $OFFICECLI_PLUGIN_DUMP_READER_DOC.
  2. User plugins directory: ~/.officecli/plugins/<kind>/<ext>/plugin(.exe)
  3. Bundled plugins directory (next to the main executable): <dir>/plugins/<kind>/<ext>/plugin(.exe)
  4. PATH lookup: an executable named officecli-<kind>-<ext> or officecli-<ext> (in that priority).

Path conventions:

  • <kind> uses kebab-case (dump-reader, format-handler, exporter)
  • <ext> is the file extension without the leading dot (doc, hwpx, pdf)
  • On Windows, (.exe) is appended automatically when searching
  • Symlinks are followed

Main caches discovery results per process invocation. Adding a plugin between invocations is picked up immediately.

4. Manifest

Every plugin MUST respond to <plugin> --info by printing a single JSON object to stdout and exiting 0. The object describes the plugin to the main binary.

4.1 Required fields

Field Type Description
name string Stable identifier, kebab-case (e.g. officecli-doc)
version string SemVer of the plugin (e.g. 1.0.0)
protocol integer Protocol major version this plugin implements. v1 plugins MUST set 1. Main rejects mismatches with exit code 5.
kinds array One or more declared kinds (see §2). Common case: ["dump-reader"]
extensions array File extensions this plugin handles, leading dot ([".doc"])
idle_timeout_seconds object Idle-timeout budget per verb. See §4.2.
runtime string Declarative runtime tag for diagnostics only: dotnet / native / go / rust / python / other. Host does not branch on this.

The target field is required for dump-reader and MUST be one of "docx", "xlsx", "pptx". The vocabulary field is required for format-handler (§4.4).

4.2 idle_timeout_seconds

Idle-timeout budgets in seconds. Main's watchdog kills the plugin when no activity (stdout byte / RPC reply / stderr heartbeat) is observed within this many seconds. Total wall-clock time is not bounded — long-running work is fine as long as the plugin keeps producing output.

"idle_timeout_seconds": {
  "default": 60,
  "verbs": {
    "dump": 30,
    "export": 120,
    "save": 30
  }
}

Rules:

  • default is mandatory (positive integer)
  • verbs is optional; entries override default for that verb
  • 0 is not allowed in the manifest (avoids silent never-kill). Users can opt out at runtime via the OFFICECLI_PLUGIN_IDLE_TIMEOUT_SECONDS environment variable (see below)
  • Recommended defaults (informative, not normative):
    • dump-reader.dump — 30s (streaming emit keeps idle low)
    • exporter.export — 60s (long jobs should heartbeat; see §5.6)
    • format-handler per-verb — 30s for reads, 60s for mutations/save

User override: set OFFICECLI_PLUGIN_IDLE_TIMEOUT_SECONDS=<n> in the host environment to override the manifest budget for every verb in that invocation (0 disables the watchdog entirely). The override is for the human user debugging a hung plugin — plugins themselves do not see this variable, and it does not propagate into the plugin subprocess.

4.3 Optional fields

Field Type Description
description string Short human-readable description
target string Native format the plugin produces ("docx"/"xlsx"/"pptx"). Required for dump-reader.
tier string Free-form tier identifier (basic/pro/enterprise)
supports array Capability tags (e.g. ["tables","images","fields"])
limits object Plugin-imposed limits (e.g. {"maxFileSizeMb": 200})
homepage string URL
license string SPDX identifier

4.4 Vocabulary (format-handler only)

Format-handler plugins MUST declare the vocabulary their proxied document model exposes:

"vocabulary": {
  "addable_types": ["page", "annotation", "formfield", "outline-item"],
  "settable_props": {
    "annotation": ["type", "rect", "color", "contents", "author", "opacity"],
    "page": ["rotation", "mediaBox"],
    "formfield": ["value", "readOnly"]
  },
  "path_segments": ["/page[N]", "/page[N]/annotation[M]", "/formfield[<name>]"]
}

Manifest vocabulary is used for discovery and help output. At session start, the plugin returns a runtime vocabulary snapshot in the open handshake reply (§5.3), which may differ from the manifest (e.g. extra aliases). The host trusts the snapshot for validation.

Vocabulary is documentation, not a runtime gate: main does not reject commands that fall outside the declared vocabulary. Plugins self-report unsupported keys via the set reply's unsupported_properties list. This follows the project-wide "handler-as-truth" principle.

4.5 Example manifests

officecli-doc (dump-reader):

{
  "name": "officecli-doc",
  "version": "1.0.0",
  "protocol": 1,
  "kinds": ["dump-reader"],
  "extensions": [".doc"],
  "target": "docx",
  "runtime": "dotnet",
  "idle_timeout_seconds": { "default": 60, "verbs": { "dump": 30 } },
  "tier": "basic",
  "supports": ["paragraphs", "runs", "tables", "images", "lists"]
}

officecli-pdf (exporter):

{
  "name": "officecli-pdf",
  "version": "0.1.0",
  "protocol": 1,
  "kinds": ["exporter"],
  "extensions": [".pdf"],
  "runtime": "dotnet",
  "idle_timeout_seconds": { "default": 60, "verbs": { "export": 120 } },
  "supports": ["from:docx", "from:xlsx", "from:pptx"]
}

officecli-hwpx (format-handler):

{
  "name": "officecli-hwpx",
  "version": "0.9.0",
  "protocol": 1,
  "kinds": ["format-handler"],
  "extensions": [".hwpx"],
  "runtime": "dotnet",
  "idle_timeout_seconds": { "default": 30, "verbs": { "save": 60 } },
  "vocabulary": {
    "addable_types": ["paragraph", "run", "table", "image", "footnote"],
    "settable_props": { },
    "path_segments": [ ]
  }
}

5. Invocation

Beyond --info, each kind has its own subcommand surface.

5.1 dump-reader

<plugin> dump <source-file> [--media-dir <dir>]
  • <source-file>: absolute path to the file to read
  • --media-dir: optional scratch directory the plugin may use for transient files (e.g. extracted images referenced by command paths)

Main sets the OFFICECLI_BIN environment variable to the path of the running officecli binary, so plugins that produce an intermediate .docx (e.g. via an external converter) can shell out to officecli dump <converted.docx> and pipe its output to stdout. Plugins that don't need this can ignore the variable.

Output format: JSONL — one JSON object per line, terminated by \n, each line flushed individually. Schema per line matches one entry of officecli batch --commands:

{"command":"add","parent":"/body","type":"paragraph","props":{"text":"Hello"}}
{"command":"set","path":"/body/paragraph[1]","props":{"bold":"true"}}

A top-level JSON array on a single line is rejected with corrupt_batch.

Diagnostics go to stderr or --log-file. The plugin exits 0 on success; non-zero codes follow §6.5.

5.2 exporter

<plugin> export <source-file> --out <target-file> [--options <json>]
  • <source-file>: native format file (.docx/.xlsx/.pptx) — read-only
  • --out: target path for the exported file
  • --options: optional backend-specific options as a JSON string

The plugin MUST NOT write to or modify <source-file>. Main relies on this to skip defensive snapshotting.

5.3 format-handler

<plugin> open <file>

The plugin reads request frames from stdin and writes reply frames to stdout (one JSON object per line, terminated by \n). Diagnostic output and heartbeat lines (§5.6) go on stderr. Anything the plugin writes to stdout that is not a valid envelope is a plugin bug: main reports it as protocol_mismatch and the session enters the broken state.

Open handshake (mandatory first exchange before any user command):

Main sends:

{"protocol":1,"msg_type":"open","path":"<file>","editable":true}

Plugin replies:

{"protocol":1,"msg_type":"ok","result":{
  "capabilities":{
    "commands":["add","set","get","query","remove","move","save","raw","raw-set"],
    "features":["save","extract-binary"]
  },
  "vocabulary":{
    "addable_types":[...],
    "settable_props":{...},
    "path_segments":[...]
  }
}}

Failure to handshake within the verb's idle timeout terminates the session. The host caches the returned capabilities and vocabulary; subsequent commands not present in commands are short-circuited with unsupported_command without round-tripping.

After handshake, each request gets exactly one reply before the next request is sent (§6.2).

Proxied verbs

Request envelope (main → plugin):

{"protocol":1,"msg_type":"command","command":"<verb>","args":{...},"props":{...}}

Read path:

command args keys result shape on ok
view mode (text/annotated/outline/stats/issues), start/end/max_lines/cols/type/limit/format string (or JSON object when format=json); for mode=issues, an array of issue objects
get path, depth DocumentNode JSON object
query selector array of DocumentNode
validate (none) array of {error_type,description,path,part}

Mutation path (envelope carries args and props separately; props is the user's --prop key=value dictionary, always string-to-string):

command args keys props result shape on ok
set path yes object {"unsupported_properties":["key1",...]} (empty array = all applied)
add parent_path, type, optional position yes object {"path":"...","unsupported_properties":[...]}
remove path no string or null — optional warning text (e.g. cells shifted)
move source_path, optional target_parent_path, optional position no string — new path
copy source_path, target_parent_path, optional position no string — new path
raw part_path, optional start_row/end_row/cols no string — raw XML (or CSV-of-rows for spreadsheet parts)
raw_set part_path, xpath, action, optional xml no null
add_part parent_part_path, part_type optional object {"rel_id":"...","part_path":"..."}
extract_binary path, dest_path no object {"found":true,"content_type":"...","byte_count":N} or {"found":false}

position (when present) is {"index":N} OR {"after":"<path>"} OR {"before":"<path>"} — at most one field set; all-null means append.

Numeric tolerance: byte_count and similar integer fields MUST be JSON numbers with no fractional part. Hosts SHOULD accept either int or double-encoded integer forms (42 and 42.0) to absorb runtime drift across languages.

save

{"protocol":1,"msg_type":"save"}

save is normative for format-handler plugins that accept mutations. The plugin MUST flush all pending writes to disk before replying ok. A no-op acknowledgement is non-conformant and breaks main's crash-recovery expectations. plugins lint verifies that a mutation followed by save is durable by reopening the file from disk after the reply.

close

{"protocol":1,"msg_type":"close"}

Plugin acknowledges with ok, flushes (implicit save if mutations were applied without an explicit save), and exits 0.

5.4 Universal options

Each plugin subcommand SHOULD accept:

  • --log-file <path>: append diagnostic output here instead of stderr
  • --quiet: suppress non-error output

These are plugin-side conventions. The host's own idle-watchdog override is the OFFICECLI_PLUGIN_IDLE_TIMEOUT_SECONDS env var (§4.2) — host does not forward CLI flags into the plugin process for timeout purposes.

5.5 Cross-runtime conventions

To keep .NET / Go / Rust / native plugins interchangeable, all plugins MUST:

  • Emit UTF-8 without BOM on stdout and stderr
  • Use \n (not \r\n) as line separator on all platforms, including Windows
  • Use snake_case for all JSON keys (manifest, IPC envelopes, error bodies)
  • Return one of the documented exit codes (§6.5); non-zero codes that are not documented are reported as internal_error

5.6 Idle-timeout watchdog & heartbeat

Main runs a watchdog thread for every spawned plugin process:

  • Any byte written to stdout (dump-reader, format-handler reply) resets the idle timer
  • A line on stderr matching {"heartbeat":true} (optionally with extra fields) resets the idle timer without producing diagnostic noise. The heartbeat line is consumed by the watchdog and not surfaced to the user
  • When now - last_activity > idle_timeout, main Kill(entire_process_tree) and reports plugin_idle_timeout (exit code 6)
  • --timeout 0 disables the watchdog; manifest cannot disable

Long opaque operations (exporter rendering, format-handler save on large files) SHOULD emit periodic heartbeats. Plugins that stream output naturally (dump-reader JSONL) do not need heartbeats.

6. IPC Protocol

Only format-handler exchanges live messages with main; the framing below applies to that kind. (dump-reader and exporter are short-lived and use the simpler stdout / exit-code contracts described in §5.1 and §5.2.)

6.1 Transport

Three standard streams, no auxiliary IPC channel:

  • stdin — main writes request envelopes here, plugin reads them
  • stdout — plugin writes reply envelopes here, main reads them
  • stderr — plugin writes diagnostics and heartbeat lines here (§5.6)

The choice is deliberate: stdin/stdout is the same shape dump-reader and exporter already use, every language has it built-in (no NamedPipeClient or UnixStream wrapper to learn), and it sidesteps macOS's 104-byte socket-path limit. The trade-off is one rule plugins MUST follow: stdout carries protocol frames only — debug output goes to stderr or --log-file. Main does not defend against polluted stdout; non-envelope content is reported as protocol_mismatch and the session enters broken.

6.2 Framing & concurrency

UTF-8 text without BOM. One JSON object per line, terminated by \n. The protocol is request/response: every client message receives exactly one server reply before the next message is sent. For format-handler, main is the client and plugin is the server.

Main MUST serialize requests per session. Callers in main that share a single FormatHandlerSession MUST go through the session's internal mutex; plugins MAY assume one request is in flight at a time.

6.3 Message envelope

Every message MUST include:

{
  "protocol": 1,
  "msg_type": "<type>",
  ... type-specific fields ...
}

6.4 Message types

Request types (client → server)

msg_type Body
open { "path": "<file>", "editable": <bool> } (handshake, §5.3)
command { "command": "add"|"set"|..., "args": {...}, "props": {...} }
save {} (normative flush, §5.3)
close {}
ping {} (liveness check; resets idle timer)

Response types (server → client)

msg_type Body
ok { "result": <value-or-null> }
error { "error": { "code": "<code>", "message": "...", "detail": "..." } }

Server-pushed events (format-handler only)

msg_type Body
event { "kind": "warning"|"info", "message": "..." }

Events are unsolicited and do not consume a reply slot; main MAY ignore them.

6.5 Exit codes

When a plugin process terminates:

Code Meaning
0 Success
2 Corrupt input file
3 Feature unsupported in this build
4 License expired
5 Protocol mismatch
6 Idle timeout (host-imposed; plugins do not emit this themselves)
64-78 Reserved (sysexits.h)
other Plugin bug; main reports as internal_error

6.6 Error codes (in error.code)

Plugins SHOULD use these codes when applicable:

Code Meaning
invalid_request Malformed message
unsupported_command Recognized message but unimplemented
unsupported_feature Recognized command but feature not in this build
invalid_argument Argument failed validation
not_found Target path/element does not exist
corrupt_input Source file is malformed or unreadable
corrupt_batch dump-reader output is not valid JSONL
license_expired Commercial plugin's license check failed
protocol_mismatch Manifest protocol version differs from main's
plugin_idle_timeout Host watchdog fired
plugin_stream_closed stdin/stdout reached EOF before handshake or mid-session
internal_error Catch-all for plugin bugs

Codes are extensible; main treats unknown codes as internal_error.

6.7 Session lifecycle state machine

                       spawn process
   (none) ─────────────────────────────────► spawning
                                              │
                              open handshake  │ idle timer running
                              succeeded on    │
                              stdin/stdout    ▼
                                            ready
                                              │
                       command request sent   │ command reply received
                              ────────────►   │ ◄────────────
                                              ▼
                                             busy
                                              │
                                              │ stdin write failure
                                              │ OR stdout EOF / read failure
                                              │ OR idle timeout
                                              │ OR malformed reply
                                              ▼
                                            broken
                                              │
                                              │ Dispose / Kill
                                              ▼
                                            closed

Rules:

  • Any IO failure or watchdog kill transitions to broken. Once broken, subsequent Send calls fail fast with plugin_stream_closed; the session is not auto-respawned (callers Dispose and re-open if needed)
  • close reply transitions cleanly to closed
  • The process is Kill(entire_process_tree)'d on transition to closed if it has not exited within 2 seconds of close reply (or immediately on transition from broken)

7. Vocabulary Contract

7.1 Universal protocol shell (all kinds)

These elements are stable across all plugins and all kinds:

  • Message envelope shape (§6.3)
  • Command verbs: add, set, remove, move, get, query, batch, raw_set
  • Path syntax: /segment[N] with [N] 1-based index OR [<name>] named reference
  • Error and exit code namespaces (extensible)

7.2 Per-format vocabulary

The specific types (paragraph/page/cell/...), property names (bold/fontsize/rect/...), and value formats (12pt/#FF0000/...) are not universal. They depend on which document model is at the other end:

  • For dump-reader, the receiving model is main's WordprocessingDocument (or the spreadsheet/presentation equivalent for non-docx targets), so the vocabulary is main's <target> vocabulary (published as schemas/word-vocabulary.json etc.)
  • For format-handler, the model is the plugin's own; the plugin declares its vocabulary in the manifest and reaffirms it via the open handshake
  • For exporter, there is no command vocabulary

8. Installation

The protocol does not mandate any installation mechanism. As long as the plugin executable ends up at one of the discovery paths (§3), it works.

Common installation channels:

  • Manual: download a release archive, extract to ~/.officecli/plugins/...
  • Bundled distribution: main's release archive includes a plugins/ directory next to the executable
  • Built-in installer (recommended for users): officecli plugins install <name>
  • Package managers: dotnet tool install, winget, brew, apt, scoop
  • Enterprise deployment: place binaries via IT distribution

The built-in installer consults a registry (default: https://officecli.ai/plugins/registry.json; configurable for private mirrors) which lists approved plugins, versions, download URLs, and SHA-256 hashes.

9. Writing a Plugin

9.1 Minimum dump-reader (C#)

using System.Text.Json;

if (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];

// Parse source file (your library here) and emit one JSON object per line.
// Flush each line individually so main's idle watchdog sees activity.
var stdout = Console.Out;
stdout.WriteLine(JsonSerializer.Serialize(new {
    command = "add",
    parent = "/body",
    type = "paragraph",
    props = new { text = "Hello from .doc" }
}));
stdout.Flush();
// ... more items ...
return 0;

9.2 Minimum exporter (Go)

package main

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

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-file> --out <target-file>
    // MUST NOT write to source-file.
    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]
        }
    }

    // Heartbeat on stderr for long jobs:
    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)
    }
    // ... move output to target ...
}

9.3 Minimum format-handler (C#, sketch)

// args: open <file>
// stdin = requests from main, stdout = replies to main,
// stderr = diagnostics + heartbeat.
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, return capabilities + vocabulary snapshot
            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":
            // MUST actually flush to disk before replying ok
            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 0;
        // ... command dispatch ...
    }
}

10. Stability Commitments

10.1 Main → Plugins

Once protocol v1 is ratified, main commits to:

  1. Protocol shell is stable for v1. Adding new optional message types is allowed; removing or changing types requires a v2 bump.
  2. Native vocabulary (relevant to dump-reader): additions allowed; deletions or renames require a deprecation cycle of at least two minor releases with the old name accepted as an alias.
  3. Path syntax does not change.
  4. Error/exit code semantics do not change. Adding new codes is allowed.
  5. Schema files (schemas/word-vocabulary.json, etc.) are released alongside main and follow the same versioning.

10.2 Plugins → Main

Plugin authors should:

  1. Treat --info output schema as stable per protocol major version.
  2. Implement graceful degradation when main lacks expected capabilities.
  3. Provide a meaningful exit code on failure (don't silently exit 1 for every error).
  4. Avoid writing to paths other than --media-dir, the declared output file, or temp files the plugin owns.

11. FAQ

Q: Can plugins be in any language? A: Yes. The protocol is JSONL over stdin/stdout. Any language with subprocess and standard-stream support works. .NET plugins can optionally use the OfficeCli.Contracts NuGet package for type-safe types.

Q: How does main know which plugin to use when several are installed? A: Discovery order (§3) is fixed and first-match-wins. For multiple installed plugins for the same extension, users select via env var or explicit --plugin flag.

Q: Can a plugin be closed-source / commercial? A: Yes. Plugins are independent binaries with their own license. License check failures exit 4 (license_expired).

Q: What if the plugin crashes? A: Main detects non-zero exit and surfaces a clear error. Partial state in main's in-memory document is discarded; no corrupt files are written.

Q: What if the plugin hangs? A: Main's idle watchdog (§5.6) kills it when no output is observed within the manifest-declared idle_timeout_seconds. Long jobs heartbeat on stderr to stay alive.

Q: Why no total wall-clock timeout? A: Large .doc files legitimately take minutes to dump; Word-interop PDF export of large workbooks can take hours. A wall-clock cap punishes correct behavior. Idle timeout catches actual hangs without false positives.

Q: How does this differ from MCP? A: MCP exposes officecli to AI clients; plugins extend officecli's format support. The two are complementary.

12. Versioning

This document tracks protocol version, distinct from main repo version.

  • v1.x: Additive changes only (new optional fields, new message types, new error codes). Backward-compatible.
  • v2.x: Breaking changes (removed/renamed fields, changed semantics).

Main repo declares supported protocol version(s) via officecli --version. Plugins declare their target protocol in manifest. Main rejects plugins whose major protocol version differs from main's supported version, exiting the plugin process with code 5 and surfacing protocol_mismatch to the user.

13. Open Questions (post-v1)

  • Should format-handler plugins support concurrent multi-document sessions in one process? (v1: no, one process per open document)
  • Should the registry support package signing? (Likely yes for v1.1)
  • Should capabilities queries return JSON Schema fragments inline, or only list names? (Currently: names; consider inline schema in v1.1)
  • Host-driven session pooling for format-handler (kill idle sessions to free memory). Not in v1; revisit if process count becomes a real problem.

This document is the source of truth for the OfficeCli Plugin Protocol v1. Pre-release plugins re-align with this document; post-ratification changes follow §10 and §12.