Write a plugin in Go
Build a capability provider with the Go plugin SDK, using the sample knowledge provider as a line-by-line reference, and prove it against the conformance kit.
The Go plugin SDK and the sample provider are in the public tree and build today. The sample is a teaching and conformance target, not a product connector. There is no plugin host in the open tree, so a provider you write cannot be launched end to end by open code alone.
main that calls plugin.Serve, a PluginService that returns an honest manifest and descriptors, and a dispatch that decodes the envelope and answers with one typed result. Build once against the contract.When to use this
Use this page when you want the exact shape of a working provider before writing your own, or when you need to know what the SDK does for you and what it leaves to you. It supersedes the older adapter development outline; the guide at Build a custom plugin is the task-oriented version, and First plugin walks the first build.
Prerequisites
- A clone of
numinance-specand the Go toolchaingo.modasks for (go 1.27,toolchain go1.27.3). See Install. - Familiarity with gRPC server-streaming in Go.
- The port you implement. Read its
.protoheader comment first: it states who may call it, the authority model, and the bounds.
Imports and module path
The module path is github.com/numinance/numinance-spec. The repository is cloned from github.com/dsha256/numinance-spec, but the Go module name inside go.mod is the one you import. The sample uses the following imports.
import (
"context"
"fmt"
"os"
"os/signal"
"google.golang.org/grpc"
"github.com/numinance/numinance-spec/plugins/examples/sample-knowledge/provider"
"github.com/numinance/numinance-spec/sdk/go/plugin"
)import (
annotationsv1 "github.com/numinance/numinance-spec/sdk/go/gen/numinance/annotations/v1"
pluginv1 "github.com/numinance/numinance-spec/sdk/go/gen/numinance/plugin/v1"
knowledgev1 "github.com/numinance/numinance-spec/sdk/go/gen/numinance/ports/knowledge/v1"
typesv1 "github.com/numinance/numinance-spec/sdk/go/gen/numinance/types/v1"
"github.com/numinance/numinance-spec/sdk/go/plugin"
)sdk/go/plugin is the handwritten runtime support (handshake, Serve, DescriptorSet, StreamCheck). The sdk/go/gen/... packages are the generated stubs of the protocol files.
The entry point
main.go is the whole process wiring. This is the complete file.
// Command sample-knowledge is the sample knowledge provider: a capability provider of numinance.ports.knowledge.v1 over a
// small in-memory source. A plugin host launches it as a subprocess; run by hand it refuses and says so.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"google.golang.org/grpc"
"github.com/numinance/numinance-spec/plugins/examples/sample-knowledge/provider"
"github.com/numinance/numinance-spec/sdk/go/plugin"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "sample-knowledge:", err)
os.Exit(1)
}
}
func run() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
digest, err := provider.ExecutableDigest()
if err != nil {
return err
}
return plugin.Serve(ctx, os.Getenv, os.Stdout, func(s *grpc.Server, rt plugin.Runtime) {
provider.New(digest, rt.Stop).Register(s)
})
}What plugin.Serve does for you:
- Checks
NUMINANCE_PLUGIN_COOKIEagainstplugin.MagicCookieand returnsplugin.ErrNotLaunchedByHostotherwise. - Parses the host's protocol offer and picks
AppProtocolVersionif it is in the offer. - Listens on a Unix socket inside a new
0700directory and removes it on return. - Calls your
registerfunction with the gRPC server and aplugin.Runtime. - Prints the handshake line and serves until the context ends or
Runtime.Stopis called.
What Runtime offers
type Runtime struct {
// HostAddr is the gRPC target of the host's HostService, "" when the host passed none.
HostAddr string
// Stop ends Serve; PluginService.Shutdown calls it after it answers.
Stop func()
}Stopis how yourShutdownhandler ends the process after it has answered. The sample calls it in a goroutine so the reply is sent first.HostAddris how you reachHostService. The sample is read-only and ignores it. A provider that needs a credential handle or emits evidence dials it: the official GitHub providers callghprovider.DialHost(rt.HostAddr)and, if that fails, callrt.Stop().
The PluginService
The provider type embeds the generated UnimplementedPluginServiceServer and implements the six lifecycle RPCs. It is shown in full; the numbered comments below the listing explain each choice.
Newbuilds the manifest once.KindisEXTENSION_CLASS_CAPABILITY_PROVIDER,Portsnamesnuminance.ports.knowledge.v1, and each of the three verbs gets aCapabilityDescriptorwithRiskFloorREAD,SideEffectREAD andIdempotencyNATURAL, because a read-only verb may not claim more.ConformanceSuitenames the kit the host will look up.ExecutableDigesthashes the running binary so the declared digest is real.mainpasses it in.Handshakeaccepts only a protocol version equal toplugin.AppProtocolVersionand answers with the spec version the sample was built against. It declares no non-Core capability.GetDescriptorsreturnsplugin.DescriptorSet(knowledgev1.File_numinance_ports_knowledge_v1_knowledge_proto): the generated port file and everything it imports, source info dropped. Do not edit these.Invokerefuses a call that has no host-assignedinvocation_idorpolicy_decision_id, dispatches byEnvelope.Method, and sends exactly one terminal message:SUCCEEDEDwith the packed result, orFAILEDwith oneDiagnosticwhoseReasoncomes from the closed vocabulary. There is no free text, so no provider output can inject instructions into a log.dispatchchecks the request's type URL equalstype.googleapis.com/plus the method's input type before unmarshalling, and answers an unknown method withUnimplemented.
The source
The provider calls a small in-memory Source. The code shows how a provider validates inputs against the port's bounds and never repairs them.
Notes on what it models, because these are the habits a real provider needs:
- Every request is validated against the port grammar (query length, scope entries,
max_resultsbetween 1 and 100) before the source is touched. A violation returnsINVALID_ARGUMENT. - Scope entries are compared as whole segments, never as raw string prefixes, and are never decoded or normalised, so
kit/%2E%2E/otherfails the grammar. - Returned text is cleaned: control, format and bidi characters become U+FFFD, and truncation happens at a character boundary.
- A document outside the requested scope is
NOT_FOUND, neverPERMISSION_DENIED, so existence is not leaked.
Build it
These commands were run against the pinned checkout in the documentation environment. The offline module cache and a local toolchain were needed there; with network access a plain go build is enough.
export GOTOOLCHAIN=local GOCACHE=$TMPDIR/gocache GOMODCACHE=$HOME/go/pkg/mod GOPROXY=off GOFLAGS=-mod=mod
go build -o "$TMPDIR/sample-knowledge" ./plugins/examples/sample-knowledge(no output; exit 0)The only message printed was a Go cache warning about writing a stat-cache entry that the sandbox denied; it did not affect the result.
Run it by hand to see the guard:
sample-knowledge: this binary is a Numinance provider and is started by the plugin hostgo vet ./plugins/examples/... exited 0. go test ./plugins/examples/... printed [no test files] for both packages: the sample has no tests of its own. Its tests are the conformance kit.
Test it against the conformance kit
conformance/knowledge/v1 is a Go package, knowledgekit, that runs a fixed list of cases against anything implementing a three-method Target interface.
type Target interface {
Search(context.Context, *knowledgev1.SearchRequest) (*knowledgev1.SearchResponse, error)
Fetch(context.Context, *knowledgev1.FetchRequest) (*knowledgev1.FetchResponse, error)
Resolve(context.Context, *knowledgev1.ResolveRequest) (*knowledgev1.ResolveResponse, error)
}The kit judges only what comes back. How you reach the provider is up to you: a real host dispatching Invoke, a replay, or a direct call. The kit's own test calls the sample's Source directly with a thin adapter that translates the provider's CallError into the kit's, and runs it with knowledgekit.Run(t, direct{provider.NewSource()}). A second test feeds the kit three deliberately broken providers (one that ignores scope, one that accepts kit/../other, one that splices query text) and asserts each fails at least one case.
go test -count=1 -v ./conformance/knowledge/...--- PASS: TestKit_TheSampleProviderPassesTheSkeleton (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-001 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-002 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-003 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-004 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-005 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-007 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-008 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-012 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-013 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-014 (0.00s)
--- PASS: TestKit_TheSampleProviderPassesTheSkeleton/CORE-015 (0.00s)
--- PASS: TestKit_FailsABrokenProvider (0.00s)
--- PASS: TestKit_CasesAreListedInTheREADME (0.00s)
PASS
ok github.com/numinance/numinance-spec/conformance/knowledge/v1 0.186sWhat this does not prove. The kit runs eleven of the cases in the README table against a seeded source. The remaining cases (principals, a one-megabyte document, a broken or unreachable source, a rate-limited source) need fixtures the skeleton does not carry. The host checks (scope refusal before Invoke, digest and bounds checks, unknown-field rejection, egress against metadata and private addresses) are enforced by the plugin host, which is not in the open tree. A pass here is evidence that your verb logic matches the port's core cases; it is not a pass of the full port and not a security review.
Write your own
Choose the port and read its header
The header of proto/numinance/ports/<port>/v1/<port>.proto states callers, authority and bounds. Take the generated stub from sdk/go/gen/numinance/ports/<port>/v1.
Declare honestly
For each verb you implement, add a CapabilityDescriptor at or above the port's risk and side-effect floors. Declare every credential, network and filesystem intent you need and nothing else. If a verb is above READ, declare the evidence it emits. See Manifest and capabilities.
Serve with the SDK
Call plugin.Serve with your register function. Return the generated descriptor via plugin.DescriptorSet. Dispatch on Envelope.Method, check the request type URL, and answer with exactly one terminal message on a unary-shaped verb.
Never hold credentials
Ask HostService.RequestCredential for a handle and send requests through the egress proxy the response names. Do not read tokens from the environment: your environment is the three handshake variables.
Build a Target and run the kit
Write a test that adapts your provider to the port's Target and calls the kit's Run. Add your own tests for what the kit does not cover.
Failure modes
| Symptom | Cause |
|---|---|
Serve returns ErrNotLaunchedByHost | Started without the cookie. Expected outside a host |
the host serves no protocol version this provider speaks | NUMINANCE_PLUGIN_PROTOCOLS does not include plugin.AppProtocolVersion |
| Kit case fails with a reason mismatch | A bound violation returned the wrong Reason (for example NOT_FOUND for an invalid argument) |
| Host would reject a stream | A RUNNING message carried a result on a unary target, or a terminal message carried none on success |
| Install would refuse the package | Placeholder SBOM and provenance references, as in the sample |
Next steps
Manifest and capabilities
What a plugin declares before it runs, side effects, credential, network and filesystem intents, digest, provenance and permission changes, and how the host treats each declaration.
Official providers
The four official providers and one sample in the public tree, what each does, how credentials and egress work, and exactly what is tested and what is not done.