Skip to content
Goatfied

open-source

Building a plugin ecosystem developers actually use

Learn how to design plugin systems where third-party extensions solve discrete problems, depend on stable contracts, and remain maintainable long-term.

2026-08-248 min readBy Goatfied
Building a plugin ecosystem developers actually use

Most plugin ecosystems fail not because developers can't build extensions, but because they don't have a reason to. A GitHub issue tracker filled with "add support for X" requests usually means your plugin architecture is discoverable. But if those plugins never get built—or get built once and abandoned—you've optimized the wrong layer.

We open-sourced Goatfied's agent runtime eight months ago, and the constraint that shaped our plugin model was simple: every extension a third party builds should be something we would want to maintain if they disappeared tomorrow. That forced us to design boundaries where plugins solve discrete problems, depend on stable contracts, and fail visibly when assumptions break.

Make the success case trivial, not the advanced case

The JavaScript ecosystem spent years celebrating frameworks that could do anything if you read enough documentation. The result: plugins that required understanding the entire host system's internals just to add a single Babel transform or webpack loader. Developers want to solve a specific problem today, not master your architecture.

Our linter extension point illustrates this. A minimal linter plugin is three TypeScript functions:


export function register(ctx: LinterContext) {

  ctx.addRule({

    name: 'no-hardcoded-secrets',

    check: (file) => {

      const matches = file.content.match(/api[_-]?key\s*=\s*["'][^"']+["']/gi);

      return matches?.map(m => ({

        line: file.lineNumber(m.index),

        message: 'Secret detected in source'

      })) ?? [];

    }

  });

}

No inheritance hierarchies. No lifecycle hooks for initialization/teardown unless you need them. The 80% case is stateless functions over file contents. We made that work in twelve lines because that's the plugin someone writes between meetings, publishes, and forgets about—and it keeps working.

The trick: the runtime owns error handling, cancellation, timeout enforcement, and telemetry. Plugin authors get the easy contract. We keep the ecosystem stable by controlling the hard parts.

Version your extension points, not your platform

Semantic versioning the entire editor creates a trap: every breaking change in internal components forces a major version bump, and suddenly plugins written for v2 won't load in v3 even if the extension point they use is unchanged. Developers stop updating. You stop evolving.

We version each extension category independently. A linter@2 plugin runs fine in Goatfied 4.x, 5.x, or 6.x as long as we support the linter@2 ABI. When we introduce linter@3—say, to add streaming support for multi-GB files—old plugins keep working. We support two versions simultaneously, deprecate the oldest only after usage drops below 5% in telemetry, and publish a migration codemod.

The cost is maintaining shims. Our formatter@1 adapter translates requests into the formatter@2 streaming API under the hood. That's ~200 lines of glue code per deprecated version—trivial compared to the support burden of breaking everyone simultaneously.

Constrain what plugins can do, then make those constraints legible

Unrestricted plugin APIs sound empowering until someone's Prettier extension mines Bitcoin in the background or exfiltrates code to an analytics endpoint. Constricting capabilities isn't about distrust—it's about making the security model obvious to users and reviewers.

Every Goatfied plugin declares permissions up front:


export const manifest = {

  permissions: [

    'read:workspace',

    'exec:linter',

    'net:api.openai.com'

  ]

};

Users see this at install time. The runtime enforces it: network requests to undeclared domains fail with a clear error. Filesystem access outside the workspace root returns empty. Plugins can't require() native modules or shell out to arbitrary binaries unless they request exec:system and the user approves.

The enforcement layer is a thin wrapper around V8 isolates for JavaScript plugins and a syscall filter (via seccomp-bpf on Linux, similar constraints on macOS/Windows) for native extensions. Overhead is <2ms per invocation—unnoticeable in a linter that takes 50ms anyway.

Limiting scope also makes plugins testable. We ship a @goatfied/plugin-test harness that mocks the filesystem, network, and execution layer. A linter author writes normal Jest tests; the harness ensures the plugin behaves the same in CI and production.

Build the plugin you wish existed, then open-source it

The fastest way to validate an extension point is to use it yourself. Our test-runner plugin API exists because we needed to integrate pytest, Jest, and Go's testing package into the agent loop's validation step. We built three first-party plugins, discovered we'd designed the wrong contract twice, and shipped the third version publicly.

That internal usage creates forcing functions:

  • If the API is annoying for us, it's worse for external developers
  • If we can't write good docs, the extension point probably has too many concepts
  • If our own plugins need workarounds, the abstraction is leaking

The Python test runner plugin is ~400 lines. We open-sourced it under MIT. It's simultaneously an example ("here's how to integrate a subprocess-based tool"), a usable default ("install this if you just want pytest to work"), and a template ("fork this to add custom test discovery"). Forks have added support for unittest, nose2, and RobotFramework without asking us for help.

Make breaking changes loud, early, and reversible

Deprecation warnings buried in changelogs don't work. Developers don't read release notes until something breaks. By then, you've burned trust and created support load.

When we deprecated the onFileSave hook in favor of onFileChange (because the new agent loop doesn't have a discrete "save" event—diffs are committed transactionally), we:

1. Logged a warning to the extension developer console on every invocation for six weeks

2. Emailed maintainers of the 30 plugins using the old hook with a specific migration snippet

3. Published a codemod that handled 90% of cases automatically

4. Shipped onFileSave as a compatibility shim that called onFileChange with a deprecation notice

Zero plugins broke. Four maintainers ignored the emails, noticed the console spam, and migrated. The rest either applied the codemod or kept running the shim. We removed the shim fourteen months later after the last holdout updated.

The lesson: developers tolerate breaking changes if you make the new path obvious and the old path survivable. What they won't forgive is silent breakage on upgrade.

Expose metrics that matter to plugin authors

Most platforms give extension developers nothing. Maybe download counts. Possibly a star rating. Both are vanity metrics that don't help someone decide whether to fix a bug or deprecate their plugin.

We send (privacy-preserving, opt-in) telemetry to plugin authors:

  • Invocation count and p50/p95 latency
  • Error rate and most common error messages
  • Goatfied version distribution of users
  • Co-installation patterns (85% of users with your linter also have formatter X)

This is the same data we use internally to decide what to optimize. A plugin author sees their error rate spike after a Goatfied update and can investigate before users file issues. They notice 60% of installs are still on formatter@1 and prioritize the v2 migration.

The implementation is a 40-line middleware in the extension host that samples 1% of invocations, strips workspace paths and file contents, and batches events hourly to a telemetry endpoint. Plugin authors fetch their dashboard via goatfied plugin stats <name> or a web UI. It took one engineer three weeks to build. The ROI is every plugin author who fixed a performance regression we'd never have noticed.

Related posts

Building a plugin ecosystem developers actually use | Goatfied Blog