Skip to content

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.

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.

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:

MethodResponsibility
DescriptorName, protocol version, configuration schema, commands and capabilities.
BindValidate and retain private configuration for this instance. It is called once before invocation.
InvokeRun a named command with the supplied context and return its terminal snapshot.
GetRead provider-defined state or requirements; it can be called before Bind.
CompareCompare two retained snapshots without reconnecting to the external system.
RenderProduce Markdown and optional artifacts from a retained snapshot or comparison.
CloseRelease 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.

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.go

Start go.mod with:

go.mod
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-framework

The 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.

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.

Use the repository’s installed Go 1.26.1 toolchain. From the new provider module:

Terminal window
GOWORK=off GOTOOLCHAIN=local go mod tidy
GOWORK=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" --describe

Keep 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:

  1. Add ./reflow-provider-feature-flags to plugins/go.work.
  2. Add "feature-flags" to the provider slug list in packages/plugin-host/scripts/catalog.mjs.
  3. Generate development descriptors and rebuild the consuming packages:
Terminal window
mise exec -- node packages/plugin-host/scripts/catalog.mjs --development
mise exec -- pnpm --filter @reflow/mcp build

The 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:

Terminal window
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:

Terminal window
mise exec -- node packages/plugin-host/scripts/catalog.mjs FULL_CODE_COMMIT_SHA

Commit 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:

Terminal window
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.

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 (its rfl SDK commit). Do not edit them.
  • plugins/reflow-provider-<slug>/ with go.mod (replacing the framework with ../reflow-plugin-framework), cmd/reflow-provider-<slug>/ and an optional plugin.build.json.
  • plugins/go.work listing 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:

Terminal window
reflow init --upgrade --plugin-source ../feature-flags-provider \
--plugin-catalog-commit FULL_METADATA_COMMIT_SHA --trust-plugin-source FeatureFlags LocalBash
  • --plugin-catalog-commit takes only a full SHA. Reflow reads the registry from that commit’s objects, never the working tree, and checks every digest.
  • --trust-plugin-source is 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-source applies 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.

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 flag
providers:
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:

Terminal window
node /path/to/reflow/services/mcp/dist/cli.js target --group flag-review \
--scope device --bindings /absolute/path/to/bindings.json
node /path/to/reflow/services/mcp/dist/cli.js plan .reflow/flows/checkout-flag.md
node /path/to/reflow/services/mcp/dist/cli.js apply .reflow/flows/checkout-flag.md

This 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.

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.

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.