Skip to Content
Numinance
Integrations

Plugin lifecycle

The full life of a provider process, from the host launching it to shutdown, with the exact RPCs, bounds and failure behavior of plugin/v1.

View as .mdUpdated
Available in public spec

The protocol, the handshake constants and the stream checks are in the public tree. The plugin host that runs this lifecycle is part of the proprietary runtime; the open tree contains the provider side of the contract and the reference checks a host applies, not a host.

A provider never starts itself, never chooses its own authority, and never writes evidence. The host launches it, reads what it claims to be, hands it one approved action at a time, and records what happened.

When to read this

Read this page if you are writing a provider, reviewing one, or deciding what the host can and cannot trust from a plugin process. The wire-level reference for each service is in PluginService and HostService; this page puts them in order and says what fails where.

The sequence

Plugin invocation sequence

In what order do host and provider talk, and who decides what?

Illustrative · simulated
Scenario

Policy allows the action; the provider runs it and every privileged step goes through the host.

HostProviderEgressEvidence

Host: Plugin host + policy decision point · Provider: Plugin subprocess · Egress: Broker / egress proxy · Evidence: Evidence writer (runner identity)

Step 1 of 14

Host launches the subprocess

exec + NUMINANCE_PLUGIN_COOKIE

Message and fields
  • NUMINANCE_PLUGIN_COOKIE = MagicCookie (plugin.CookieEnv)
  • NUMINANCE_PLUGIN_PROTOCOLS (offer)
  • host address env (plugin.HostAddrEnv)
Who holds authority
Host: it starts the process and offers the protocol versions. The host identifies the plugin by the connection it established at launch, never by request content.
✕ The provider can not
The cookie is not a secret and not authority: it only stops a user from running the provider binary by hand. A provider with a wrong cookie refuses to serve.

Allowed. Step 1 of 14: Host launches the subprocess. exec + NUMINANCE_PLUGIN_COOKIE. Host: it starts the process and offers the protocol versions. The host identifies the plugin by the connection it established at launch, never by request content.

▶ selected message◆ host policy decision✕ denied or rejectedName typed RPC from the spec
Text description

Illustrative and simulated: nothing here talks to a running host. RPC and field names are the ones in proto/numinance/plugin/v1 and sdk/go/plugin.

  1. The host starts the provider subprocess with NUMINANCE_PLUGIN_COOKIE set to the magic cookie, then calls Handshake, GetManifest and GetDescriptors on PluginService.
  2. The policy decision and the grant happen on the host, outside the model and before the provider sees the action.
  3. If allowed, the host calls Invoke (server-streaming) with an Envelope whose ActionContext the host sets. The provider may call back on HostService: RequestCredential returns an opaque handle that only works through the egress proxy, which injects the real token; EmitEvidence submits evidence that the host appends under the runner identity; ReportProgress reports progress.
  4. The stream ends with an ActionResult; the host records the outcome, may call Health, and finally Shutdown, which first revokes handles and egress grants.
  5. If policy denies, Invoke is never sent and the denial is recorded as evidence. If the provider requests a credential intent that is not in its declared credential_intents, the host rejects it and records the call.

The provider never holds authority: it cannot approve, widen a grant, see a credential, or append evidence.

Two services are involved. PluginService is served by the provider and called by the host. HostService is served by the host and called by the provider while it serves an Invoke. Neither is ever exposed as a model tool and neither is an action target.

StepDirectionRPC or mechanismWhat the host decides
1. Launchhost to processexec with three environment variablesWhich binary, which protocol versions to offer
2. Announceprovider to hostone line on stdoutWhether the line is well formed and the socket path is acceptable
3. Negotiatehost to providerPluginService.HandshakeWhich version and capabilities are agreed
4. Declarehost to providerPluginService.GetManifestWhether the manifest equals the installed one
5. Describehost to providerPluginService.GetDescriptorsWhether the provider's port files match the host's own copy
6. Decideinside the hostpolicy evaluation and grant checkAllow or deny, before the provider is told anything
7. Invokehost to providerPluginService.Invoke (server stream)Builds the Envelope and ActionContext
8. Call backprovider to hostHostService.*Validates each call against the invocation in flight
9. Recordinside the hostevidence append under the runner identityWhat is written; the provider only submits
10. Healthhost to providerPluginService.HealthWhether the provider accepts calls
11. Shutdownhost to providerPluginService.ShutdownCancels, revokes, then kills after a bounded grace

The host starts the provider as a subprocess and passes three environment variables. The names and values are constants in sdk/go/plugin/handshake.go.

sdk/go/plugin/handshake.go
const (
	CookieEnv    = "NUMINANCE_PLUGIN_COOKIE"
	ProtocolsEnv = "NUMINANCE_PLUGIN_PROTOCOLS"
	HostAddrEnv  = "NUMINANCE_HOST_ADDR"
	MagicCookie  = "numinance.plugin.v1/6d0b2f94-3c1e-4a58-9f7d-1e5b8a2c4d63"
)

The cookie is not a secret and not authority. Its only job is to stop someone from running a provider binary from a shell and being puzzled by a handshake line. A provider whose cookie is wrong refuses to serve. HostAddrEnv carries the host's HostService address as unix: plus an absolute path, and the provider's environment is otherwise only these variables.

The handshake line

After checking the cookie, plugin.Serve picks the highest protocol version both sides speak, listens on a Unix socket inside a new directory with mode 0700, and prints one line on stdout in the go-plugin wire format, CORE-PROTOCOL-VERSION|APP-PROTOCOL-VERSION|NETWORK-TYPE|NETWORK-ADDR|PROTOCOL. A host reads at most 1024 bytes and rejects any line that:

  • does not have exactly five fields (a sixth field, the go-plugin mutual TLS certificate, is refused: v1 carries none because the socket is reachable only by the host and the provider),
  • has a core version other than 1, an app version below 1, a network other than unix or a protocol other than grpc,
  • names an address that is not an absolute path of at most 103 bytes, or contains ., .. or empty segments, or a non-printable character.

A rejection names the field and never its value, because the line comes from a process the host does not trust.

1|1|unix|<tmpdir>/nump427867795/p.sock|grpc
✓ verifiedthe sample provider launched with the cookie and one offered protocol version (path shortened)· numinance-spec @ 3c74d59 · 2026-11-09· 0

The line was printed by the sample-knowledge binary built from the pinned spec checkout, started with NUMINANCE_PLUGIN_COOKIE set to the value above and NUMINANCE_PLUGIN_PROTOCOLS=1, then stopped with an interrupt. Starting it with NUMINANCE_PLUGIN_PROTOCOLS=2 instead fails with the host serves no protocol version this provider speaks (1) and exit status 1. Binding the Unix socket is blocked inside this documentation's sandbox, so that launch was run outside it.

Handshake

Handshake negotiates the protocol version, the spec version and the non-Core capabilities.

proto/numinance/plugin/v1/plugin.proto
message HandshakeRequest {
  string spec_version = 1;
  repeated uint32 protocol_versions = 2;
  repeated string ports = 3;
  repeated string capabilities = 4;
}

message HandshakeResponse {
  uint32 protocol_version = 1;
  string spec_version = 2;
  repeated string capabilities = 3;
}
  • protocol_versions must equal the NUMINANCE_PLUGIN_PROTOCOLS offer. At install both carry the host's full set. At run time both carry only the version locked at install, so a provider cannot talk the host down to a lower protocol later.
  • HandshakeResponse.protocol_version must be one of the offered versions and equal the version on the stdout line. A zero or unoffered value aborts the handshake.
  • Negotiated capabilities must be a subset of both the host's offer and the manifest. Any entry not in both aborts the handshake. Core verbs are never negotiated; only vendor features beyond the core are.
  • ports lists the installed manifest's ports, each at the major it declares. A port the host cannot serve at that major fails install.
  • spec_version is recorded in lifecycle evidence. It never selects the host's validation path.

Manifest and descriptors

GetManifest returns the manifest the provider declares, at most 1 MiB serialized. It must equal (proto.Equal) the manifest decoded from the installed package's manifest.yaml, whose digest the lockfile pins, or the host refuses to start the plugin. It is never a source of intents or capabilities the install did not review. See Manifest and capabilities.

GetDescriptors returns a FileDescriptorSet of the ports the provider implements, at most 2 MiB and 64 files. For every port the host serves, the host's compiled copy is authoritative:

  • A shared port file that differs from the host copy in any option fails install.
  • An RPC that is not in a port file the host serves is not dispatchable.
  • Plugin-private files must not use the numinance. or google. package prefixes and must not extend any google.protobuf options message.
  • Options are always read through the host's own registry, and source_code_info of plugin-supplied files is discarded.

The practical consequence: a provider cannot redefine a method's risk class, execution mode or dry-run support to change how the host treats it. In Go, plugin.DescriptorSet(file) builds this answer from the generated port file and everything it imports.

Invoke

Invoke is the only action-carrying RPC, and it only carries an action whose target is a method in a numinance.ports.* package. The target's annotations in the host's descriptor copy govern the call, never Invoke's own.

proto/numinance/plugin/v1/plugin.proto
rpc Invoke(InvokeRequest) returns (stream InvokeResponse)

The host builds the Envelope: the full method name, the ActionContext (invocation id, change id, run id, actor kind and principal, grant id, policy decision id, plan and profile digests, idempotency key, deadline, budget, residency, dry run) and the typed request, at most 4 MiB. The host never dispatches without a policy decision. The provider never supplies or changes the context. See Envelope and ActionContext.

The stream shape is bounded and checked:

RuleBound
Messages in a streamAt most 1024
A RUNNING messageAt most 64 KiB, and only status and diagnostics (plus one stream item on a server-streaming target)
The terminal messageAt most 4 MiB, the only one carrying evidence_ids, side_effects and cost
Non-OPERATION targetExactly one message
ResultRequired on SUCCEEDED, absent otherwise, with the method's output type URL
Unknown fieldsRejected at any depth

sdk/go/plugin.StreamCheck is the reference implementation of these rules. A host calls Next for each message as it arrives and End when the stream closes, so it never buffers the stream. The first failure is sticky.

sdk/go/plugin/stream_check.go
func NewStreamCheck(method protoreflect.MethodDescriptor, rule PortRule) (*StreamCheck, error)
func (c *StreamCheck) Next(m *pluginv1.ActionResult) error
func (c *StreamCheck) End() error

A stream that breaks any rule is rejected and recorded as failed. A provider rejection with no stream message may only end FAILED.

HostService callbacks

While serving an Invoke, a provider can call the host. Every request names the invocation_id the host assigned; any other id is rejected, so a provider can act only inside the authority of the call it serves. The host identifies the calling plugin by the connection it established at launch, never by request content.

RPCRiskWhat it doesPer-invocation limit
LogLOWOne structured log entry with a reason code and bounded fields, redacted by the host1024
EmitEvidenceLOWSubmits one evidence item; the host validates it and appends it256
RequestCredentialPRIVILEGEDReturns a handle for a declared credential intent, never credential material16
ResolveReferenceREADStreams the content of a referenced artifact in chunks of at most 64 KiB, after verifying its sha25664
ReportProgressREADReports completed and total, never decreasing1024

At most 2048 HostService calls per invocation in total. A call over any count is rejected and the invocation is recorded FAILED.

Three details matter for security:

  • Credential handles. RequestCredential returns an opaque handle, an expires_at no later than the invocation deadline, and an egress_proxy address. The provider sends a request carrying the handle to that proxy as an absolute-form HTTP request for a destination its manifest declared. The proxy applies the credential and re-originates the request over TLS. With dry_run set, the broker issues a handle only for a read-class intent.
  • References, not content. ResolveReference serves only a Reference found by walking the method's input type with the host's descriptor copy. A reference marked host_only is refused regardless of how it is spelled.
  • Evidence is submitted, not appended. EmitEvidence takes an emit_key unique per item (a replay with identical content returns the same evidence_id), a record_type that the capability declared and the host allows plugins to emit, and a payload of at most 16 KiB. Lifecycle, policy decision, gate, approval and credential-issuance records are host-authored and rejected here. Record fields such as ids, actor, grant and timestamp come from the host's invocation state, never from the payload.

Evidence

The host records each lifecycle call in its own lifecycle evidence and each Invoke under the target's declared evidence. ActionResult.evidence_ids lists the ids EmitEvidence returned for this invocation; any other id is rejected. The host derives the outcome itself and never trusts the claimed status: PARTIAL is never claimed by a provider, the host derives it when declared evidence is missing. Denials are recorded too. The chain itself is described in Evidence and trust.

Health and shutdown

Health returns HEALTH_STATUS_SERVING or HEALTH_STATUS_NOT_SERVING; a not-serving plugin accepts no calls, and the zero value is rejected.

Shutdown asks the whole plugin to stop. Before calling it, the host cancels in-flight operations and revokes the plugin's credential handles and egress grants. The plugin starts no new work after it, and the host kills the process after a bounded grace period. The host may always kill the process. In the Go SDK, Runtime.Stop ends Serve; the sample's Shutdown answers first and then calls rt.Stop in a goroutine.

Failure semantics

What goes wrongWhat happens
Wrong or missing cookieServe returns ErrNotLaunchedByHost; the binary exits 1
No protocol version in commonServe returns an error; no handshake line is printed
Handshake line malformed or over 1024 bytesThe host rejects it with an error naming the field
Handshake response names a version or capability the host did not offerThe handshake aborts
Manifest differs from the installed oneThe host refuses to start the plugin
Port file option differs from the host copyInstall fails
Invoke stream breaks a bound or carries an unknown fieldThe stream is rejected and recorded as failed
Reported side effect above the capability's declared oneThe call fails
Declared evidence missing from a succeeded callThe host derives PARTIAL
HostService call over a limit, or an invocation id not in flightThe call is rejected; over a limit, the invocation is recorded FAILED
Deadline passesThe host cancels the call
Shutdown not honoredThe host kills the process after the grace period

Troubleshooting

  • The binary prints the "started by the plugin host" message. Expected. It was run by hand; see Write a plugin in Go.
  • The host rejects your descriptors. You shipped a modified port file, or a private file under a reserved package prefix. Use the generated port descriptors unchanged.
  • A call fails with a bound error naming messages[3]. Paths are index based and never echo values. Check the stream bounds above against what your provider sent.

Next steps

On this page