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

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

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

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

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. Ordinary source initialization verifies and builds that pinned source:

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

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

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.