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 language, protocol and Go framework are maintained in the private
Resilient-Software/rfl repository. They are not open source yet. Custom providers
require access to that repository. 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
capture enabled = flags.read name="checkout-redesign". 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. An
unmodified hosted backend or installed public CLI uses its installed 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 \ --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. Plan and apply do not install plugins. --plugin-source
selects source for a known catalog entry; it is not an external binary loader.
The lower-level host API accepts a catalog directory, but the CLI has
no --catalog-directory flag.
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. Ordinary source initialization verifies and builds that pinned source:
mise exec -- node services/mcp/dist/cli.js init FeatureFlags --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.
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.
Copy the exact sourceCommit and descriptorDigest from the generated catalog
into this flow. The uppercase values below are placeholders, not released pins:
---name: Checkout feature flagproviders: flags: {use: FeatureFlags, binding: flags-service}plugins: - name: FeatureFlags sourceCommit: FULL_CODE_COMMIT_SHA descriptorDigest: GENERATED_DESCRIPTOR_SHA256---
Observe whether the redesigned checkout is enabled.
```rfl step="Read checkout flag"capture enabled = flags.read name="checkout-redesign"```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.