34 KiB
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:
- User invokes a command that opens a
.docfile - 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 3–5 - Main spawns the plugin:
<plugin> dump <source> - Plugin parses the source and streams
add/set/batchitems to stdout as JSONL (one JSON object per line, terminated by\n), then exits 0 - 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:
- 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. - Main resolves the
(from, to)pair to a plugin - Main spawns the plugin with the source path and target path
- Plugin reads the source (using its own libraries), writes the target
- 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:
- User invokes a command on a
.hwpxfile - Main resolves
.hwpxto aformat-handlerplugin - Main spawns the plugin with the file path; main writes requests to the plugin's stdin and reads replies from its stdout
- Plugin opens the file and serves JSONL frames on stdin/stdout
- Main and plugin exchange the open handshake (§5.3) — plugin replies with its runtime capabilities and vocabulary snapshot
- Main wraps the plugin in a
FormatHandlerProxy : IDocumentHandler; every operation becomes an IPC message - 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.
- Environment variable:
$OFFICECLI_PLUGIN_<KIND>_<EXT>(absolute path to the plugin executable). Example:$OFFICECLI_PLUGIN_DUMP_READER_DOC. - User plugins directory:
~/.officecli/plugins/<kind>/<ext>/plugin(.exe) - Bundled plugins directory (next to the main executable):
<dir>/plugins/<kind>/<ext>/plugin(.exe) - PATH lookup: an executable named
officecli-<kind>-<ext>orofficecli-<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:
defaultis mandatory (positive integer)verbsis optional; entries overridedefaultfor that verb0is not allowed in the manifest (avoids silent never-kill). Users can opt out at runtime via theOFFICECLI_PLUGIN_IDLE_TIMEOUT_SECONDSenvironment 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-handlerper-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, mainKill(entire_process_tree)and reportsplugin_idle_timeout(exit code 6) --timeout 0disables 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
Sendcalls fail fast withplugin_stream_closed; the session is not auto-respawned (callers Dispose and re-open if needed) closereply transitions cleanly to closed- The process is
Kill(entire_process_tree)'d on transition to closed if it has not exited within 2 seconds ofclosereply (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'sWordprocessingDocument(or the spreadsheet/presentation equivalent for non-docx targets), so the vocabulary is main's<target>vocabulary (published asschemas/word-vocabulary.jsonetc.) - 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:
- Protocol shell is stable for v1. Adding new optional message types is allowed; removing or changing types requires a v2 bump.
- 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. - Path syntax does not change.
- Error/exit code semantics do not change. Adding new codes is allowed.
- Schema files (
schemas/word-vocabulary.json, etc.) are released alongside main and follow the same versioning.
10.2 Plugins → Main
Plugin authors should:
- Treat
--infooutput schema as stable per protocol major version. - Implement graceful degradation when main lacks expected capabilities.
- Provide a meaningful exit code on failure (don't silently exit 1 for every error).
- 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-handlerplugins 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
capabilitiesqueries 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.