← All posts

Two Ways to Extend an Agent Platform — and They're Not the Same Axis

Every agent platform eventually asks you the same question: how do I make it do something it doesn't do yet? In FloMorphic there are two answers, and the most common mistake is treating them as competitors — as if you had to pick a side. You don't. They live on different axes.

MCP is how the platform reaches a system it does not own. A plugin is how the platform grows a new capability of its own. One is an integration boundary. The other is an extension seam. Confusing them is like asking whether a USB port is better than a device driver — the question sounds reasonable until you notice they are answers to different problems.

FloMorphic ships an MCP node precisely so you rarely have to think hard about the first case. The interesting decision is knowing when you've crossed from reach into extend — because that's the line where a plugin stops being overkill and starts being the only thing that fits.


What MCP is for

The Model Context Protocol is a good standard, and it is a good standard because it is a boundary. It defines how one system exposes tools to another without either side knowing the other's internals. When Anthropic, or your CRM vendor, or a public data provider speaks MCP, any MCP client can call them without a bespoke integration. That interoperability is the entire point.

In FloMorphic, MCP is a node. You point it at a server, the server advertises its tools, and your agent can call them mid-flow. If StoneInsurance keeps its member records behind an MCP server, you drop an MCP node into the claims flow and read the record. No SDK, no deploy of your own, no code. Someone else built the far side and maintains it; you consume it over a standard wire.

Reach for MCP when all of these are true:

  • The system already exists and you do not own it.
  • It already speaks MCP, or trivially can.
  • You want interoperability without coupling — a clean boundary you can swap behind.

The MCP node is there so that the entire universe of "call an external tool" never requires you to write a line of plugin code. If that's your situation, stop here. Writing a plugin to do what an MCP node already does is effort spent making your system more coupled, not less.


What a plugin is for

A plugin is a different animal. It is not a way to call the outside — it is a way to add a new kind of node to the platform itself, one that participates in a flow exactly the way the built-in nodes do.

Here is the architectural fact that makes this matter. FloMorphic ships a handful of primitive nodes, and every higher-level node compiles down to those primitives. The plugin node is the one exception: rather than compiling to primitives, it is a live external process the runtime calls into. That is what makes it the full-featured extension point of the whole system. A plugin can hold connections, run background loops, surface a queue or a webhook or a piece of hardware as a node on the canvas, and — this is the part that changes everything — read and write the running flow's context by JSON path, mid-execution.

Which means a plugin is inside the graph, not across a boundary from it. And because a plugin is written against one protocol — inflowv1 — rather than against FloMorphic specifically, the same plugin runs on any product built on the Inflowenger runtime. FloMorphic is one such product; a plugin written once for the protocol loads into all of them. Look at what a plugin's job handler is actually handed:

func(job sdkv1.Job) {
    req, _ := sdkv1.CastRequestTo[MyInput](job.Req.Data)

    // read the flow's shared context, by path
    policy := job.CmdGetScope("$.claim.policy")

    job.Progress(40, sdkv1.Frame{Title: "scoring", Content: "applying rules"})

    // ... do the real work ...

    // write back into the flow context, at a path
    job.CmdSetOnPath(`$["score"]`, map[string]any{"risk": 0.18})

    // route the outbound ports by tag — the model-proposes-graph-decides move,
    // available to your own code
    job.CmdNextFilter([]string{"low_risk"})

    job.Done(map[string]any{"ok": true})
}

Every line of that is something an MCP tool call structurally cannot do. An MCP tool receives arguments and returns a result across a protocol boundary; it has no handle on your flow's context, cannot commit into it by path, cannot emit your route tags, cannot stop the run. It isn't allowed to, and that's correct — it's a foreign system and the boundary is the feature. But it means the moment your extension needs to be part of the flow rather than be called by it, MCP is the wrong tool and a plugin is the right one.

Reach for a plugin when any of these is true:

  • You need a new node type — a domain-specific evaluator, a specialized retriever, a scoring function — that should feel native on the canvas, with its own configuration form.
  • The capability must read or write flow context mid-execution, or steer the flow's own routing.
  • It's long-lived: it holds a connection, runs a loop, or turns an external event stream into node activity.
  • You're bridging a system that doesn't speak MCP and you'd rather adapt it once, cleanly, than bolt translation onto every call.

The line that actually separates them

Here is the distinction stated as a rule you can apply without thinking about protocols at all:

A plugin inherits the harness. An MCP call reaches outside it.

That is the whole thing. Recall the argument from the first two pieces: FloMorphic's claim is that every path the system can take is drawn where a person can read it, and every decision is a visible edge with a scope and a route tag. A plugin lives under that claim. When it makes a decision, it does so with CmdNextFilter — the same tag-routing mechanism every built-in node uses — so its choice is an edge on the diagram, governed and observed like any other. Its context access is scoped. Its work streams progress onto the canvas. It is, for governance purposes, indistinguishable from a native node. The SDK even routes a plugin's calls to your backend through an extrinsics service that origin-tags them plugin:<node title> — so the service on the far side can refuse a plugin call the operator never granted. The harness reaches all the way into your extension.

An MCP call, by design, steps over that boundary. What happens on the far side of an MCP server is not on your diagram and not in your audit trail — nor should it be, because it isn't your system. That is exactly why MCP is right for reaching a vendor's CRM and wrong for implementing your own claims-scoring logic. One is a foreign system you're interoperating with; the other is a capability that should be governed like the rest of your flow.

So the decision rule:

Reaching a system you don't own and want to stay decoupled from? MCP. Adding a governed capability that should be native to your flows? Plugin.

Neither is a fallback for the other. If you find yourself writing a plugin that only wraps an HTTP call to a service that already speaks MCP, you've taken the long way around. If you find yourself trying to make an MCP tool commit into your flow context, you're asking a boundary to stop being a boundary.

One honest complication, since the clean split invites it: a plugin can also just integrate an external system. The Jira plugin above reaches Jira's REST API — that's reaching out, not growing in, and MCP could arguably cover it. So why build it as a plugin? Because even when a plugin's job is integration, it does that job inside the harness: its actions are native canvas nodes with their own forms, its calls carry a settings profile the operator controls, its work streams progress onto the diagram, and — if you want — it can read and steer flow context on the way through. You reach for a plugin over MCP for an external system when you want that system to feel native and governed rather than foreign and called. When Jira should be a first-class citizen of your flows with a proper configuration UI, a plugin earns its keep; when you just need to fire one tool call at something that already speaks MCP, it doesn't. The axis still holds — it's about whether the capability lives inside your harness or across a boundary from it — but "external system" alone doesn't decide it. How native and governed you need it to be does.


Why plugins are more seamless than they sound

There's a reflex to assume "write a plugin" means heavy work and "use MCP" means easy. For extending the platform itself, that reflex is backwards, and it's worth seeing why.

A plugin is an ordinary long-running program. The whole skeleton is: construct from the platform identity, declare who you are, register your actions, start, and block until a signal. Here is the real shape, from a production Jira plugin built on the current SDK:

func main() {
    // The dotenv carries the platform identity only — PLUGIN_ID, INFRA_CRED,
    // INFRA_URL. Jira credentials never live here.
    plugin, _ := sdkv1.NewPlugin(sdkv1.WithDotEnv(".env.inflow"))

    registry := actions.New()

    plugin.Intro(sdkv1.PluginIntro{
        Name:     "JIRA",
        Author:   "mehdi-shokohi",
        Version:  version,
        Settings: registry.SettingsForm(),
    })
    plugin.RequiredParams(registry.Settings())

    plugin.AddAction(registry.All()...)
    plugin.AddMeta(registry.Metas()...)

    plugin.Start()

    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
    <-stop
}

Two things in that are worth naming. First, the plugin holds no Jira configuration. It declares what a connection needs as a settings form; the platform stores that as a named settings profile and ships the values with every call. Same pattern as the LLM node from the walkthrough — credentials are a profile bound to the node, never baked into the code. You can open-source the plugin and the keys stay behind. Second, an individual action is just a typed handler that reports progress and returns its result:

RequestHandler: run(..., func(ctx, job *sdkv1.Job, client *jira.Client, in createIssueInput) (map[string]any, error) {
    job.Progress(50, sdkv1.Frame{Title: "create issue", Content: in.Summary})
    created, err := client.CreateIssue(ctx, fields)
    if err != nil {
        return nil, err
    }
    return created, nil
})

The plugin runs as a process you own and deploy anywhere, on your own cadence, versioned independently of the platform. It gets its own configuration UI on the canvas for free — an action carries a JSON Schema form, and the runtime renders it, so the operator configures your node visually without you writing any frontend. And because the SDK ships an agent skill — a SKILL.md that teaches a coding agent the SDK's real API, the one-Done-per-path rule, the context commands, the known gotchas — you can hand the scaffolding to Claude Code and get a correct plugin back rather than fighting the protocol yourself. The Jira plugin ships that skill in its own .claude/skills/ directory; that is how a plugin this complete — create, search, transition, comment, attach, log work, a dozen-plus Jira actions — gets built without memorizing a wire protocol.

The Go SDK is the reference implementation today; Python and JS versions are on the way, which will widen this further. But the shape is already the point: extending FloMorphic with a native, governed capability is a small Go program and a form schema, not a fork of the runtime.


A plugin carries its own interface

There's a second thing a plugin ships that's easy to miss and worth pulling out, because it's where the difference from MCP is sharpest: a plugin brings its own UI.

An action declares a form — a JSON Schema for the data it takes and a UI Schema for how to lay it out — and the runtime renders it on the canvas. The operator configuring your node gets a real dialog with typed fields, validation, and conditional visibility, and you wrote no frontend to get it. It goes further than static fields: through an x-inflow-ui extension, a control can carry a button that calls back into your plugin while the form is open. The Jira plugin uses this for a "search issues" field — you type, it hits a lookup action, and either fills in the issue key it found or rebuilds the field as a dropdown of matches. A live, interactive configuration surface, declared in the plugin, rendered by the host. (The SDK's formkit package generates both schemas from one fluent declaration per field, so the data model and the layout can't drift apart — but that's a convenience on top of the same idea.)

Here is why this matters for the comparison, and I want to be current about it, because the ground moved. Until early 2026 the contrast was blunt: an MCP tool returned data and text, and the calling model decided what, if anything, the user saw — the tool author had no say in presentation. That changed. MCP Apps, the first official MCP extension, now lets a server ship an interactive UI that the host renders in a sandboxed iframe. So "MCP can't carry a UI" is no longer true, and the honest comparison is more interesting than that.

The distinction now is what kind of interface, for whom, and how its portability is guaranteed:

  • An MCP App renders UI into the conversation, for the end-user. It is bundled HTML in a sandboxed iframe — a chart, a checkout form, a config wizard the user clicks through inside a chat. And because it is code the host did not write, the whole model depends on iframe sandboxing, pre-declared templates, and a JSON-RPC bridge — which is why the spec is explicit that an MCP App is only portable insofar as every host implements that same bridge and capability contract the same way. Portability is a contract you hope each client honors.
  • A plugin form is operator-facing configuration, rendered natively by the runtime. It is not aimed at an end-user in a chat; it is the dialog you fill to configure the node while composing the flow. It is declarative JSON Schema, not foreign code, so there is no iframe to sandbox — and it renders the same way on every Inflowenger runtime because the runtime is the renderer. Portability is a property of the platform, not a per-host negotiation.

Both are legitimate, and they are solving different problems — one wants to show a user something rich mid-conversation, the other wants an operator to configure a governed node at build time. But if what you need is a capability that drops into any Inflowenger-based system and arrives with its interface intact, configured the same way everywhere, that is the plugin's home turf. The UI travels with the capability, guaranteed by the runtime rather than by each client agreeing to render it alike.


There's a catalog, not just an SDK

None of this is hypothetical scaffolding. Plugins are indexed in a plugin catalog — one entry per plugin, each pointing at the author's own repository, because plugins are not hosted centrally; their authors own and deploy them. The Jira plugin used throughout this piece is a listed entry. The same index doubles as the knowledge base for writing one: the mental model, a build-from-zero guide, the doc on dependent form fields, the SDK matrix, and how to publish and get listed.

The catalog is also where the write-once claim becomes concrete. An entry is a plugin against inflowv1, not against FloMorphic — so listing is a statement that the capability runs on any product built on the runtime. That is the difference between an extension ecosystem and a pile of one-off integrations: a plugin someone writes for their own need is, with no extra work, a node anyone else on the runtime can drop in.


The one-paragraph version

If you take one thing from this: don't frame it as MCP versus plugins, because they answer different questions. MCP is your interoperability layer — the way flows reach systems you don't own, over a standard everyone else also speaks, staying cleanly decoupled. Plugins are your extension layer — the way you add new, native, governed node types that live inside the flow, touch its context, steer its routing, and inherit the whole audit story. The MCP node exists so you almost never write a plugin just to make an external call. The plugin SDK exists so that when you need the platform to genuinely do more, you can grow it without leaving the harness behind. Reach out, or grow in. Pick by which one you're actually doing.


Plugin SDK: github.com/Inflowenger/go-plugin-sdk

Plugin catalog: github.com/Inflowenger/plugin-catalog

A real plugin to read: github.com/mehdi-shokohi/jira-plugin

Concepts and docs: inflowenger.com/flomorphic

Repo: github.com/FloMorphic/getting-started

If you build a plugin — especially one that does something I didn't anticipate with context or routing — I'd like to see it. That's the feedback that shapes where the SDK goes next.