The ABI surface

The extern "C" functions are cxagent_plugin.h, in cxagent.Core/sdk/c/ with the rest of what a C author gets. This file is the JSON vocabulary they exchange, and the reasoning behind it.

The calls, and what crosses each one

C functionManaged equivalentJSON inJSON out
cxagent_plugin_abi_version— (handshake only)—an int32_t, not JSON
cxagent_plugin_describeIPlugin.Load's returned manifest—PluginManifest
cxagent_plugin_startIPlugin.StartAbiPluginContextAbiResultEnvelope (void)
cxagent_plugin_gateIPluginGateSource.Gatethe arguments object (AbiInvokeCall.Arguments)a gate object, or NULL
cxagent_plugin_invokeIPlugin.Invokethe arguments object (AbiInvokeCall.Arguments)AbiResultEnvelope (JobResult)
cxagent_plugin_stopIPlugin.Stop—AbiResultEnvelope (void)
cxagent_plugin_poll (optional)IPluginClient.Submit, originated by the plugin rather than called into it—a submit request, or NULL
cxagent_plugin_command (optional)IPluginCommandHandler.RunCommanda command name and its typed argument stringCommandResult, or NULL
cxagent_plugin_attach (optional)the call's IJobContext reporting membersa cxagent_host callback table, not JSON—

Every export carries CXAGENT_PLUGIN_EXPORT, which the header defines as __declspec(dllexport) on Windows and default visibility elsewhere. Without it a Windows DLL exports nothing, and a library built with hidden visibility exports nothing either; the host then finds no cxagent_plugin_abi_version and refuses the file.

IPlugin.Load is split across two ABI calls deliberately: describe returns the manifest with no context (called before the plugin has seen anything plugin-specific), and start is where the plugin first receives its working directory and settings. The managed IPlugin.Load(context, ct) folds both into one call because a managed plugin can safely do both in one method; a native plugin cannot describe itself meaningfully before its own static initializers have run, but can before it has a working directory, so splitting the two costs nothing and gives the host a manifest to validate before it commits to starting the plugin at all.

pluginContract — the handshake

cxagent_plugin_abi_version() returns a bare int32_t, not JSON. This is the one function exempt from "everything is JSON," because the version check has to precede parsing, so it cannot itself depend on a JSON shape the host might not understand yet. Its signature can never change.

The current constant is 4. A range, not exact equality — see cxagent_plugin.h's own comment. The host refuses a version above what it understands (it may require something never seen) and a version below its floor (support for it was dropped); a version within the range may omit a capability a newer contract added — cxagent_plugin_poll among them — without failing the handshake. A host meeting a version outside that range refuses the load and names both versions in its error; it does not attempt to read an unfamiliar manifest with familiar assumptions.

v2 added cxagent_plugin_gate, so every v1 plugin is refused rather than degraded. That is the handshake working as intended: a v1 library has no gate to call, and a host that guessed at the absence would be deciding permission questions on behalf of a plugin that never answered one. The floor sits at 2 today for exactly this reason — see cxagent_plugin.h's handshake comment.

v3 added cxagent_plugin_poll, and it is the one export that does NOT raise the floor. Unlike gate, poll is optional: a v2 library omits it entirely and loads on a v3 host exactly as it always did, because a plugin that never originates work has nothing wrong with omitting the ability to. See "poll — a plugin originates work" below.

describe — the manifest

{
  "pluginContract": 2,
  "name": "lsp-rust",
  "version": "1.0.0",
  "instructions": "These tools talk to a running language server...",
  "spawns": true,
  "tools": [
    {
      "name": "lsp_definition",
      "description": "Finds where the symbol at a file position is declared.",
      "inputSchema": { "type": "object", "properties": { "...": "..." } },
      "gated": false
    },
    {
      "name": "lsp_rename",
      "description": "Renames a symbol across the solution.",
      "inputSchema": { "type": "object", "properties": { "...": "..." } },
      "gated": "dynamic"
    }
  ]
}

Every field but pluginContract mirrors CxAgent.Core.Plugins.PluginManifest and PluginToolManifest field for field — same names, same optionality — because an ABI plugin's manifest must be indistinguishable from a managed one to everything downstream of the loader: PluginRegistry, the collision matrix, and the load-gate prompt read a PluginManifest, not "a managed one" or "an ABI one." pluginContract is the one addition, carried in the manifest body as well as returned by the handshake function — deliberately redundant: the handshake function is checked first, before the manifest is trusted enough to parse, and the in-body copy lets a mismatch between the two be caught as a manifest error with a clear message rather than silently trusting whichever the host happened to read.

Unknown top-level keys are refused by name, not ignored — this already matches PluginManifest.Parse's existing behaviour (commands, completions, providers, observers each report "this build does not service" rather than being silently dropped) and the ABI surface changes nothing about it. A native plugin declaring a hook point v1 doesn't service is told so at load, the same as a managed one.

context — what start receives

{
  "workingDirectory": "/home/nick/source/cxagent",
  "settings": { "server": "rust-analyzer", "args": ["--log-level", "warn"] }
}

Deliberately smaller than IPluginContext. Three of that interface's five members have no JSON representation and are withheld on purpose, not merely because they are hard to serialize:

So context_json carries exactly the two fields a plugin needs to start doing its own work: workingDirectory (a string) and settings (the plugin's own settings object from config, verbatim — passed through unparsed, exactly as IPluginContext.Settings is a raw JsonElement today).

call — what invoke and gate receive

call_json is the tool's arguments object itself, for invoke and gate alike — the tool's name comes as tool_name, beside it:

{ "file": "src/main.rs", "line": 12, "character": 4 }

arguments mirrors JobParameters.Values — a flat-ish JSON object, the tool's own declared input schema. This surface does not introduce a typed parameter list (i64/f64/bool/string/...). JobParameters.Get<T> already tolerates untyped, occasionally mistyped, LLM-sourced JSON — string-encoded numbers, 0/1 for bool — and a native plugin receiving the same untouched JSON object gets the same latitude a managed plugin's call.Get<T>("line") already has. Introducing a second, stricter type system at the ABI boundary would fight JobParameters's own design rather than extend it. arguments is always a JSON object, never null and never omitted — an argument-less tool receives {}, so a plugin may index into it unconditionally exactly as cxagent_plugin.h states for call_json.

toolName is a plain string, not re-validated against the manifest by the wire format — the same division of responsibility IPlugin.Invoke's own doc states: "an unrecognised name reaching this method is this plugin's own bug, not a name the caller must additionally validate."

gate — one per-call permission decision

Called only for a tool whose manifest says "gated": "dynamic", and only before the call it is about. Receives the same arguments object invoke does (AbiInvokeCall.Arguments); returns either NULL or:

{ "display": "run: DROP TABLE users", "alwaysAskable": true }

NULL means this call needs no prompt. That is an answer, not a failure — it is what a plugin gating nothing returns for every call, and why every v2 plugin can export a gate of three lines.

display is wording, not scope. The host builds the permission itself and decides what an "always" grant would cover, so a plugin cannot widen a prompt about its own tool into a grant over anything else. alwaysAskable may only narrow: a manifest that already said false is not overridden.

Never return NULL to signal an error. The host reads NULL as "this call is fine". A gate that cannot decide should return malformed JSON or simply take too long — both read as "ask, and offer no standing grant".

It must be fast. The host calls this synchronously while deciding whether to interrupt the user, and abandons a gate that takes longer than a few hundred milliseconds — an abandoned gate asks. Inspect the arguments; do not open a connection.

poll — a plugin originates work

Contract 3 lets a plugin start a turn in its own session (IPluginClient.Submit, managed-side). cxagent_plugin_poll is how a native plugin reaches for the same ability without this host having to call into unmanaged code on its own initiative: the host calls poll periodically instead, and the plugin hands back a request only when it has one.

Optional, unlike every export above it. A library built before contract 3, or one that never originates work, omits it entirely — resolved by name at load time, and its absence is not a load failure. Declaring the client capability ("client": true in the manifest) without exporting poll is the native-side equivalent of a managed plugin declaring it without implementing IPluginClientConsumer: refused at load, naming the missing piece.

Returns NULL far more often than not, and that is the ordinary case, not a failure — an explicit exception to "a plugin must never return NULL" elsewhere on this surface. A plugin that allocated an empty object instead, to avoid ever returning NULL, would hand the host a fresh string to parse and free on every poll for no information gained.

The non-NULL shape:

{ "goal": "run the tests", "wantResult": false }

goal is required. wantResult is optional, defaults to false, and on this contract must be false or omitted — true is refused by name, not silently downgraded. Answering true would mean holding a call open across a turn that may run for minutes, over a pipe whose only inbound channel is a poll this plugin itself controls; the reply would have to arrive on some later poll, needing its own correlation on top of the one the host-to-plugin wire already has, for a capability no plugin has asked for yet. The field exists on the wire so a later contract can serve it without changing this shape again — this contract does not walk through that door. Fire-and-forget is therefore the whole contract: a plugin that polls a submit gets no reply on this channel, ever — not success, not failure, not the turn's own result.

May be called concurrently with cxagent_plugin_invoke — nothing on this host serializes the two, matching invoke's own concurrency note above.

command — one command a person typed

cxagent_plugin_command is invoke's counterpart for commands rather than tools — the same relationship describe's own commands array has to its tools array. Where invoke's caller is a model reasoning about a JSON schema, command's caller is a person who typed /schedule tomorrow 9am: arguments is everything after the name, trimmed, exactly as they wrote it — "", never NULL, when there was nothing after the name — not a JSON object matching a schema.

Optional, unlike every mandatory export — the same shape of addition poll is: a library built before commands existed, or one whose manifest declares none, omits it entirely, and the host loads it exactly as it always has. Declaring a command in describe's own commands array without exporting this function is refused, but not at load — the host has no way to know an export exists without asking for it, so this is discovered the first time the command is actually run, and refused by name then.

The non-NULL shape:

{ "message": "...", "status": "reported" }

Field for field CxAgent.Core.Plugins.CommandResult: message is text for the transcript, or JSON null when the command has nothing to say, and status is one of "reported", "changed", or "refused" — the lowercase wire spelling of PluginCommandOutcome, matching how gated already crosses this boundary as a string a native author writes by hand rather than a numeric enum value.

MAY return NULL, the same explicit exception to "a plugin must never return NULL" that gate and poll carry: NULL means "ran, and said nothing," exactly {"message":null,"status":"reported"} without allocating a string to say so.

attach — reporting while a call runs

cxagent_plugin_attach is how a native plugin reaches what a managed one has on its call's IJobContext — ReportProgress, Log and ReportTextDelta. The plugin host calls it once, after the handshake and describe and before start, with a cxagent_host table of callbacks; the header lists the fields and the rules for each.

Optional, and the contract number does not change — the same rule as poll and command. A library without it loads exactly as it did, and its calls report nothing until they return.

Each report names a call, because calls may run concurrently. Before calling in, the plugin host records on the calling thread which request it is serving, so a NULL call_id — "the call on this thread" — is right for any report made inside cxagent_plugin_invoke. current_call() returns that same id as a string, for a plugin that hands work to a thread of its own: it copies the id inside invoke and passes the copy later. A report for a call that has already returned is dropped, so a late report cannot land on whatever call happens to be running next.

Between the two processes, each report becomes one line on the plugin host's stdout, beside the replies:

{"event":"progress","id":7,"percent":50,"text":"half"}
{"event":"log","id":7,"level":2,"text":"working"}
{"event":"stream","id":7,"text":"partial output "}
{"event":"log","id":0,"level":2,"text":"started"}

id is the request id of the invoke the report belongs to, and "event" is what tells the line from a reply, which has "ok" instead. Id 0 is no call: a log there is the plugin's own, and reaches the logger the plugin was loaded with; any other event with id 0 is dropped. The reader never fails on a report — one it cannot parse or place costs that report and nothing else.

The result envelope

One shape answers start, invoke, and stop — deliberately one envelope for all three, because a host reading three different shapes for "did this call succeed" is the kind of near-duplication this design avoids elsewhere (see PluginManifest's single sidecar-and-runtime shape).

{ "ok": true, "result": { "success": true, "output": { "locations": [] } } }
{ "ok": true }
{ "ok": false, "error": "language server is not running." }
FieldWhenRule
okalwaysrequired boolean. Absent or non-boolean is a malformed envelope.
resultok: true, from invoke onlya JobResult, below. Omitted for start/stop, whose managed equivalents return Task, not Task<JobResult>.
errorok: falserequired, non-empty string.

ok and JobResult.Success are not the same bit, and both exist for a reason. ok: false means the CALL ITSELF failed — the plugin could not produce a JobResult at all: a malformed argument, an unknown tool name reaching a plugin that treats that as a hard bug, a serialization failure. ok: true, result: { success: false, errorMessage: "..." } means the call completed and the tool failed on its own terms — a language server timeout, a file not found — exactly the distinction IPlugin.Invoke's existing managed contract already draws by returning a JobResult with Success: false rather than throwing. Collapsing the two into one boolean would lose ErrorMessage vs. PermissionDenied vs. a hard ABI-level fault as one bucket, which the managed side never had to make peace with because C# exceptions and return values were always two channels. The result JSON object is:

{
  "success": true,
  "exitCode": 0,
  "errorMessage": null,
  "permissionDenied": false,
  "decidedBy": null,
  "output": { "locations": [] },
  "logFile": null,
  "durationMs": 42
}

Field-for-field CxAgent.Core.Models.JobResult, with Duration (a TimeSpan) carried as durationMs (an integer) — the one field with no natural JSON primitive, sent as milliseconds because that is the unit every duration already displayed to a user in this codebase uses. Every other field is a direct name match. Output is Dictionary<string, object?>, i.e. an arbitrary JSON object — the same escape hatch JobParameters/JobResult already use managed-side; nothing here narrows it to a closed type vocabulary the way ConsoleEs's ServiceParameter.Type does, because JobResult.Output was never one.

Unknown envelope or result fields are ignored, not rejected — the forward-compatibility seam: a v2 plugin may add a diagnostic field a v1 host does not read.

Every failure mode, and what the host does

Native returnsHost behaviour
{"ok":false,"error":"..."}the call fails; error becomes the tool's ErrorMessage
{"ok":false} — no errorfails with a generated message naming the plugin and function
{"ok":true,"result":{...}} from invoke, valid JobResult shapethe JobResult, verbatim
{"ok":true} from start/stopsuccess, no result to read
{"ok":true} from invoke (missing result)fails — invoke promises a JobResult and did not send one
NULL pointerfails — a plugin must always return an envelope; see cxagent_plugin.h, "Why a plugin must never return NULL"
empty string, or invalid JSONfails, quoting a bounded prefix of what was returned
valid JSON but not an objectfails — the envelope must be a JSON object
object without okfails — malformed envelope

None of these throws a C# exception across the plugin-to-host process boundary — there is no such boundary to throw across; the host process is what turns a failed envelope, a closed pipe, or a dead process into a JobResult { Success = false } the rest of Core sees exactly like any other tool failure. That translation is the host process's job; this surface only defines what the envelope means once it arrives.

Memory ownership

One rule, stated once, applied uniformly to every string that crosses: the side that allocated a string frees it, using its own allocator. Concretely:

It is the only ownership rule that has no ambiguous case, which is the property an ABI boundary needs from it.

Cancellation

Does not cross the boundary as a signal cxagent_plugin_invoke can observe mid-call. The managed IPlugin.Invoke(toolName, call, context, ct) takes a CancellationToken; the ABI equivalent has no parameter for it, and cxagent_plugin.h's own comment says why: the function is synchronous C, and there is no safe way to interrupt native code already running inside it — no unwind path exists at this frontier for a token firing mid-call, the same reason an exception cannot cross it.

Cancellation is instead a HOST-SIDE consequence, not a native-side interrupt. When a call's CancellationToken is cancelled while cxagent_plugin_invoke is still in flight:

This is a real place where the managed contract does not translate across the ABI without distortion, and it is called out here rather than quietly bent: a managed plugin CAN observe cooperative cancellation mid-Invoke; a native one CANNOT be asked to, only abandoned by the host. The asymmetry is accepted rather than closed, because closing it would mean either blocking plugin_invoke behind a lock that lets the host inject a signal (reintroducing exactly the coupling that "the host does not serialize invokes" rules out) or running every native call pre-emptively on a killable OS thread (possible, but a question of how the host manages its own process and threads, not a change to this wire contract).