plugin — authoring-recipes
Detail page of the plugin recipe card.
One Provider, transport-invisible
Section titled “One Provider, transport-invisible”Every reserved word — every kind, verb, deploy-target, step, builder, command, build — is served by ONE Provider
(charly/provider.go): Reserved() string, Class() ProviderClass, Invoke(ctx, *Operation) (*Result, error). A Provider is IN-PROCESS (a builtin, registered from init()) or OUT-OF-PROCESS (an external,
served over go-plugin gRPC). The registry (providerRegistry, provider_registry.go), the call sites, and
the bijection gate treat both identically — the transport is invisible above the registry. A
check: step’s authored <word>: <input> verb sugar (desugared at parse time to the internal
plugin/plugin_input pair — authoring that pair directly is a hard load error) dispatches through
runPluginVerb → providerRegistry.ResolveVerb(word) → Invoke, whether the provider is compiled in or
out-of-process. A verb plugin declares its scalar-sugar primary: field both in its served capability and
in its candy manifest’s plugin: block (primary: {<word>: <field>}).
The build class (BUILD-ENGINE DISPATCH). ClassBuild serves build:box + build:generate
(candy/plugin-build, COMPILED-IN): charly box build / charly box generate route through it instead of
calling NewGenerator inline. The plugin’s Invoke (op sdk.OpBuild) OWNS the podman DRIVE (the
build-order loop, per-image build lock via kit.AcquireImageBuildLock, podman build, push, and the
inline-merge gate) IN the candy — it imports only the sdk module. It reaches the host for what a sdk-only
candy cannot hold — buildengine-scan-local (local candy scan), buildengine-connect-plugins (registry plugin connect), and buildengine-prep (the render-seam-floor renderGenCache populate, which returns an EMPTY map; the host-fs PREP itself moved plugin-side — runHostFSPrep in candy/plugin-build/host_prep.go). The resolved-project envelope + drive-model come from the plugin’s resolveBuildEngine reply (candy/plugin-build/resolve.go), NOT from buildengine-prep. The Containerfile
RENDER’s host-coupled seams (RenderService, the builder resolves, ValidateEgress, EmitPluginOp, localpkg) via HostBuild("render-seam", …), and bake_plugin: binary baking INLINE via deploykit.EmitBakedPlugins (no HostBuild). The layer merge is externalized to verb:oci
(candy/plugin-oci — no HostBuild("merge") seam). build:box runs the full drive; build:generate calls the SAME
resolveBuildEngine with generateOnly=true (a PLUGIN-SIDE parameter, NOT a HostBuild("buildengine-prep") argument
— that host leg takes spec.ResolvedProjectRequest, which has no GenerateOnly field), returns after the envelope
projection (no drive-model), renders the Containerfiles itself via sdk/deploykit.Generator
(#67 render-DRIVE move — the host no longer renders), and returns the written paths (no podman).
The host-builder KINDS (buildengine-prep, render-seam) are class-generic action nouns, never the provider
WORDS (the F11 uniform-API gate TestNoSinglePluginAPISurface forbids a provider word on that surface); bake_plugin: baking is inline (deploykit.EmitBakedPlugins), so it has NO host-builder kind. The
Containerfile RENDER DRIVE (Generate / generateContainerfile) is in sdk/deploykit (#67 — driven by
plugin-build over the envelope + the render-seam reverse legs); the host keeps ONLY the thin buildengine-* shards + render-seam (the loader + resolve + envelope projection + drive-model moved plugin-side — K3 build-engine migration, the host render-leg is DELETED). See /charly-build:build +
/charly-build:generate.
In-proc reverse channel (compiled-in placement of HostBuild / the reverse channel). A COMPILED-IN plugin
has no go-plugin broker, so it cannot dial the gRPC reverse channel an out-of-process plugin uses.
inprocExecutorClient (charly/plugin_inproc_reverse.go) implements pb.ExecutorServiceClient by delegating
DIRECTLY to a host-side executorReverseServer (no socket); sdk.NewInProcExecutor + sdk.ContextWithExecutor
thread it onto the Invoke context, and sdk.ExecutorForInvoke(ctx, brokerID) (ctx-first, broker-fallback) is
the ONE accessor a plugin’s Invoke calls — so the plugin’s reverse-channel code is byte-identical compiled-in
vs out-of-process (placement-invisible). It deliberately does NOT reuse executorInvoker (which stays
grpcProvider-ONLY — that interface is the discriminator routing external verbs to ExternalPluginStep, and a
compiled-in provider must not collide with it); the in-proc reverse channel is threaded by the build dispatch
inline. This is the general foundation the “every builtin routes through the reverse channel” direction needs;
its first consumer is the build class.
Placement: builtin OR external, in-proc OR out-of-process — at deploy AND build time
Section titled “Placement: builtin OR external, in-proc OR out-of-process — at deploy AND build time”Placement is a free, per-plugin choice, invisible above the registry: a provider runs EITHER compiled into
charly (a builtin, in-process) OR dynamically loaded from an out-of-tree candy (an external, out-of-process
over go-plugin gRPC), and the SAME provider works in either placement with ZERO authoring change. The strategic
direction is that every internal/builtin provider migrates external over time — so author every plugin
placement-agnostic from day one, and design it to work either way.
Every class is placement-free — kind, verb, deploy-target, step, builder, AND command are ALL
external-capable. An external (out-of-tree) COMMAND plugin contributes a charly <word> CLI subcommand
dispatched OUT-OF-PROCESS: the declared word is prescanned into the Kong grammar before parse, and the provider
is LAZY-connected on the first actual charly <word> invocation, which forwards the pass-through args via
Invoke(OpRun). builtinCommandBase.Invoke returning “in-process only” is the BUILTIN command path ALONE (a
builtin command contributes via its static KongCommand() + Go Run handler and never serves itself
out-of-process) — NOT a class-level limit. Each class’s leg differs by lifecycle phase: verb/deploy/step/
builder ride build (OpEmit/OpResolve) and/or deploy (OpExecute); command rides CLI invocation (OpRun);
a kind rides CONFIG LOAD (OpLoad) — its serving plugin is recognized + connected at config-PARSE by the
F4 prescan (registerDeclaredKind + connectDeclaredKindPlugins, re-entrancy-guarded), so a kind: <word>
entity whose plugin is NOT compiled in decodes via runPluginKind during load. Reference (out-of-process-only):
candy/plugin-example-kind; loader mechanism: /charly-internals:go (plugin_prescan.go).
A plugin DECLARES its lifecycle PHASE (F9). Beyond its class, a capability declares a Phase (the
sdk.Phase* set: bootstrap → schema → load → build → runtime, default runtime) via
ProvidedCapability.Phase over Describe — the ordered point at which the kernel loads/invokes it. The
bootstrap phase runs BEFORE config validation/migration: the kernel invokes a bootstrap plugin’s
OpBootstrap on the RAW config bytes (runBootstrapPhase, in LoadUnified before the schema gate), applying
any transformed bytes it returns — so early-running capabilities (migrate, egress) can themselves be plugins.
Bootstrap plugins are compiled-in only (no validated config exists yet to discover an out-of-process
source). Reference: candy/plugin-example-bootstrap (a no-op returning the bytes unchanged). M15 (migrate) /
M16 (egress) move those in-core capabilities onto this phase machinery.
A kind decode is FLAT or STRUCTURAL (F5). A FLAT kind (the default) lands its OpLoad body OPAQUELY in
uf.PluginKinds[disc][name] (F4). A STRUCTURAL kind sets ProvidedCapability.Structural = true (the proto
structural field) in its Describe — its OpLoad returns a spec.Deploy (BundleNode) MEMBER TREE the host
folds into uf.Bundle, the SAME map the in-proc pod/candy decoders populate, so the entity participates in
deploy/check exactly like a builtin (the folded member goes through the SAME validateDeploy). This is the
channel that externalizes the structural kind decoders — ALL of them are now DONE: group (C2-group,
candy/plugin-group), the 5 deploy-substrate kinds pod/vm/k8s/local/android (C2-substrate,
candy/plugin-substrate — one provider serving all 5), and the LAST one, the candy box⊻layer factory
(C2-candy, candy/plugin-candy-kind) — all COMPILED-IN. The substrate consumer added the TEMPLATE-map fold
arm: a substrate node in standalone-TEMPLATE shape (a bare vm:/pod: — no from:/image:, no members) folds
into the typed map uf.Pod/uf.VM/uf.K8s/uf.Local/uf.Android, alongside the deploy-shape fold into
uf.Bundle; candy folds into uf.Box (image) / uf.Candy (layer). So EVERY authoring kind is plugin-served:
the #Node disjunction has ZERO built-in arms (#Node: {...} — a structural gate only) and spec.KindWords
is EMPTY. (candy’s bootstrap-critical box⊻layer routing — candyIsImage + buildCandy — STAYS core: the
discovered-candy pre-check calls it directly, and the COMPILED-IN candy plugin registers at init before any
load, so there is no bootstrap cycle. candy is Structural:false — it nests no deploy members — and routes
to the host’s foldCandyKind by an explicit disc branch.)
AUTHORED-member INPUT-threading (the enabler that makes group/substrate externalization real). A
structural kind’s whole POINT is preserving the node’s AUTHORED resource-member children (peers, nested
pod-in-pod, cross-member ${HOST:…} checks) — but they CANNOT ride op.Params: that JSON is unified against
the plugin’s CLOSED #<Kind>Input def, which the member subtree would violate. So the HOST pre-decodes the
authored member children via the SAME core buildBundleNode recursion the builtin path uses
(buildResourceMemberChildren — ONE member-decode source of truth, R3) and threads the decoded subtree to
OpLoad via op.Env (spec.StructuralKindLoadEnv{Members}). The plugin decodes only its kind-specific scalar
body from op.Params and ATTACHES the host-threaded members to its spec.Deploy reply — Members for a
targetless kind (group), Children for a workload — so the reconstructed uf.Bundle entry is BYTE-EQUIVALENT to
the FORMER builtin group’s in-proc decode — the invariant C2-group’s check-group bed + the
TestExternalStructKind_StructuralDecode byte-equivalence test both prove (${HOST:…} refs survive as
literals, resolved later by tree position). A
FLAT kind carrying member children is a HARD error (never a silent drop). The parser admits sub-entity children
under a recognized external STRUCTURAL kind (externalKindMayNestMembers), core non-resource kinds stay guarded.
Reference (out-of-process-only): candy/plugin-example-structkind (decodes deploy-config scalars from
op.Params, attaches host-threaded members); the host fold is runPluginKind (/charly-internals:go); the
byte-equivalence witness is TestExternalStructKind_StructuralDecode + the check-structkind runtime bed.
Rich-value variant (C2-substrate + C2-candy — the HOST-pre-decode+ECHO case). The op.Params-decode above
works for a kind whose value is SCALAR-simple (group’s #GroupInput). The 5 substrate kinds
(pod/vm/k8s/local/android) AND the candy box⊻layer factory have a RICH, core-referencing value
(#Vm/#Deploy/#LibvirtDomain/#Candy/#Box/… with host-canonicalized shorthand like tunnel:/port:)
that a plugin CANNOT re-decode soundly from op.Params nor validate with a self-contained schema. So
candy/plugin-substrate AND candy/plugin-candy-kind use the spec.StructuralKindLoadEnv.Standalone channel: the
HOST pre-decodes the WHOLE CANONICAL node via the core loader (buildBundleNode deploy / decodeNodeValue
template for substrate; candyIsImage + buildCandy for candy — the bootstrap-critical routing that STAYS
core), validates its value host-side against the KEPT #<Kind>Value / #CandyValue def
(validateKindValueCUE), and threads the canonical result via op.Env; the plugin is a PURE ECHO
(InputDef:"", no validateAuthoredPluginInput), and the host folds the echo into uf.Bundle (deploy) / the
typed template map uf.Pod/uf.VM/… (template) / uf.Box (candy-image) / uf.Candy (candy-layer).
Byte-equivalence over ALL shapes: TestSubstrateKind_BothShapesByteEquivalent +
TestCandyKind_BothShapesByteEquivalent (against the direct core decode) + box validate across all repos (candy
is THE core entity) + the check-substrate runtime bed. References: candy/plugin-substrate,
candy/plugin-candy-kind (distinct from candy/plugin-candy, the command:candy CLI plugin).
A kind may serve a DEEP OpValidate check (F7/C8). Beyond the static CUE input-def gate the host always
runs (validateAuthoredPluginInput — unifies the body against the served #<Kind>Input), a kind that sets
ProvidedCapability.Validates = true (the proto validates field) ALSO serves OpValidate: at load, the host
dispatches Invoke(OpValidate) with the body, and the plugin returns spec.Diagnostics ({Items: [{Severity, Message, Path}]}) — any error-severity item FAILS the load with the messages. Use it for checks CUE cannot
express (cross-field invariants, semantic rules). Reference: candy/plugin-example-kind (rejects the sentinel
marker: INVALID); dispatch is runPluginKind (/charly-internals:go).
See “Authoring an external COMMAND plugin” below.
- The perf invariant that makes placement free. A builtin dispatches through its typed in-proc fast path
(
CheckVerbProvider.RunVerb/KindProvider.DecodeNode/DeployTargetProvider.ResolveTarget/StepProvider.Emit*) and NEVER marshals the Op into the serializableInvokeenvelope; the JSON envelope (marshalJSON) is paid ONLY out-of-process. Choosing builtin therefore costs no envelope tax — placement is a free build/deploy decision, not a performance trade-off. Locked byTestPerfGate_BuiltinVerbsSkipEnvelope+BenchmarkVerbTypedDispatchFork(0-alloc) vsBenchmarkVerbEnvelopeMarshal(provider_bench_test.go). - Plugin↔plugin + host-build (F10). A plugin running WITH a reverse channel (deploy/step/check/build —
any Invoke the host stands a broker up for) can call BACK to the host to invoke ANOTHER plugin or request a
host-side build, via the
sdk.Executor:InvokeProvider(class, word, op, params, env)— the host resolves the peer in the registry and Invokes it on the caller’s behalf (threading the SAME venue executor into an out-of-process target over a nested broker — the host is the dispatch broker, since it owns the registry); andHostBuild(kind, spec)— the host runs the registered host-builder forkind— ~54 registered kinds today (the count drifts per cone;git grep 'registerHostBuilder(' charly/*.goresolving the*BuilderKindconstants is the authoritative list). The build/render-relevant ones: the 8buildengine-*kinds (buildengine-prepet al. — the thin host shards the candy’s plugin-sideresolveBuildEnginereaches: the local scan, the registry plugin connect, and the render-seam-floorrenderGenCachepopulate; REPLACING the former fatbuild-prep/build_resolve_host.goseam, DELETED, whose loader+prep+envelope+drive-model resolve all moved plugin-side),render-seam(the #67 render’s host-coupled seams: RenderService, builder resolves, ValidateEgress, EmitPluginOp, localpkg).bake_plugin:baking is INLINE viadeploykit.EmitBakedPlugins(the formerbake-pluginshost-builder is DELETED — no HostBuild). The rest:overlay(the pod overlay build),step-emit(host-coupled step fragments),plugin-binary(the F10 plugin host build),cli(run-any-charly-command reentry),hostprobe(doctor’s raw host facts),feature,render-service,retention-defaults,raw-project,validate-project-checks,remote-image-resolve,box-fetch-resolve,config-resolve(config-persist is DELETED — persist moved plugin-side to candy/plugin-vm/vm_host_persist.go), thedeploy-*-resolve/deploy-members-*/resolve-target-add/deploy-node-del-dispatch/deploy-plugins-connect/deploy-from-boxfamily, theloader-*andpod-*(lifecycle verbs) andpod-config-*families,arbiter-bracket-acquire+-release,construct-step,check-load-plugins,check-bed,check-run. The build engine is in core TODAY — K3 build-engine migration inventory, not permanent core — the box-build podman DRIVE moved to candy/plugin-build in P8b, and the Containerfile RENDER DRIVE moved tosdk/deploykit(#67, driven by plugin-build over the envelope + therender-seamreverse legs; the host render-leg is DELETED). This is the shared-capability seam: a SHARED plugin (egress, k8s-gen, arbiter) is “a plugin others invoke”, never “kept in core”. Reference:candy/plugin-example-dispatch; mechanism:/charly-internals:install-plan(plugin_dispatch_reverse.go). - Deploy time. An external deploy-target provider runs its full Add/Test/Update/Del lifecycle over the
host-served executor reverse channel — the plugin applies the deployment’s ops on the real venue it cannot
hold across the process boundary (
OpExecute), and the host records the returned teardown ops to the ledger. A bed/deploy that uses an external deploy SUBSTRATE word is recognized at config-PARSE time (before the provider connects) and routed host-side by the shared check classifier. A substrate may ALSO bring its OWN host-side venue LIFECYCLE + PRERESOLVE (F6): aclass:deploycapability declaringLifecycle=true/Preresolve=trueis read at plugin-connect into plainhasLifecycle/hasPreresolvebooleans threaded throughpluginDeployTarget(charly/unified_targets.go, S3b) andcandy/plugin-bundle’s genericInvoke(OpDeployDispatch)— there is no longer a SEPARATE wire-backedsubstrateLifecycle/deployPreresolverobject registered at plugin-load (both interfaces, and the core files that implemented them,charly/substrate_lifecycle_grpc.go+charly/deploy_preresolve.go, are DELETED). Insteadcandy/plugin-bundle’slifecycleInvoke/preresolveSubstrate(deploy_target.go) reach the substrate’sOpPrepareVenue/OpStart/OpStop/OpStatus/OpRebuild/OpPreresolvevia its OWNsdk.Executor.InvokeProvider(class:"deploy", word, op, …)(S1) — the SAME PLUGIN↔PLUGIN dispatch every other peer-invoke uses.OpPrepareVenuestill returns aspec.VenueDescriptorthe host re-materializes into a real executor (the live executor never crosses the wire), andOpPreresolvestill returns the opaqueDeployVenue.Substratepayload, generalizing the in-core k8s/android preresolvers. Reference (out-of-process-only):candy/plugin-example-lifecycle; mechanism:/charly-internals:install-plan(candy/plugin-bundle/deploy_target.go). This is the channel M4 reuses to externalize the pod/vm lifecycles. An externalrun:plugin verb / step composed INSIDE a deploy (alocal:/vm:target, where the install runs ON the target, not baked into an image) likewise EXECUTES at deploy: it lowers to anExternalPluginStepIR node which the externallocal:/vm:deploy walk reaches as a host-engine step overRunHostStep, where the sharedinvokeExternalStepdispatch (charly/plugin_executor_reverse.go, S4/R3)Invoke(OpExecute)s over the PLUGIN↔PLUGINInvokeProviderleg (a nested reverse channel delegating to the SAME venue executor), so the plugin runs its deploy-context effect on the target and RETURNS its teardownReverseOps, which the host records to the ledger and replays atcharly bundle del(record-and-replay, the SAMEspec.DeployReplywire the deploy-substrate dispatch uses — R3). Only an EXTERNAL provider is routed there (theexecutorInvokerdiscriminator, satisfied SOLELY by the out-of-processgrpcProvider); a builtinProvisionActorverb keeps its in-proc shell path. So the verb/step class is external-capable at BOTH build (OpEmit, next bullet) AND deploy (OpExecute), placement-agnostic. Detail →/charly-internals:install-plan(thepluginDeployTargetlifecycle + theExternalPluginStepIR kind + theOpExecutereverse channel). - Build time.
charly box build/charly box generateconnect the project’s external plugin candies during image generation, so a plugin EXECUTES at build to emit its Containerfile contribution, placement-agnostically (a builtin in-proc, an external over gRPC) — and BOTH the verb/step leg AND the builder leg ride the SAME connect seam, class-agnostically:- a
run:plugin verb / step returns a Containerfile fragment viaOpEmit→spec.EmitReply.Fragment, spliced verbatim into the Containerfile (egress-validated). - a builder (
ClassBuilder) returns a multi-stage build viaInvoke(OpResolve)→spec.BuilderResolveReply: itsStage(aFROM <ref> AS <name>block) is spliced PRE-main-FROM, itsCopyArtifacts+CopyBinary(COPY --from=<stage> …) POST-main-FROM, and an INLINE builder’sInlineFragmentin-candy. This serves BOTH the four DETECTION-builders (pixi/npm/aur/cargo — selected by a candy’s detect files, rendered via the sharedsdk/kit.BuilderResolve, C10) AND an out-of-tree builder a candy selects withexternal_builder: <word>. This is the build-time BUILDER leg — the multi-stage counterpart of the verb/step OpEmit leg, sobuilderis an external-capable class at build too (alongside verb/kind/deploy/step). Thecommandclass has no build-time leg — a command dispatches at CLI invocation, not at build — and is external-capable THERE viaInvoke(OpRun)(the Placement paragraph above), so EVERY class (kind/verb/deploy/step/builder/command) is external-capable. This is operator-authorized build-time execution of host-built plugin code: a project’s composed external plugins run as host code during its image builds. Detail →/charly-build:generate+/charly-internals:generate-source.
- a
The retired in-charly-module builtin path
Section titled “The retired in-charly-module builtin path”There is NO LONGER an “in-charly-module builtin” path — a Provider whose Go lived under the charly
module’s former plugin/builtins/<name>/ subtree and registered from package main’s init() via
RegisterBuiltinPluginUnit. It was retired as each builtin relocated into a candy: that
subtree is GONE, and the last unit, examplerunverb, is now the compiled-in
kit candy candy/plugin-examplerunverb/ (guarded by charly/plugin_examplerunverb_relocated_test.go).
The source: builtin candy-linkage form is likewise gone (exampleprobe is a real candy module).
Every builtin today is ONE of the two CANDY forms below — a candy COMPILED IN via compiled_plugins:
(“Authoring a COMPILED-IN plugin candy”) or a HOST-COUPLED check-verb KIT candy (“Authoring a
HOST-COUPLED check-verb candy”). Author new builtins as one of those; there is no in-charly-module
builtins subtree to mirror.
Authoring an EXTERNAL plugin (out-of-tree git repo)
Section titled “Authoring an EXTERNAL plugin (out-of-tree git repo)”The candy IS its own Go module — its go.mod carries require github.com/opencharly/sdk v0.0.0
plus, while in-repo, replace github.com/opencharly/sdk => ../../sdk; a PUBLISHED out-of-tree module
drops the replace and requires a TAGGED github.com/opencharly/sdk instead (tag scheme
v0.<YYYYDDD>.<HHMM leading-zeros-stripped>, e.g. superproject v2026.185.0751 ⇄ sdk v0.2026185.751 —
the superproject’s own vYYYY.DDD.HHMM tags are not valid Go module versions). A plugin module imports
ONLY the sdk module, never charly core. Mirror candy/plugin-example-external/:
schema/<name>.cue— the self-contained def (same shape as a builtin’s).params/cue_types_gen.go— generated the same way (the gen loop also covers the in-repo example).main.go—func main() { sdk.Serve(&provider{}, &meta{}) }; the provider decodesplugin_inputinto the generatedparams.<Word>Input;meta.Describereturnssdk.BuildCapabilities(calver, []sdk.ProvidedCapability{…}, schemaFS, "schema")(it//go:embedsschema/*.cue).BuildCapabilitiesconcatenates + compiles the schema STANDALONE (failing loudly before serving) and ships the source inschema_cue.- The candy’s
plugin.sourceis thegithub.com/org/repo/candy/<name>ref. charly’s loader fetches the repo (the same@githubresolver candies use),go builds the provider binary ON THE HOST, and connects out-of-process viaLocalTransport. The host build runs withGOWORK=offso a repo-rootgo.workcannot reject a non-member candy dir — the out-of-process build is always standalone in the candy’s own module.
Authoring a COMPILED-IN plugin candy (the candy compiled INTO charly)
Section titled “Authoring a COMPILED-IN plugin candy (the candy compiled INTO charly)”The SAME out-of-tree candy can be COMPILED INTO charly — the in-proc placement of a candy, selected
per-charly-build by charly.yml compiled_plugins:. This ships a plugin candy INSIDE the binary
WITHOUT its Go living in charly’s module (it rides go.work). candy/plugin-example-external/ is the
reference: it is BOTH the out-of-process example above AND the compiled-in example — one provider, two
placements, ZERO authoring change.
- The provider lives in the candy’s IMPORTABLE root package (
NewProvider() pb.ProviderServer+NewMeta() pb.PluginMetaServer+//go:embed schema/*.cue), NOTpackage main.main.gomoves tocmd/serve/as a 3-linesdk.Serve(<pkg>.NewProvider(), <pkg>.NewMeta())shim (the out-of-process entrypoint, host-built only when the candy is NOT compiled in). - List the candy in
compiled_plugins:(the embeddedcharly/charly.ymlfor the default binary; a consumer’s owncharly.ymlfor a custom footprint — “which plugins are in the binary” is a normal candy-inclusion choice). pluginsgen(charly/internal/pluginsgen, run bytask build:binarybeforego build,GOWORK=off, stdlib+yaml only) readscompiled_plugins:and emitscharly/plugins_generated.go(oneregisterCompiledPlugin(<pkg>.NewProvider(), <pkg>.NewMeta())per candy) + the repo-rootgo.work(use ./charly+use ./sdk+ ause ./candy/<name>per candy, sogo build ./charlyresolves the candy imports; pluginsgen guards a missing sdk submodule with a cleargit submodule update --init sdkerror). Both are COMMITTED + reproducibility-gated (TestPluginsGenReproducible).registerCompiledPlugindrivesInProcServedTransport: it calls the candy’sDescribeIN-PROCESS, runs the SAMEbuildUnitcapability-lift + schema gate an external goes through (so the compiled-in schema enters the SAMEloadBuiltinPluginUnitsgate), and registers each capability wrapped in aninprocProvider(origin"builtin") — the in-proc twin ofgrpcProvider. Dispatch is registry-routed, transport-invisible.- COEXIST: a candy compiled in (origin
"builtin") is SKIPPED byloadProjectPlugins(the out-of-process host build is redundant); a candy NOT incompiled_plugins:still host-builds + connects out-of-process when a plan references its word. Placement is a per-charly-build choice, invisible above the registry. - PERF: a compiled-in pb CANDY dispatches through the pb
Invokeenvelope IN-PROCESS (no socket) viainprocProvider, whereas a HOST-COUPLED KIT candy (next section) uses its typed fast path (RunVerb/DecodeNode, no envelope). The pb-candy path pays the JSON envelope but not the gRPC transport.
Authoring a HOST-COUPLED check-verb candy (the kit — dual-placement via the reverse channel)
Section titled “Authoring a HOST-COUPLED check-verb candy (the kit — dual-placement via the reverse channel)”A check verb whose logic needs the LIVE check engine — exec-in-container, host-vantage HTTP, host TCP
dial (file/http/port/command/service/…) — implements sdk/kit, the importable
contract for the check engine. It runs in EITHER placement, invisibly above the registry: COMPILED-IN
(charly passes the live *Runner as the kit.CheckContext) OR OUT-OF-PROCESS (the CheckContext legs are
served back over the host’s reverse channel — ExecutorService for cc.Exec() + CheckContextService for
cc.HTTPDo/cc.AddBackground, F2 — while the scalar legs Mode/Box/Instance/Distros/DialTimeout
ride the env_json snapshot). candy/plugin-port + candy/plugin-http are the reference (both served
OUT-OF-PROCESS in the default build); the remaining kit candies are compiled-in by default until M1 adds
their cmd/serve shim. Shape:
- The candy’s importable root package implements
kit.CheckVerbProvider(Reserved()+RunVerb(ctx, cc kit.CheckContext, op *spec.Op) kit.Result) — the verb’s logic (formerly ther.runX*Runner method) lives HERE, reaching the deployment throughcc.Exec().RunCapture/cc.Mode()/cc.DialTimeout()/cc.HTTPDo(...)/cc.Distros(). It exportsNewCheckVerb() kit.CheckVerbProvider- the raw schema
embed.FS(SchemaFS+SchemaDir) +InputDefs.
- the raw schema
- COMPILED-IN: charly’s
registerCompiledCheckVerb(generated intoplugins_generated.goby pluginsgen, which detects the kit shape by the exportedNewCheckVerb) wraps it in akitVerbAdapter(a package-mainCheckVerbProviderthat passes the live*Runneras akit.CheckContextviarunnerCheckContextand convertskit.Result→CheckResult), concatenates the candy’s schema (via the publicsdk/schemaconcat), and registers through the SAMERegisterBuiltinPluginUnitgate (origin"builtin"). Dispatch is the SAMErunOne→CheckVerbProvider.RunVerbpath a compiled-in candy verb uses — full typed fast path, the real*Runner, no envelope. - OUT-OF-PROCESS: a
cmd/serve/main.goshim callssdk.ServeCheckVerb(pkg.NewCheckVerb(), calver, pkg.SchemaFS, pkg.SchemaDir, pkg.InputDefs), which wraps the kit verb in a pb.ProviderServer whose Invoke reconstructs asdkCheckContextfrom the InvokeRequest broker (ExecutorService + CheckContextService) + the env_json snapshot, then runsRunVerb. The host go-builds./cmd/serve+ connects viaLocalTransportwhen the candy is NOT incompiled_plugins;invokeVerbProvider(provider_checkenv.go) serves BOTH reverse services on one broker id. The verdict round-trips as the same{status,message}every out-of-process verb returns. kitimports the stdlib +sdk/spec+sdk/vmshared+ the pinned external helpers (gopkg.in/yaml.v3,golang.org/x/sys/unix) — never thesdkroot module; the candy importskit+sdk+spec+ itsparams.
A kit candy keeps the verb’s logic (RunVerb on kit.CheckContext) OUTSIDE charly’s module while preserving
the typed fast path — runnable in-proc (compiled-in, the real *Runner, no envelope) OR out-of-process (the
reverse channel) with ZERO authoring change.
The SDK module (github.com/opencharly/sdk) is the ONLY module a plugin imports — root package
sdk (Serve, ServeCheckVerb (the kit-verb out-of-process serve entry), Handshake,
BuildCapabilities, ProvidedCapability, Conn, plus the shared out-of-process check-verb helpers
ResultJSON (the {status,message} reply) / CheckRequiredModifiers (the required-modifier check) +
the *Executor venue methods VenueCapture/VenueHasTool/VenueRunSilent), sdk/kit (the
pure-helper package, stdlib + sdk/spec only — ShellQuote, TrimPreview, MethodSpec,
WalkPlans, …; the SDK root imports it too), sdk/spec, and sdk/proto. schemaconcat is the
public sdk/schemaconcat package (the SDK uses it internally).
Authoring an external COMMAND plugin (a charly <word> subcommand)
Section titled “Authoring an external COMMAND plugin (a charly <word> subcommand)”A command:<word> provider is an external plugin (authored exactly like the verb-class one above — its own Go
module, self-contained schema/<name>.cue, generated params/, sdk.Serve + BuildCapabilities) that
contributes a TOP-LEVEL charly <word> CLI subcommand. Mirror candy/plugin-example-command/:
plugin.providers: [command:<word>]+source: github.com/org/repo/candy/<name>in the candy’splugin:block;Describereturns the capability withClass: "command", Word: "<word>".InvokehandlesOpRun(the command-run selector): the host forwards the user’s pass-through CLI tokens fromcharly <word> <args…>asop.Params = {"args": [...]}marshalled DIRECTLY — NOT wrapped in theplugin_inputenvelope a verb CHECK step uses. The provider decodes that into its generated typed params struct (e.g.params.<Word>Inputwith anArgs []stringfield) and does its effect.
The discovery → grammar → dispatch flow (host side, owned by charly/plugin_command_prescan.go +
provider_command_external.go): when the candy is discovered in the project, a byte-gated prescan
(prescanProjectCommandWords, run in main BEFORE kong.Parse) registers the declared word so charly <word>
PARSES; collectExternalCommandPlugins builds a Kong grammar holder for it with the provider UNconnected. The
host build + gRPC connect is LAZY — paid ONLY when the user actually runs the command: dispatchExternalCommand
calls connectCommandPlugin (scoped to the one word) then Invoke(OpRun, {"args":[…]}). So every charly
invocation that is NOT charly <word> is byte-for-byte unaffected. (A builtin command, by contrast, contributes
via its compiled-in CommandProvider.KongCommand() + Go Run handler — provider_command.go.)
A command candy can ALSO be COMPILED IN (F8) — placement-invisible like every other class. List its candy
in compiled_plugins: and it registers in-proc (an inprocProvider, Class command); the host builds the SAME
dynamic Kong grammar (externalCommandHolder, pass-through Args) and dispatchCommand routes it IN-PROC via
Invoke(OpRun) (dispatchInProcCommand) instead of syscall.Exec — the candy’s Invoke(OpRun) handler runs in
charly’s own process (native stdio/TTY). So author a command candy DUAL-PLACEMENT: an importable provider
package (NewProvider/NewMeta, Invoke(OpRun) runs the effect, Describe advertises command:<word>) + a
cmd/serve sdk.Main(..., CliMain) shim for the out-of-process path, both calling ONE shared effect (mirror
candy/plugin-example-command). This is the command half of compile-in-for-all-six-classes; the M-series moves
the dedicated builtin commands into candies on this surface.