fyflow-plugin-sdk
Types and definePlugin for writing Plugins for fyflow-app, the visual builder and Host for fyflow-orchestration flows. A Plugin contributes Node Types (with the Workers that run their Tasks), gate types, Resource Group types and Resource Groups.
npm install --save-peer fyflow-plugin-sdk
npm install --save-dev fyflow-orchestration # its types, for type checking; the Host brings its own at run time
The contract
A Plugin is a module whose default export is a plain object. definePlugin returns its argument unchanged; it only gives you type checking. Depend on the SDK as a peer and do not bundle it: the Host provides it at run time (see below), and a copy of your own would only report its own SDK_VERSION, not the Host's.
import { definePlugin } from "fyflow-plugin-sdk";
export default definePlugin({
id: "acme.signer", // stable and unique across Plugins
version: "1.0.0",
base: import.meta.url, // what Worker `url`s resolve against
// Set on the Host's Profiles, once for every Node Type that takes them (see Profiles and secrets).
profile: {
schema: {
type: "object",
properties: { key: { type: "string", writeOnly: true } }, // a secret
},
startup: ["key"], // read once, when a Worker starts
},
workers: {
sign: {
url: "./sign.worker.js",
settingsSchema: { type: "object", properties: { field: { type: "string" } } },
},
},
nodeTypes: {
Sign: {
worker: "sign",
inline: true,
profile: ["key"], // the fields of `profile` its Workers get
groups: ["acme.signing"],
icon: "signature", // from the app's icon set, below
description: "Signs a field with HMAC.",
},
},
groups: { "acme.signing": { type: "ConcurrentLimitGroup", config: { limit: 2 } } },
gateTypes: {
threshold: {
routes: ["below", "at-or-above"],
defaultRoute: "below",
fn: ({ key, settings }) => (Number(key) >= Number(settings.at) ? "at-or-above" : "below"),
},
},
});
| Field | What it is |
|---|---|
profile | The Plugin's Profile fields, since 0.5.0: one object schema, defaults for fields no Profile sets, the startup fields its Workers read when they start, and the per-Task fields only a Profile may set (profileOnly). See Profiles and secrets. |
workers | Worker modules by key. A Worker module's default export is a class: its constructor gets the start-up fields its Node Type takes, and run({ settings, input }, context) does one Task (see Workers). A Worker declares no configSchema since 0.5.0: its config is the Plugin's profile. Schemas are JSON Schema in fyflow's subset. A Worker's outputSchema, what run returns (accepted by every version of the SDK, as fyflow's own Worker entry), is the form a person fills in to mark a failed Task done, and the paths the editor suggests for conditions on its Node's Edges and gates before any Run. Give inputSchema and outputSchema a description on each field and an examples list: a Node's panel shows what its Worker takes and returns from them, and checks what reaches the Node against what it takes. Before any Run, the first of an outputSchema's examples is the sample the next Node's Transform is tried and checked on; without one, the editor makes a coarser value from the schema. |
nodeTypes | Node Types by name, each naming one of this Plugin's workers, with fyflow's declaration fields (inline, pool, settings defaults, retryPolicy, groups; no config since 0.5.0), since 0.2.0 how the app shows it (an optional icon and a one-line description), and since 0.5.0 the profile fields it takes. |
dataDir | true asks the Host for a data directory of the Plugin's own, since 0.6.0. See A Plugin's data directory. |
status | A function saying how the Plugin is, shown on its card on the Plugins page, since 0.6.0. See A Plugin's status. |
gateTypes | Gate types by name: their routes, default route, settings schema, and the decision fn. |
groupTypes | Resource Group types by name: a create(config, key) factory for the scheduler's limit, and a configSchema. |
groups | Resource Groups by key, of fyflow's built-in types (ConcurrentLimitGroup, RateLimitGroup, KeyedRateLimitGroup) or this Plugin's own. A Node Type may use its own Plugin's groups, and the built-in Plugin's disk, cpu and gpu groups (below). |
Do not name a setting like a member of every JavaScript object (constructor, toString, valueOf, hasOwnProperty, __proto__). The Host hands Agents a Flow Definition's shape as a JSON Schema that includes your settingsSchema, and some JSON Schema validators misread such a property: they refuse, or throw, on a Flow that is valid.
Every name a Plugin contributes (Node Type, gate type, Resource Group type, Resource Group) must be unique across the Plugins a Host loads: the Plugin the Host saw first keeps a name, and a later one that repeats it is skipped with the reason.
The Plugin directory
A Host loads Plugins from its Plugin directory (<dataDir>/plugins unless pluginDir, FYFLOW_PLUGIN_DIR or --plugin-dir says otherwise):
- a folder holding
plugin.tsorplugin.js, or a package (package.jsonand its entry), as the Host lays out a Plugin installed from the app; - a package named
fyflow-plugin-*or@scope/fyflow-plugin-*under the directory'snode_modules, installed with npm. A package must ship JavaScript: Node does not strip TypeScript undernode_modules.
A Plugin's own imports resolve from the Plugin directory's node_modules, so npm install your other dependencies there (or bundle them). The SDK is the Host's to provide: on a server, npm install fyflow-plugin-sdk there (npm also installs it as your package's peer); the desktop app puts its own there by itself. Changes take effect when the Host is told to Reload.
Peer range
Declare the SDK versions your Plugin works with as a peer dependency:
{ "peerDependencies": { "fyflow-plugin-sdk": "^0.5.0" } }
A Host implements its SDK version (SDK_VERSION, exported here) and the earlier ones it is compatible with (SDK_COMPATIBLE). It skips, with the reason, a Plugin whose range excludes them all. 0.5.0 changed what Profiles are, so it is compatible with no earlier version: a Plugin written for 0.4 or before is skipped until it is upgraded, as UPGRADING.md describes. 0.6.0 and 0.7.0 only add to 0.5.0, so a Host on 0.7 loads a Plugin declaring ^0.5.0 or ^0.6.0. From 0.5 on, raise the range only for what a Host must do at run time: a Plugin that asks for a data directory says ^0.6.0, since a Host on 0.5 would load it and give it none.
Icons
A Node Type's icon names one of the app's icons, from Lucide: activity, archive, bell, bot, box, braces, brain, calculator, calendar, camera, chart-bar, check, clock, cloud, code, cpu, credit-card, database, download, file, file-text, filter, flag, folder, git-branch, globe, hash, image, key, link, list, lock, mail, map-pin, message-square, package, pen-tool, play, plug, repeat, search, send, server, settings, shield, shopping-cart, signature, sparkles, table, tag, terminal, timer, trash, upload, user, users, wand-sparkles, webhook, zap. A name outside the set is not an error: the Node Type shows its Kind's icon, and the Plugins page shows a warning.
Workers
A Worker module's default export is a class. The Host builds each instance (one per worker of the pool: a thread, or on an inline pool an object on the Host's own thread) with the start-up fields its Node Type takes, from the Profile the pool is for, and reuses it across Tasks, so keep per-Task state out of fields. run gets the Task's envelope (the Node's settings and the Task input) and a context; what it returns is the Task's result, and throwing fails the Task with the error's message. Since 0.3.0 the SDK types all of it:
import type { TaskEnvelope, Worker, WorkerContext } from "fyflow-plugin-sdk";
type Config = { key?: string };
type Settings = { field?: string };
export default class Sign implements Worker<Settings> {
readonly #config: Config;
constructor(config: Config) {
this.#config = config;
}
async run({ settings, input }: TaskEnvelope<Settings>, context: WorkerContext) {
if (this.#config.key === undefined) throw new Error("No key: set `key` on this Node's Profile");
context.sendProgress(0, "signing"); // a fraction from 0 to 1, not a percentage
context.signal.throwIfAborted(); // aborted when the Task is Cancelled
const value = settings.field === undefined ? input : (input as Record<string, unknown>)[settings.field];
return { signed: await hmac(this.#config.key, value) }; // hmac: your own signing function
}
}
WorkerClass<Config, Settings, Input, Output> types the class itself, constructor included. Check context.signal between steps, or pass it to anything that takes one (fetch, streams): a Worker that stops promptly frees its slot sooner, and what it returns after a Cancel is discarded. Use import type, so a Worker module needs nothing from the SDK at run time. The context offers what the Host shows (progress) and Cancel; fyflow's scheduler passes more, but the SDK promises only these.
A Worker's lifecycle
Since 0.5.0 the SDK also promises what happens to an instance between Tasks, for a Worker that owns something long-lived (a child process, a connection, a loaded model):
- Built:
new Worker(config, pool), whereconfigis its start-up fields andpoolaPoolContext. setup(), if it has one: once, awaited before its first Task. Start what it owns here, not in the constructor.run, for any number of Tasks.teardown(), if it has one: once, when the pool retires the instance. That is when it has been idle past its pool'sidleTimeout(the pool checks every 5 seconds, so up to 5 seconds later), when it asked to be replaced, and when its Runtime shuts down: the Host stopping, or after a Reload once the last Run on the replaced Runtime ends.
import type { PoolContext, Worker } from "fyflow-plugin-sdk";
export default class Thumbnails implements Worker {
readonly #pool: PoolContext | undefined;
#service: Service | undefined; // Service, start: your own
constructor(_config: object, pool?: PoolContext) {
this.#pool = pool;
}
async setup() {
// Port 0: another copy of this Worker may be running (see below).
this.#service = await start({ port: 0, onExit: (error) => this.#pool?.terminateWithError(error, { canRestart: true }) });
}
async teardown() {
await this.#service?.stop(); // undefined when setup() did not get that far
}
async run() {
return this.#service?.thumbnail();
}
}
What to rely on, and what not to:
- A
setup()that throws fails every Task the instance is given, with its message, and itsteardown()runs at once. It is not tried again on that instance: the pool builds a new one only once it retires this one, at a Reload, or once no Task has reached it for itsidleTimeout, which a steady stream of Tasks never allows. So asetup()that may fail for a passing reason (a port taken, a service still starting) should retry within itself before it throws, andteardown()must cope with asetup()that never finished. pool.terminateWithError(error, { canRestart: true })asks the pool to replace the instance, say after the process it owns dies. It returns at once and never throws, so call it from a callback or a timer. The instance'steardown()runs, its Tasks still running go back in the queue to run again on a new instance, and that one is built and set up. WithcanRestart: falsethose Tasks fail instead. The pool replaces instances only so many times (3, fyflow-scheduler'smaxWorkerRestarts); after that it builds no more, and its Tasks wait until a Reload. So ask when the instance is broken, not because a Task failed: throw fromrunfor that.- Never assume one copy. Each Profile has its own pool, a pool may hold several instances (up to its
maxThreads), and after a Reload the replaced Runtime's instances keep running its Runs while the new Runtime sets up its own. Bind port 0, not a fixed port, and keep anything shared outside the Worker. idleTimeoutdefaults to 5 seconds. For a Worker costly to set up, give its Node Type'spoola longer one (pool: { idleTimeout: 60_000 }); a Profile may override it for that Node Type.0keeps an instance until its Runtime shuts down.
A Plugin's data directory
A Plugin that keeps state beyond one Host process (a cache its service requires, a record of a ban to wait out) sets dataDir: true (since 0.6.0). The Host then gives it a folder of its own, plugin-data/<plugin id>/ under the Host's data directory, and creates it before it builds the Plugin's Workers. Each Worker of the Plugin finds its absolute path in its config, as host.dataDir, on an inline pool or a threaded one alike:
import { join } from "node:path";
import type { HostConfig, Worker } from "fyflow-plugin-sdk";
type Config = { host: HostConfig; ttlHours?: number };
export default class Lookup implements Worker {
readonly #cache: string;
constructor(config: Config) {
this.#cache = join(config.host.dataDir, "answers.sqlite");
}
// …
}
- The folder is kept across Reloads, updates, and uninstalling then installing again. The app never deletes it: to start afresh, delete it by hand while the Host is stopped.
- So that the path has a place in the config, a Plugin that asks for one may not have a start-up field named
host; the Host skips it with the reason. A Plugin that does not ask gets no folder and nohostin its config, and its ownhostfield is its own. - Several instances, and the Runtime a Reload replaced, may run at once: write to the folder as a shared place (a SQLite database, a file replaced atomically), not as one Worker's own.
A Plugin's status
A Plugin that keeps something running between Tasks (a connection, a session, a queue behind a rate limit) can say how it is with status() (since 0.6.0). The Host shows the answer on the Plugin's card on its Plugins page, asking again every 5 seconds while the page is open, and gives it to Agents. A Plugin without status() shows none.
import { definePlugin } from "fyflow-plugin-sdk";
import { session } from "./session.ts"; // your own module, shared with the inline Workers
export default definePlugin({
// …
status: () =>
session.blockedUntil === null
? { level: "ok", summary: `Logged in · ${session.queued} queued` }
: {
level: "warning",
summary: "Blocked after a ban",
details: [{ label: "Blocked until", value: session.blockedUntil.toLocaleTimeString() }],
},
});
levelisok,info,warningorerror;summaryis one line;detailsare optional label and value pairs. It is data: the Host draws it, so a Plugin still ships no UI code.- It runs on the Host's own thread, where the Host loaded the Plugin's module. It sees what that module shares with
inlineWorkers, not what threaded Workers hold. - Answer within a second. A
status()that throws, rejects, answers with something else, or takes longer shows as anerrorstatus with the reason. While one answer is still pending, the Host does not ask again. - It is handed
{ dataDir }when the Plugin asks for a data directory, and{}otherwise. - The Host redacts its secrets from the answer, as it does from errors, but do not put one there.
The disk, cpu and gpu groups
The Host's built-in Plugin declares three Resource Groups any Plugin's Node Types may name, each a concurrency limit a Host can override on its Resource Groups page:
disk(exported asDISK_GROUPsince 0.3.0), on Tasks that read or write the Host's disks. It is 4. Name it when your Tasks do heavy disk work (copying, transcoding, indexing), so they share one budget with the Host's own file system Node Types instead of thrashing the disk beside them.cpu(exported asCPU_GROUPsince 0.4.0), on Tasks that keep a processor busy (hashing, encoding, image work). It is the Host's logical processor count, so such Tasks from every Plugin together take no more processors than there are.gpu(exported asGPU_GROUPsince 0.7.0), on Tasks that use a GPU (inference, rendering, GPU encoding). It is 1, since a Host cannot count its GPUs; a person raises it on a machine with more. It only counts: a Task is not told which GPU it holds, so when the limit is above 1 your Plugin chooses each Task's device (from its settings, or by keeping track itself).
A Task that does both names both:
import { CPU_GROUP, DISK_GROUP, definePlugin } from "fyflow-plugin-sdk";
export default definePlugin({
// …
nodeTypes: { Transcode: { worker: "transcode", groups: [DISK_GROUP, CPU_GROUP] } },
});
Other Plugins' groups stay out of reach, so Disabling one Plugin never breaks another. The keys are values, so a Plugin that imports them needs the SDK at run time; writing "disk", "cpu" or "gpu" instead works on any Host that has the group. Hosts before SDK 0.4.0 have no cpu and skip a Plugin naming it, so a Plugin that names it says ^0.4.0 in its peer range; the same goes for gpu and ^0.7.0.
Holding a group while a Worker lives
groups holds a unit of each group for one Task, from its start to its end. A Worker that holds something for as long as it lives, such as a model it keeps loaded in a GPU's memory between Tasks, names the group in its Node Type's pool.residentGroups instead (since 0.7.0): the pool takes the units when it builds the instance and gives them back when the instance is retired (idle past its idleTimeout, a Reload, or the Host stopping). Another pool builds an instance only while what it needs still fits, and its Tasks wait meanwhile: up to the holder's idleTimeout and 5 seconds more after the holder's last Task. So a Node Type holding a group resident needs an idleTimeout above 0; the Host refuses one of 0, from the Plugin or a Profile.
import { definePlugin, GPU_GROUP } from "fyflow-plugin-sdk";
export default definePlugin({
// …
groups: { "imagegen.vram": { type: "ConcurrentLimitGroup", config: { limit: 24 } } }, // GB
workers: { model: { url: "./model.worker.ts" } },
nodeTypes: {
// Keeps its model loaded for 10 minutes after its last Task, holding the GPU and 16 GB of its memory meanwhile.
Generate: {
worker: "model",
pool: { maxThreads: 1, idleTimeout: 600_000, residentGroups: { [GPU_GROUP]: 1, "imagegen.vram": 16 } },
},
},
});
residentGroups is a list of keys (a unit of each) or keys with how many units each holds, and may name the same groups groups may: the Plugin's own and disk, cpu and gpu. A Host refuses a Plugin naming any other. Units are held per instance, so a pool with maxThreads: 2 takes two; and each Profile has its own pool of a Node Type, so a Node Type that Tasks run under two Profiles holds units for each. Keep maxThreads at 1 for a Worker that holds a whole device, and a Host that runs it under several Profiles needs as many units as it has pools alive at once.
Profiles and secrets
A Plugin's configuration lives on the Host, not in Flows: a Profile is a named configuration with a section for each Plugin, edited in the Host's UI with a form built from the Plugin's profile.schema. Every Host has a Default Profile. Declare your fields once, in profile, and list on each Node Type the ones it takes: a field several Node Types take is set once, for all of them. A field no Profile sets is its defaults value.
A start-up field (listed in profile.startup) is read when a Worker starts: the start-up fields a Node Type takes are its Workers' config, the constructor's argument, and a change takes effect at the Host's next Reload. Use one for what a Worker sets up once: a connection, a loaded model.
Every other field is a per-Task field: the Host reads it as each Task is dispatched and hands the ones the Node Type takes to run as settings.profile ({} when it takes none), so a change reaches the next Task with no Reload. Type them with TaskEnvelope<Settings, Input, ProfileFields>. Your settingsSchema may not name profile. List in profile.profileOnly the per-Task fields only a Profile may set: a grant, such as the folders Tasks may reach, beside a field a Flow may set to narrow it, which your Worker checks against the grant. Per-Task fields never reach a Run's records, but what run returns is recorded, so do not return them.
Mark a property of profile.schema writeOnly: true to make it a secret: the Host never shows it again, only whether it is set, and redacts its value from errors and logs. Only properties of the schema can be secrets.
Versioning
This package follows semver. SDK_VERSION always equals the package version.