Writing plugins
A custom plugin brings an external system into the same review as your application. It can inspect a feature flag, prepare test data or operate a system-specific workflow, then record the state and evidence a human needs to understand the result.
The RFL language, protocol, Go framework and TypeScript host are licensed under
Apache-2.0. Their source repository,
Resilient-Software/rfl, remains private while a public release is prepared.
Building a custom provider still requires access to that source; a standalone SDK
release is not available yet. The core license does not cover the separate Reflow
service or its bundled provider implementations.
Bundling a provider into Reflow also requires access to the private Reflow repository, catalog registration and matching builds. The public CLI bundles Browser, PostgreSQL, LocalBash, DockerUbuntu and Mailbox; it does not install arbitrary third-party binaries or discover a public plugin registry.
Choose the smallest integration
Section titled “Choose the smallest integration”Use an existing plugin when it already expresses the job: Browser for UI behavior, PostgreSQL for database checks and setup, or LocalBash/DockerUbuntu for an existing script. Write a provider when an external system needs its own command schema, private connection settings, stable state or comparison and rendering rules.
Keep the boundary specific. For example, flags.read name="checkout-redesign"
should observe that flag and return comparable evidence. A separate write command
would declare its own inputs, permissions and failure handling.
The provider contract
Section titled “The provider contract”Each provider is an independent Go module implementing
provider.Provider.
Reflow manages its binary over authenticated private gRPC. provider.Main supplies
the transport and --describe; your implementation supplies these methods:
| Method | Responsibility |
|---|---|
Descriptor | Name, protocol version, configuration schema, commands and capabilities. |
Bind | Validate and retain private configuration for this instance. It is called once before invocation. |
Invoke | Run a named command with the supplied context and return its terminal snapshot. |
Get | Read provider-defined state or requirements; it can be called before Bind. |
Compare | Compare two retained snapshots without reconnecting to the external system. |
Render | Produce Markdown and optional artifacts from a retained snapshot or comparison. |
Close | Release connections and owned resources, including after cancellation. |
The interface has no Migrate method. The
wire protocol
calls Descriptor through Describe and Close through CloseInstance.
Schemas are objects with named primitive properties: string, number, integer
and boolean, plus an explicit required list. The host and framework reject unknown
commands, unknown arguments and wrong types. Add semantic checks such as valid
identifiers or permitted URLs in the provider. Get and comparison options have
provider-owned JSON contracts; validate them where you use them.
Declare each command’s effect as observe or mutate. This is execution metadata,
not an operating-system permission boundary. Diagnostics use info, warning or
error; a diagnostic alone does not decide the command’s outcome. A completed
snapshot has a status of succeeded, failed or unknown. An optional boolean
Predicate is only valid on a successful observation, for prerequisite checks;
missing or failed evidence is never equivalent to false.
A command can also declare Returns: &provider.ScalarReturn{Type: "boolean"}
(or string, number, integer) and place its successful result in
Snapshot.Value as JSON. This lets a flow write
flags.read name="checkout-redesign" -> enabled. The host validates the
value independently of snapshot state. Missing values, wrong types and values on
failed or unknown results are rejected; false, 0 and "" remain valid.
This requires matching framework and host builds.
Example: inspect a feature flag
Section titled “Example: inspect a feature flag”This illustrative API answers GET /flags/checkout-redesign with
{"enabled":true}. The provider accepts an HTTPS origin and token through private
Bind configuration, then records only the flag name and enabled state. It has no
write command, and it never stores response headers or raw response bodies.
Create this module under plugins/ in your authorized rfl checkout, beside
reflow-plugin-framework:
plugins/reflow-provider-feature-flags/ go.mod cmd/reflow-provider-feature-flags/main.goStart go.mod with:
module github.com/Resilient-Software/reflow/plugins/reflow-provider-feature-flags
go 1.26.1
require github.com/Resilient-Software/rfl/plugins/reflow-plugin-framework v0.0.0
replace github.com/Resilient-Software/rfl/plugins/reflow-plugin-framework => ../reflow-plugin-frameworkThe example uses the Reflow organization namespace so it can be copied into the
product checkout later. The framework resolves locally from rfl; neither path
is a published SDK download.
The following is the complete cmd/reflow-provider-feature-flags/main.go:
package main
import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strings" "time"
"github.com/Resilient-Software/rfl/plugins/reflow-plugin-framework/provider" "github.com/Resilient-Software/rfl/plugins/reflow-plugin-framework/schema")
var validName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
type flagsProvider struct { baseURL, token string client *http.Client last *provider.Snapshot}
var _ provider.Provider = (*flagsProvider)(nil)
func (p *flagsProvider) Descriptor() provider.Descriptor { return provider.Descriptor{ Name: "FeatureFlags", ProtocolVersion: 1, Capabilities: provider.Capabilities, Configuration: schema.New(map[string]schema.Property{ "baseURL": {Type: "string"}, "token": {Type: "string"}, }, "baseURL", "token"), Commands: map[string]provider.Command{ "read": { Description: "Observe a feature flag", Effect: "observe", Returns: &provider.ScalarReturn{Type: "boolean"}, Arguments: schema.New(map[string]schema.Property{ "name": {Type: "string"}, }, "name"), }, }, }}
func (p *flagsProvider) Bind(ctx context.Context, raw json.RawMessage) error { if err := ctx.Err(); err != nil { return err } var cfg struct{ BaseURL, Token string } if json.Unmarshal(raw, &cfg) != nil { return fmt.Errorf("invalid configuration") } u, err := url.Parse(cfg.BaseURL) if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { return fmt.Errorf("baseURL must be an HTTPS origin") } if strings.TrimSpace(cfg.Token) == "" || strings.ContainsAny(cfg.Token, "\r\n") { return fmt.Errorf("a valid private token is required") } p.baseURL, p.token = strings.TrimRight(u.String(), "/"), cfg.Token p.client = &http.Client{ Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } return nil}
func (p *flagsProvider) Invoke(ctx context.Context, command string, raw json.RawMessage) (*provider.Snapshot, error) { if command != "read" || p.client == nil { return nil, fmt.Errorf("unknown command or unbound provider") } var args struct{ Name string } if json.Unmarshal(raw, &args) != nil || !validName.MatchString(args.Name) { return nil, fmt.Errorf("name must contain 1–64 letters, digits, underscores or hyphens and start with a letter or digit") } provider.ReportProgress(ctx, "Reading feature flag", 0, 1) req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/flags/"+url.PathEscape(args.Name), nil) if err != nil { return nil, fmt.Errorf("cannot prepare flag request") } req.Header.Set("Authorization", "Bearer "+p.token) resp, err := p.client.Do(req) if err != nil { if ctx.Err() != nil { return nil, ctx.Err() } return nil, fmt.Errorf("flag request failed") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("flag API returned HTTP %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024+1)) if err != nil || len(body) > 64*1024 { return nil, fmt.Errorf("flag response unavailable or too large") } var result struct{ Enabled *bool } if json.Unmarshal(body, &result) != nil || result.Enabled == nil { return nil, fmt.Errorf("flag response requires an enabled boolean") } s, err := snapshot("read", map[string]any{ "name": args.Name, "enabled": *result.Enabled, }) if err != nil { return nil, err } s.Value, err = json.Marshal(*result.Enabled) if err != nil { return nil, fmt.Errorf("cannot encode flag result") } s.Artifacts = []provider.Artifact{ provider.NewArtifact("flag.json", "application/json", s.State), } p.last = s provider.ReportDiagnostic(ctx, "info", "Feature flag observed") return s, nil}
func (p *flagsProvider) Get(ctx context.Context, raw json.RawMessage) (*provider.Snapshot, error) { if err := ctx.Err(); err != nil { return nil, err } var args struct{ Operation string } if json.Unmarshal(raw, &args) != nil { return nil, fmt.Errorf("invalid Get arguments") } if args.Operation == "requirements" { return snapshot("requirements", map[string]any{ "available": true, "runtime": "Go HTTPS client", }) } if args.Operation != "" || p.last == nil { return nil, fmt.Errorf("no recorded read or unsupported Get operation") } return p.last, nil}
func (p *flagsProvider) Compare(ctx context.Context, a, b *provider.Snapshot, _ json.RawMessage) (*provider.Comparison, error) { if err := ctx.Err(); err != nil { return nil, err } return provider.JSONCompare(a, b)}
func (p *flagsProvider) Render(ctx context.Context, s *provider.Snapshot, c *provider.Comparison) (*provider.RenderResult, error) { if err := ctx.Err(); err != nil { return nil, err } return provider.JSONRender(s, c)}
func (p *flagsProvider) Close(context.Context) error { if p.client != nil { p.client.CloseIdleConnections() } p.baseURL, p.token, p.client, p.last = "", "", nil, nil return nil}
func snapshot(command string, value any) (*provider.Snapshot, error) { s, err := provider.JSONSnapshot(command, value) if s != nil { s.Provider = "FeatureFlags" } return s, err}
func main() { provider.Main(&flagsProvider{}) }Get({"operation":"requirements"}) reports runtime availability without network
access, as the installer expects. Get({}) reads the last observation; it does
not refresh the API. A missing enabled field fails instead of becoming a false
flag. Errors and progress use fixed messages so credentials and response bodies
cannot enter diagnostic output.
State, originals and comparisons
Section titled “State, originals and comparisons”flag.json is the exact recorded projection of the flag state, not a raw HTTP
response. Its bytes, media type and SHA-256 are retained with the snapshot. Only
explicitly selected, non-secret fields belong in state or artifacts. Avoid volatile
request IDs and observation timestamps when they do not represent a meaningful
application change.
JSONCompare compares state, status, scalar value and the complete artifact set. It refuses
unknown completion; missing or corrupt evidence must not become equality.
JSONRender produces readable JSON in Markdown. Implement custom Compare and
Render when a system needs a domain-specific diff, image or other evidence;
return exact originals using provider.NewArtifact. Both operations must work
from their supplied retained inputs without Bind, credentials or a live target.
The artifact limit is 24 MiB total, with 32 MiB gRPC messages.
These observations can inform application knowledge such as “checkout uses the redesigned form when this flag is enabled.” Knowledge describes the current application; keep execution history and repair notes in the review evidence.
Build and check the example
Section titled “Build and check the example”Use the repository’s installed Go 1.26.1 toolchain. From the new provider module:
GOWORK=off GOTOOLCHAIN=local go mod tidyGOWORK=off GOTOOLCHAIN=local go test ./...plugin_build_dir="$(mktemp -d)"GOWORK=off GOTOOLCHAIN=local go build \ -o "$plugin_build_dir/reflow-provider-feature-flags" ./cmd/reflow-provider-feature-flags"$plugin_build_dir/reflow-provider-feature-flags" --describeKeep the resulting go.sum. These commands compile the provider independently
using the adjacent framework replacement. --describe validates and prints its
schema without connecting to the API. Add tests using an owned HTTP fixture for
valid responses, absent fields, cancellation and errors, then compare and render
saved snapshots after closing the connection. A successful build alone does not
establish external API behavior.
Register and initialize in a Reflow source build
Section titled “Register and initialize in a Reflow source build”The remaining steps run in an authorized Reflow checkout. Copy your provider
module into its plugins/ directory; the framework there is pinned from rfl.
The catalog is materialized from a fixed source list; dropping a binary into a
folder does not register it. In the Reflow checkout:
- Add
./reflow-provider-feature-flagstoplugins/go.work. - Add
"feature-flags"to the provider slug list inpackages/plugin-host/scripts/catalog.mjs. - Generate development descriptors and rebuild the consuming packages:
mise exec -- node packages/plugin-host/scripts/catalog.mjs --developmentmise exec -- pnpm --filter @reflow/mcp buildThe generator writes plugins/registry/feature-flags.json and the compiled
catalog source at packages/plugin-host/src/generated/catalog.ts. Do not edit
those generated schemas by hand. Rebuild and use the matching API and dashboard
from this checkout too: they validate and display the selected schemas. The application lock retains its descriptor for validation. Installation still
requires a matching CLI build with your provider registered in its catalog.
Use the rebuilt source CLI, with an isolated cache shared by its execution process:
export REFLOW_PLUGIN_CACHE="$HOME/.reflow/plugins-feature-flags-dev"mise exec -- node services/mcp/dist/cli.js init FeatureFlags --collection <collection-id> \ --upgrade --development --plugin-source "$PWD"Development initialization explicitly builds working-tree source and records its
content identity. After edits, regenerate changed descriptors, rebuild consumers
and initialize again with —upgrade. The application lock records the new source
identity. Plan and apply do not install plugins. --plugin-source
selects source for a known catalog entry; it is not an external binary loader.
To select a provider that is not in the catalog, see
Install from your own source repository.
For a committed plugin, commit provider, framework and workspace changes as code revision A. From that clean exact checkout, run:
mise exec -- node packages/plugin-host/scripts/catalog.mjs FULL_CODE_COMMIT_SHACommit the generated catalog as a following revision B, then rebuild the consumers. The catalog records A’s full source commit and the descriptor digest. A provider whose descriptor and build inputs are unchanged since its committed pin keeps that earlier pin, so its application lock entry and step evidence are unaffected by the new catalog. Ordinary source initialization verifies and builds that pinned source:
mise exec -- node services/mcp/dist/cli.js init FeatureFlags --collection <collection-id> --upgrade --plugin-source "$PWD"Keep the source checkout containing A available. The installed plugin’s binary,
manifest and runtime assets are verified on reuse. Custom providers that need
extra runtime files can declare a plugin.build.json. In your authorized Reflow
checkout, plugins/README.md documents the build script, source inputs and runtime
files that this manifest accepts.
Install from your own source repository
Section titled “Install from your own source repository”An application can also select a provider that is not built into the CLI, straight from your own Git repository, without registering it in Reflow. This is a source install: each device builds the provider from an exact commit.
Lay the repository out like Reflow’s plugins/ tree:
plugins/reflow-plugin-framework: the exact framework bytes pinned by the CLI you use (itsrflSDK commit). Do not edit them.plugins/reflow-provider-<slug>/withgo.mod(replacing the framework with../reflow-plugin-framework),cmd/reflow-provider-<slug>/and an optionalplugin.build.json.plugins/go.worklisting the framework and your provider.plugins/registry/<slug>.json: the catalog entry.
Publish two commits. Code commit A contains the provider and a bootstrap registry recipe with only its final name and Go command:
{ "name": "FeatureFlags", "goCommand": "plugins/reflow-provider-feature-flags/cmd/reflow-provider-feature-flags"}Metadata commit B descends from A and replaces that file with the complete
entry: name, goCommand, sourceCommit set to A’s full SHA, descriptor,
commands and descriptorDigest. Generate the descriptor with
go run ./cmd/reflow-provider-<slug> --describe at A. Reinstallation reads the
recipe at A, so init refuses an entry whose A lacks it or names another command.
Select it, together with any built-in providers:
reflow init --upgrade --plugin-source ../feature-flags-provider \ --plugin-catalog-commit FULL_METADATA_COMMIT_SHA --trust-plugin-source FeatureFlags LocalBash--plugin-catalog-committakes only a full SHA. Reflow reads the registry from that commit’s objects, never the working tree, and checks every digest.--trust-plugin-sourceis required before Reflow reads or builds that repository. Its build runs with your user’s privileges.- Built-in providers in the same selection keep their released, bundled or
Reflow source route.
--plugin-sourceapplies only to non-built-in providers. - The application lock records A, the descriptor, the build manifest and the verified bytes, not the repository location. A command’s JSON result and output show the trusted repository, its origin without credentials, and B.
Before a provider is accepted, init runs this CLI’s pinned rfl-plugin-docs check
against the provider it just built and the README and docs/ committed at A. The
README must contain the generated capabilities, usage and examples sections,
every docs/commands/<command>.md page must be current, and at least one command
example must set readme: true. Run rfl-plugin-docs generate (for example
through a docs:generate script) and commit the result in A. A missing or stale
page refuses the selection before anything is locked; a check that cannot run
(for example, missing Go modules) is reported separately.
On another device, after clearing the cache, or for --lockfile-readonly, pass
--plugin-source with any checkout that contains A, plus --trust-plugin-source.
Each such device needs Git, the CLI’s exact Go toolchain and your provider’s own
build tools. The rebuilt binary and runtime files must match the lock byte for
byte, so keep builds deterministic: a different compiler or SDK fails closed
instead of silently changing the selection. A verified cache is reused without
the source. CLIs released before this feature cannot install a lock that contains a
non-built-in provider on a device without a verified cache; upgrade every
device and PR check first.
Bind privately and call from RFL
Section titled “Bind privately and call from RFL”Save a private binding file with mode 0600, replacing these example values:
{ "flags-service": { "provider": "FeatureFlags", "configuration": { "baseURL": "https://flags.example.com", "token": "YOUR_PRIVATE_API_TOKEN" } }}The token stays in device configuration and provider memory. Never put credentials in flow frontmatter, command arguments, snapshots, artifacts or diagnostics.
Initialize this provider in the application first. Init records its source, schema and package checksums in the generated application lock. The flow only needs its alias:
---name: Checkout feature flagproviders: flags: {use: FeatureFlags, binding: flags-service}---
Observe whether the redesigned checkout is enabled.
```rfl step="Read checkout flag"flags.read name="checkout-redesign" -> enabled```From the application’s Git checkout, use the rebuilt CLI with the matching backend connection and plugin cache. Select the private binding and run the flow:
node /path/to/reflow/services/mcp/dist/cli.js target --group flag-review \ --scope device --bindings /absolute/path/to/bindings.jsonnode /path/to/reflow/services/mcp/dist/cli.js plan .reflow/flows/checkout-flag.mdnode /path/to/reflow/services/mcp/dist/cli.js apply .reflow/flows/checkout-flag.mdThis flow needs no browser URL. A matching completed observation may be reused;
plan does not poll the external API for drift. After an external flag change,
request fresh evidence with taint checkout-flag, then plan and apply again.
Stable names and runtime context
Section titled “Stable names and runtime context”When a plugin needs a persistent test identity, read
provider.RuntimeContextFromContext(ctx) during Bind. Admitted execution supplies
TestDataEpoch, Group, GroupScope, DeviceID and FlowSlug separately from
private configuration. Other execution modes may not supply this context; refuse
commands that require it instead of inventing a scope.
The Mailbox provider keys names by the authenticated team’s
execution group and test-data generation. Its remote service validates that scope;
the context itself is not a credential. The flow slug is not part of a shared
mailbox name. reflow reset changes the generation; ordinary apply or taint does
not. Keep credentials, private file paths and application input values out of
runtime context and snapshots.
Cancellation and external writes
Section titled “Cancellation and external writes”The framework serializes instance calls and streams bounded progress and
diagnostics before one terminal snapshot. Use provider.ReportProgress and
provider.ReportDiagnostic with the invocation context. Carry that context into
HTTP requests and any owned work, close response bodies and release resources in
Close. Caller deadlines are capped at 30 minutes; an unspecified host call
normally defaults to 30 seconds. The example adds its own 10-second HTTP timeout.
If you add a write command, label it mutate and make its intended effect explicit.
A lost response can mean the external write succeeded without confirmation.
Reflow does not automatically retry that invocation or undo external changes.
Use the external system’s idempotency and read-back facilities where available;
inspect unknown outcomes before allowing another write. A Go error ends the stream
without a successful terminal snapshot and must not be treated as completed work.
A managed subprocess is not a sandbox: custom code has the execution user’s available network and filesystem access. The provider owns its external effects, secret handling and cleanup. Keep the integration scoped to systems and actions the author has authorized.
For the exact implementation, read the
plugins/reflow-plugin-framework/provider/serve.go in rfl,
packages/plugin-host/src/install.ts in rfl
and packages/plugin-host/scripts/catalog.mjs in Reflow.