fyflow-plugin-sdk
v0.11.0
Published
Types and definePlugin for fyflow-app Plugins: Node Types, Workers, gate types and Resource Groups
Maintainers
Readme
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 timeThe 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, and since 0.9.0 UnitGroup and KeyedConcurrentLimitGroup) 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.
Where a Host finds Plugins
A Host loads Plugins from two places:
- The Plugin directory (
<dataDir>/pluginsunlesspluginDir,FYFLOW_PLUGIN_DIRor--plugin-dirsays otherwise) holds Local Plugins: plain folders you, or an Agent, put there and edit in place. A folder is a Plugin when it holdsplugin.tsorplugin.js, or apackage.jsonwhose entry is the Plugin. Its own imports resolve from the Plugin directory'snode_modulesthe normal Node way, sonpm installits other dependencies there (or bundle them). Packages undernode_modulesare never Plugins themselves, whatever their name. - The Plugin store (
<dataDir>/plugins-installed) holds the Plugins installed from the app (a version a Plugin repository lists, an uploaded.tgz, a.tgzURL, or a folder on the Host copied in), each version in a folder of its own,<id>/<version>-<hash8>/. What the app may install is the Host'splugins.install(fyflow.config.json,FYFLOW_PLUGINS_INSTALLor--plugins-install):off(nothing, the default in server Mode),repositories(only versions a configured Plugin repository lists) orany(also an upload, a URL or a folder, the default in the desktop app); a person confirms every install. The Plugin repositories areplugins.repositories(FYFLOW_PLUGINS_REPOSITORIESor--plugins-repositories, comma-separated):index.jsonURLs, the official index unless set. Each must be https, or http only on a loopback address (localhost,127.0.0.0/8or[::1], for a local mirror): over plain http anyone on the network could rewrite an index's artifact URLs and the integrity it pins together, so a Host refuses to start with a non-loopbackhttp:index, naming it. Artifact URLs in an index may be http or https: the integrity a trusted index pins checks the download. The Plugins page browses them, and installing from one previews its index entry, so none of the Plugin's code runs before a person confirms. Only the Host writes there: to change an installed Plugin, copy it into the Plugin directory, where it is a Local Plugin. Make editable copy on its card does that whereplugins.installisany: it copies the current version, without itsnode_modules, into<pluginDir>/<id>/(refused when that folder is there), installs its dependencies there, and Reloads.
When a Local Plugin has the id of an installed one, the Local one loads and the installed one is listed as shadowed (its code is not loaded); it can still be Updated or Uninstalled from the app, and an id only a Local Plugin has can be installed, shadowed from the start. Once the Local one goes, the installed one loads again at the next Reload. A Local Plugin and an installed one alike may be TypeScript, erasable syntax only, as neither sits under a node_modules.
The SDK is the Host's to provide: it writes node_modules/fyflow-plugin-sdk above the Plugin directory and the store, in every Mode, re-exporting its own, unless you installed one there yourself. Changes take effect when the Host is told to Reload; an install from the app Reloads by itself.
npm dependencies
A Plugin installed from the app may have npm dependencies (and optionalDependencies, such as packages built per platform). The Host installs them while it stages the Plugin, before its code first runs, into the Plugin's own node_modules:
- Ship an
npm-shrinkwrap.json. Runnpm shrinkwrap(it turns yourpackage-lock.jsoninto one) beforenpm pack: npm packs a shrinkwrap, never apackage-lock.json. The Host installs exactly what it lists, withnpm ci; a Plugin with dependencies and no shrinkwrap is refused, saying so. - Install scripts never run (
--ignore-scripts): a dependency that builds or downloads something when installed (a native addon compiled bynode-gyp, apostinstalldownload) does not work. Prefer one that publishes its builds as per-platformoptionalDependencies, which npm picks for the Host's machine. - Dependencies stay JavaScript: Node does not strip types inside
node_modules. devDependenciesand peers are left out (--omit=dev --omit=peer). The SDK, your peer, is always the Host's: a copy of it in yournode_modulesis removed.- Or bring them along. A tarball that already holds its
node_modules(bundledDependencies) is used as it is and npm does not run, so it installs on a Host with no network or no npm. Bring all of them: anode_modulesis taken as the whole set.
The Host runs npm ci --ignore-scripts --omit=dev --omit=peer --cache <dataDir>/npm-cache in the staged folder (with --no-audit --no-fund --no-update-notifier, which ask the registry and change nothing installed). Its npm is plugins.npm in fyflow.config.json (or FYFLOW_NPM, or --npm: npm's package folder, its bin/npm-cli.js, or a program), else the one bundled beside the Host when it has one, else npm on the PATH; it needs npm 8.0.0 or later, and the Plugins page shows the one in use, or why there is none. Without one, a Plugin with dependencies cannot be installed; one without still can. Your npm configuration (.npmrc: a registry, a proxy, a token) and the Host's environment reach npm unchanged; only its cache is the Host's. When npm fails, the install is refused with npm's last lines and nothing changes. Each installed version has a folder and a node_modules of its own, so an Update that changes a dependency's version takes effect at its Reload, with no restart.
A Local Plugin's dependencies are yours to install: in its own folder, Install dependencies on its card (where plugins.install is any; POST /api/plugins/install/dependencies/<folder>) runs npm install --ignore-scripts --omit=peer --cache <dataDir>/npm-cache there with the same npm, which writes your package-lock.json (or follows your npm-shrinkwrap.json), and removes a copy of the SDK it leaves; then Reload. A failure shows npm's last lines. Run npm install there yourself as well, as you would for any package; a Plugin folder's own node_modules resolves before the Plugin directory's.
Packing a Plugin
To publish a Plugin, pack it with fyflow-plugin pack, the bin this package ships (since 0.10.0), from the Plugin's folder or naming it:
npx fyflow-plugin pack [<plugin folder>] [--out <folder>] [--platform <platform>]...npx finds it once the SDK is installed in the Plugin's project (npm install --save-peer above); elsewhere, npx -p fyflow-plugin-sdk fyflow-plugin pack. It runs with your Node (22.18, 23.6 or later to load a plugin.ts) and your npm, and in order:
- Checks the Plugin as far as it can on its own. Its
package.jsonhas aname, aversionand afyflow-plugin-sdkpeer range a Host can read (^,~, comparisons,xand||; no hyphen ranges or pre-releases). Its code loads as a Host loads it (plugin.ts, elseplugin.js, else the package's entry) and its default export is a definition with anid, aversionequal to the package's, and abaseURL. A Plugin with Python Workers has apythonsection whose project holdspyproject.tomlanduv.lock. It stops on the first problem, saying what it is. - Writes
npm-shrinkwrap.jsonwhen the Plugin hasdependenciesoroptionalDependencies(and does not bundle them): from yourpackage-lock.json, withnpm shrinkwrap, which renames it: from then on npm keeps the shrinkwrap current in its place. Without a lockfile it stops: runnpm installfirst. Annpm-shrinkwrap.jsonalready there is packed as it is (with a warning when apackage-lock.jsonsits beside it, which npm ignores). - Runs
npm pack, into--out(default: the current folder). Leave Python environments and bytecode (.venv,__pycache__) out with an.npmignoreorfiles; it warns when the tarball holds them, and stops (removing the tarball) when it lacks thepyproject.tomloruv.lockof a Plugin with Python Workers. - Prints the index entry for the tarball on stdout, as JSON, with its SHA-512 integrity. Fill in
urlwith where you publish the tarball (a GitHub Release, npm, any URL), and submit the entry to a Plugin repository:
{
"id": "example.word-counter",
"version": "1.0.0",
"sdk": "^0.8.0",
"python": { "variants": ["cpu"] },
"url": "",
"integrity": "sha512-…"
}python is false for a Plugin without Python Workers, else the variants its environment comes in (cpu, then the ones python.variants names). Progress and warnings go to stderr, so fyflow-plugin pack > entry.json keeps the entry alone.
A repository's index.json lists each Plugin once, with its versions: { "schema": 1, "plugins": [{ "id", "name", "description", "homepage"?, "icon"?, "versions": [{ "version", "sdk", "python", "url", "integrity", "platforms"?, "published"? }] }] }, where published is an ISO date and time the repository sets. A version may have artifacts: [{ "platforms", "url", "integrity" }] in place of url, integrity and platforms (below). A Host ignores keys it does not know (an entry's id beside its version included), and takes the whole index as malformed when a key it reads is wrong. It lists only the versions whose sdk range admits its SDK, and installs one only when the download (256 MB at most) matches integrity and holds that id and version.
Plugins for some platforms
Most Plugins are one tarball for every platform: their code is JavaScript or TypeScript, their npm dependencies are installed on the Host's machine (npm picks a dependency's per-platform builds there), and Python Workers' wheels are resolved per platform by uv from the one uv.lock. A Plugin that ships binaries of its own (an ffmpeg, a model runtime) has two ways to say where it runs. Platforms are spelled as Node spells them: <os> (process.platform: win32, darwin, linux), which means every cpu, or <os>-<cpu> (with process.arch: x64, arm64).
One tarball, some platforms. Set npm's own
osandcpuin itspackage.json("os": ["darwin", "linux"],"cpu": ["x64"], or"os": ["!win32"]).packwrites them into the entry asplatforms(["darwin", "linux"]; withcpu, each<os>-<cpu>; an exclusion is read againstdarwin,linuxandwin32,x64andarm64). A Host on another platform lists the Plugin, greyed, as "Not available for Windows (x64)", and never installs or offers that version. Installed by upload, URL or folder on such a Host, it is installed all the same, with a warning in its preview.One tarball per platform. Build each platform's tarball on that platform (in CI, a runner each) and pack it with
--platform, once per tarball, naming the platforms it is built for (repeatable:--platform darwin-arm64 --platform darwin-x64). Each must be one the package'sosandcpuallow (a bare<os>is refused whencpulimits them). The tarball is named for them,<name>-<version>-<platform>.tgz(several joined by_), so the platforms' tarballs sit side by side in one release, and the entry printed is one artifact:{ "id": "example.thumbnails", "version": "1.0.0", "sdk": "^0.10.0", "python": false, "artifacts": [{ "platforms": ["win32-x64"], "url": "", "integrity": "sha512-…" }] }Merge the platforms' entries into one version, its
artifactsholding each platform's artifact, every one with itsurlfilled in. No platform may be named in two artifacts of one version, nor a bare<os>in one and an<os>-<cpu>of it in another. A Host installs the first artifact that names its platform; a Host whose platform none names lists the version as not available.Or put the binaries in per-platform npm packages, as esbuild and sharp do: a package per platform, each with its
osandcpu, all listed in the Plugin'soptionalDependencies. npm installs the one for the Host's machine, so the Plugin stays one tarball. Choose it when the binaries are worth publishing to npm on their own, or another project uses them too; otherwise per-platform tarballs keep everything in the Plugin's own release.
A tarball is at most 256 MB, whichever way: a Plugin with large binaries for several platforms ships one artifact per platform.
The rest is the Host's to check: the Node Types, Workers, schemas, gate and Resource Group types of the definition, and whether the peer range admits the SDK the Host implements. A Host checks all of it when it installs the tarball, and refuses it with the reason, before anything is installed. Before submitting, install the tarball into a Host (upload it on the Plugins page, with plugins.install set to any) and run a Task.
Publishing to the official repository
The official Plugin repository, fyflow/fyflow-plugins, is the index every Host reads unless configured otherwise (https://fyflow.github.io/fyflow-plugins/index.json): its Plugins are listed on the Plugins page, under Browse. A version gets there by a PR adding it to plugins/<id>.json, which its CI checks. A reusable workflow in that repository does all of it from a tag in yours: it packs the Plugin, creates a GitHub Release with the tarball, and opens the PR.
What belongs there: Plugins that do real work, not examples, demos or tests. An id is permanent once a version is published under it, since Flows record the Plugins they use by id: namespace yours (acme.tagger), and choose it before the first release. A published version never changes either: to fix one, release the next.
Layout
One Plugin per repository, at its root:
acme-tagger/
.github/workflows/release.yml
package.json "version": "1.0.0", peerDependencies: { "fyflow-plugin-sdk": "^0.10.0" }
package-lock.json or pnpm-lock.yaml
plugin.ts id "acme.tagger", version "1.0.0"
tag.worker.tsOr several, each in a folder of plugins/, each with its own package.json (a workspace at the root, or a lockfile per Plugin):
acme-plugins/
.github/workflows/release.yml
package.json the workspace: no fyflow-plugin-sdk peer
pnpm-lock.yaml
plugins/
tagger/ package.json, plugin.ts id "acme.tagger"
resizer/ package.json, plugin.ts id "acme.resizer"A tag names one Plugin and its version: <id>@<version> ([email protected]; the id's dots are fine in a tag). The workflow takes the folder whose definition has that id and version, so package.json, the definition and the tag must all say the same version.
The workflow reads the Plugin's fields in the index from its package.json: description, and homepage (else the GitHub repository). Under a "fyflow" key it reads name (the name people see; default: the package name) and icon (one of the icons). A Plugin built before it is packed adds build, the command that builds it, and pack, the folder that is packed, which holds its own package.json:
{ "fyflow": { "name": "Tagger", "icon": "tag", "build": "pnpm build", "pack": "dist" } }The workflow
Add .github/workflows/release.yml:
name: Release
on:
push:
tags: ["*@*"] # <id>@<version>
jobs:
release:
permissions:
contents: write # the GitHub Release
uses: fyflow/fyflow-plugins/.github/workflows/release-plugin.yml@v1
secrets:
FYFLOW_PLUGINS_TOKEN: ${{ secrets.FYFLOW_PLUGINS_TOKEN }}
# with:
# platforms: win32-x64 darwin-arm64,darwin-x64 linux-x64 # one tarball per platform group (below)And add the secret: in your repository's Settings → Secrets and variables → Actions, FYFLOW_PLUGINS_TOKEN, a classic personal access token with the public_repo scope (a fine-grained one cannot open a PR on another owner's repository). The workflow forks fyflow-plugins with it and opens the PR. Without the secret it makes the Release, prints the entry in the run's summary, and leaves the PR to you.
Then release with git tag [email protected] && git push origin [email protected]. The run's summary links the PR; watch its checks with gh pr checks <url> --repo fyflow/fyflow-plugins --watch. Its admit check must pass, and a maintainer merges it. Once Pages has deployed, the version is in the index.
For a Plugin with one tarball per platform, set platforms: each group is packed with pack --platform on a runner of that os, and the artifacts are merged into one version (see Plugins for some platforms).
A second reusable workflow checks on every push that each Plugin of the repository packs:
name: CI
on: [push, pull_request]
jobs:
pack:
uses: fyflow/fyflow-plugins/.github/workflows/check-plugin.yml@v1The workflows install your dependencies (pnpm install --frozen-lockfile with a pnpm-lock.yaml, npm ci with a package-lock.json, or the command you give as install), and pack with Node 26 (node-version). If your Plugin has npm dependencies, keep a package-lock.json beside it even when you develop with pnpm (npm install --package-lock-only writes it): pack turns it into the npm-shrinkwrap.json a Host installs from. Write it the normal way, with the SDK installed as a peer: a lockfile written with --legacy-peer-deps leaves the SDK out, and npm ci then refuses it, on a Host too.
Test it in a Host before you tag
npx fyflow-plugin pack --out dist, then in a Host with plugins.install set to any (the desktop app's default), upload dist/<name>-<version>.tgz on the Plugins page, confirm the install, and run a Task of each Node Type. The repository's CI loads the Plugin and checks its entry, but it runs no Task.
Python Plugins
There are no pure-Python Plugins: a Python Plugin is always a small JS module, plugin.ts and package.json, naming its Python project (see Python Workers), since the Host reads every definition from JS. So it packs and publishes the same way, and its author needs Node only to pack, which the workflow does for them. Ship the project, its pyproject.toml and uv.lock, and leave out the environment, bytecode and tests with a files list:
{
"files": [
"plugin.ts",
"python/pyproject.toml",
"python/uv.lock",
"python/acme_tagger/",
"!**/__pycache__"
]
}What fyflow-plugins' CI checks on each PR (the schema, that published versions never change, the download and its integrity, the package against the entry, loading the Plugin as a Host does, and that its Node Type and gate type names are not another Plugin's) is in its README.
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 to 0.10.0 only add to 0.5.0, so a Host on 0.10 loads a Plugin declaring ^0.5.0, ^0.6.0 and so on up to ^0.10.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 process from "node:process";
import type { HostConfig, Worker } from "fyflow-plugin-sdk";
// `dataDir` is there whenever the Plugin sets `dataDir: true`.
type Config = { host: HostConfig & { dataDir: string }; 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. Nor may a Plugin whose peer range admits 0.11.0, whose Workers find their pool options there too (host.pool), and a Python Plugin's Workers their build (host.variant). Any other Plugin that does not ask gets 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.
Allowed roots: the folders a Task may touch
A Plugin that reads or writes files a Flow names sets roots: true (since 0.11.0) to take the Host's Allowed roots (ADR-0030). A person sets them on the Profiles page: the Profile's, once for every Plugin that takes them, and your Plugin's own in its section, where they may also leave the Profile's out. Each Task then carries its reach, the folders it may touch with read or write access (write grants read), at settings.roots. Only a Profile grants roots: the Host writes them over anything a Flow puts there, and your Workers may not name roots in their settingsSchema.
The Host has no sandbox, so your Worker keeps to the reach by checking every path it is given, before it touches it:
import { allowedPath, type TaskEnvelope, type Worker } from "fyflow-plugin-sdk";
export default class Hash implements Worker<{ path?: string }, { path: string }> {
async run({ settings, input }: TaskEnvelope<{ path?: string }, { path: string }>) {
// Where the path really leads, or a throw saying why it is refused.
const file = await allowedPath(settings.roots, settings.path ?? input.path, "read");
// … read `file`
}
}allowedPath(reach, path, access, { unfollowed? })answers wherepathreally leads, links followed, so a link inside a root that points out of it is refused like any path outside; a part that does not exist yet is joined onto its nearest existing parent, so a path to write is checked before it is made. On Windows, case and slash direction do not matter. It refuses a relative path, every path while the reach has a relative root, and every path while the reach is empty, saying to add a root on the Profiles page. Withunfollowed, a link at the path's end is checked where it stands: what deleting a link removes.refuseRoots(reach, path, doing)refuses a path that is an Allowed root or holds one, compared by identity on disk: call it before you move or delete something, so a Flow never takes away a folder it was given.- A Python Worker has the same checks from the Host, as
ctx.allowed_path(path, access="read")andctx.refuse_roots(path, doing)(see Python Workers).
Python Workers
A Worker may be written in Python (since 0.8.0, ADR-0026). The Plugin stays a JS module: its definition, schemas, Node Types, groups and status() are as for any Plugin. It names a Python project, and for each Python Worker a class in it instead of a url. The Host brings the rest: uv, a Python, the Plugin's environment, and the process each Worker instance runs in. fyflow-app's examples/plugins/word-counter is a whole one to start from.
From pyproject.toml to a Run
Take a Plugin that tags images with a model kept loaded on the GPU between Tasks. Its folder:
acme-tagger/
package.json peerDependencies: { "fyflow-plugin-sdk": "^0.9.0" }
plugin.ts
python/
pyproject.toml
uv.lock
acme_tagger/
__init__.py
tag.pyThe project.
python/pyproject.tomlis an ordinary uv project: itsdependenciesare what the Worker imports, andrequires-pythonwhich Python uv downloads for it. Runuv lockinpython/and shipuv.lockbeside it: the Host syncs from the lock alone (uv sync --frozen), so a Plugin without one is not loaded, and its error says to runuv lock. The project itself is not installed: its folder is on the Python path, soacme_taggeris imported from it as it is.[project] name = "acme-tagger" version = "1.0.0" requires-python = ">=3.12" dependencies = ["pillow>=11", "transformers>=4.50"]torch is not among them: it comes in a CPU and a CUDA build, which the project declares as extras,
cpuandcuda(see "GPU builds" below).The Plugin.
pythonnames the project and its builds (variants); a Worker'spythonnames its class,module:Class. The Node Type'spoolis the Worker's, as for a JS Worker's:import { definePlugin, GPU_GROUP } from "fyflow-plugin-sdk"; export default definePlugin({ id: "acme.tagger", version: "1.0.0", base: import.meta.url, python: { project: "./python", variants: { cpu: ["cpu"], cuda: ["cuda"] } }, profile: { schema: { type: "object", properties: { threshold: { type: "number" } }, additionalProperties: false }, defaults: { threshold: 0.5 }, startup: ["threshold"], }, workers: { tag: { python: "acme_tagger.tag:Tag" } }, nodeTypes: { Tag: { worker: "tag", profile: ["threshold"], // One process with the model loaded, four Tasks at once in it, holding the GPU while it lives and for // 10 minutes after its last Task. pool: { maxThreads: 1, maxConcurrentTasks: 4, idleTimeout: 600_000, residentGroups: [GPU_GROUP] }, }, }, });The Worker. The class is built once per Worker instance, in a Python process of its own, with the config a JS Worker's constructor gets (the start-up fields, and
hostwhen the Host gives the Plugin anything), and, if its__init__takes a second argument, its pool (since 0.9.0:pool.grants, the units it holds, below).setup()loads what every Task uses, once per process;run()does one Task. Each of them, andteardown(), may be plain orasync def;setup()andteardown()are optional:import threading import torch from PIL import Image class Tag: def __init__(self, config, pool): self.threshold = config.get("threshold", 0.5) self.gpu = pool.grants.get("gpu", [0])[0] # the GPU this process holds while it lives def setup(self): from transformers import pipeline # On that GPU with the `cuda` variant's torch, else on the CPU with the `cpu` one's. device = f"cuda:{self.gpu}" if torch.cuda.is_available() else "cpu" self.model = pipeline("image-classification", device=device) # once per process self.lock = threading.Lock() def run(self, task, ctx): ctx.progress(0.1, "reading") image = Image.open(task.input["path"]).convert("RGB") # up to four Tasks do this at once with self.lock: # and take turns on the model labels = self.model(image) return {"tags": [l["label"] for l in labels if l["score"] >= self.threshold]} def teardown(self): del self.modelThe Run. Put the folder in the Host's Plugin directory (the Host provides the SDK beside it) and Reload. The Plugins page shows a line under the Plugin: Preparing (uv's current step) while the Host makes its Python environment, then Ready (the Python and the variant), or Failed (uv's last lines, and a Retry). The first sync downloads a Python and the locked packages; later ones reuse uv's cache, shared by every Plugin. A Task started meanwhile waits for it.
Tagis in the palette like any Node Type; its Tasks gettask.settings(withprofile) andtask.inputas a JS Worker's do.
The Host syncs once per Plugin, variant and lock each time it starts; a Reload syncs again only a Plugin whose pyproject.toml or uv.lock changed, or whose sync failed. It uses its own uv (bundled with the packaged Host and the desktop app; in server Mode python.uv in fyflow.config.json, else the PATH's), and a Python uv downloads, never the machine's own. A Host with no uv skips the Plugin, saying so. Only a Plugin with Python Workers has a Python environment.
GPU builds: variants and pickVariant
A package often comes in a CPU build and a GPU build. variants maps a build's name to extras of the project. Without variants there is only cpu, with no extras; a cpu with extras must be listed.
The Plugin picks which build this machine runs (since 0.11.0): pickVariant({ platform, arch }) returns one of its builds, and the Host syncs that one. It is called before the sync, at the Host's start and at each Reload. Without it, the build is cpu. One that throws, returns a build the Plugin does not have, or takes over 10 s gets cpu, and the Plugin's card on the Plugins page says why. Only the Plugin knows what its builds need (a driver, its version, an OS), so detecting the machine is its own, and it may import its own npm dependencies for it. The Host's python.accelerator setting, which picked the build for every Plugin before 0.11.0, is no longer used.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import process from "node:process";
// An example, not an API: an NVIDIA driver is there (Linux: its version file or libcuda; Windows: nvcuda.dll).
function hasNvidiaDriver(platform: string): boolean {
if (platform === "linux") {
if (existsSync("/proc/driver/nvidia/version")) return /NVIDIA/.test(readFileSync("/proc/driver/nvidia/version", "utf8"));
return ["/usr/lib/x86_64-linux-gnu/libcuda.so.1", "/usr/lib64/libcuda.so.1"].some(existsSync);
}
if (platform === "win32") return existsSync(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "nvcuda.dll"));
return false;
}
python: {
project: "./python",
variants: { cpu: ["cpu"], cuda: ["cuda"], directml: ["directml"] },
pickVariant: ({ platform }) => (hasNvidiaDriver(platform) ? "cuda" : platform === "win32" ? "directml" : "cpu"),
},The detection above is an example, not an API: write the checks your own builds need.
A person may set another build for some Nodes, or for all: a Profile's section of a Python Plugin sets its build (the Default Profile's for every Node but those of a Profile setting its own), over your pick. The Host syncs every build some Profile runs, and each pool's Workers run in their Profile's build, so one Flow can run a Node on the GPU and another on the CPU. A build you do not have falls back, with a warning on the Profiles page.
Each Worker reads the build it runs in at host.variant (since 0.11.0, under the same opt-in as host.pool: your peer range admits 0.11.0), to pick what it runs on, such as ONNX execution providers:
import onnxruntime
class Tag:
def __init__(self, config, pool):
build = config["host"].get("variant", "cpu")
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] if build == "cuda" else ["CPUExecutionProvider"]
self.session = onnxruntime.InferenceSession("model.onnx", providers=providers)A driver that is there but too old, or broken, still fails when a session starts: keep a fallback to the CPU in your Worker too.
uv sync does not detect a machine's GPU (only uv pip has a torch backend), so torch's builds are the Plugin's to name: declare each extra's index in your own pyproject.toml, and mark the extras as conflicting, since one environment holds one torch:
[project.optional-dependencies]
cpu = ["torch>=2.7"]
cuda = ["torch>=2.7"]
[tool.uv]
conflicts = [[{ extra = "cpu" }, { extra = "cuda" }]]
[tool.uv.sources]
torch = [
{ index = "pytorch-cpu", extra = "cpu" },
{ index = "pytorch-cu128", extra = "cuda" },
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[[tool.uv.index]]
name = "pytorch-cu128"
url = "https://download.pytorch.org/whl/cu128"
explicit = truepython: { project: "./python", variants: { cpu: ["cpu"], cuda: ["cuda"] } },uv lock locks both; each Host syncs one, and keeps an environment per variant and lock.
A Node Type's pool may differ per build (since 0.11.0): pool.variants maps a build to what it changes of the pool. A numeric option (maxThreads, maxConcurrentTasks, idleTimeout, cancelGraceMs) replaces the base pool's, which must set it too; residentGroups replaces the base's whole, {} for none. So the cpu build of a Node Type holding the GPU need not hold it, and holds more cpu instead:
pool: {
maxThreads: 1,
idleTimeout: 600_000,
residentGroups: { gpu: 1, cpu: 1 },
variants: { cpu: { residentGroups: { cpu: 4 } } },
},Each pool takes its own build's: a Profile setting another build for its Nodes gets that build's pool, with the Profile's pool options over it. A key that is not one of the Plugin's builds skips the Plugin with the reason.
Concurrency
Each Worker instance is one Python process, driven from a thread of the Host's own:
maxThreadscounts processes, somaxThreads: 1loads the model once. A Node Type on a Python Worker may not beinline.maxConcurrentTasksis how many Tasks run at once in one process. A plaindef runruns each on a thread of its own, so what they share (a model, a cache) needs a lock unless it is safe to share. A lock held for all ofrunmakes them take turns, so hold it only around what must not overlap, asTagdoes; anasync def runruns them all on one event loop, so it must never block it (useawait asyncio.to_thread(…)for blocking work).residentGroupsis held for the process's life, fromsetup()until it is retired, as for a JS Worker ("Holding a group while a Worker lives").Which units it holds (since 0.9.0, "Which units a Worker holds"): a class whose
__init__takes a second argument gets its pool, whosegrantsare the process's resident units,{"gpu": [1]}; one taking the config alone is built as before. Each Task'sctx.grantsare its own, its process's included. The Host sets nothing in the process's environment, such asCUDA_VISIBLE_DEVICES: pass the device to your library (cuda:1).A Plugin with
roots: true(since 0.11.0, "Allowed roots") finds its Task's reach attask.settings["roots"], and checks a path withctx.allowed_path(path, access="read", unfollowed=False), which answers where it really leads or raises aPermissionErrorsaying why it is refused, andctx.refuse_roots(path, doing)before moving or deleting it. They are the JS checks, rule for rule and message for message:def run(self, task, ctx): image = ctx.allowed_path(task.input["path"]) # raises PermissionError outside the reach return self.tag(image)
Cancel
- A Cancel stops that Task only:
ctx.cancelledturns true, andrunis interrupted, byKeyboardInterruptin its thread or byCancelledErrorin anasync def run. What it returns afterwards is discarded. Other Tasks in the process go on. - Python is interrupted only between its own steps: a long call into native code (a model's forward pass) finishes first. A long loop of your own may check
ctx.cancelledand stop early. - A Task still running 10 s after its Cancel (the Node Type's
pool.cancelGraceMs, 10 s for Python Workers) is forced: the process is killed, its other Tasks run again on a fresh one, and the model loads again.
Logs
ctx.log(message)andctx.progress(fraction, message=None)reach the Task: its Run Events, and the Run's page. Progress is sent at most 10 times a second.- What the process writes to stdout or stderr (
print,logging, a library, a child process) goes to the Host's log, tagged with the Plugin and Worker, and the Plugins page keeps each Python Worker's last 200 lines under "Python output" (Agents read them withget_python_output). It never reaches a Task: with several Tasks in a process, a line cannot be told apart by Task. Profile secrets are redacted from it. runraising fails the Task with the exception's message, and its traceback goes to the Host's log.- A
__init__orsetup()that raises fails the Task waiting for it with the exception's message, and the process ends. Unlike a JS Worker's, the next Task starts a fresh process, so a cause that has gone away (a file now there, a service now up) needs no Reload. A process that exits while Tasks run has them run again on a fresh one, and its exit code and last stderr line go to the Host's log.
What not to do
- Do not catch
BaseException(or a bareexcept:) around work a Cancel must interrupt: it swallowsKeyboardInterruptandCancelledError, and the Task runs on until it is forced. CatchException. - Do not install packages at run time (
pip install,uv add): the environment is made from the lock, shared, and replaced when the lock changes. Add them topyproject.tomland runuv lock. - Do not write into the project or the environment. Ask for a data directory (
dataDir: true) and write underconfig["host"]["dataDir"]. - Do not read stdin, or write the protocol yourself: stdin and stdout are the Host's channel to the process. Anything printed goes to the log instead, so it is harmless, but it is not a Task's output: return it, or
ctx.logit. - Do not pass secrets in environment variables: a Worker gets them as Profile fields (
writeOnly), which the Host redacts. - Do not return a big result: one over 16 MiB fails the Task. Write it to a file in the data directory and return its path.
- Do not leave processes behind on purpose: when the Worker's process ends, however it ends, so does every process it started.
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 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). Its units are the Host's GPUs, numbered0ton-1as CUDA numbers devices: 1 unit, since a Host cannot count its GPUs, until a person sets their number on a machine with more. Since 0.9.0 a Task or Worker holding one is told which (see Which units a Worker holds). Before, it only counted, and the Plugin chose each Task's device.
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 (since 0.9.0, or which: Which units a Worker 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 change what a Worker holds
A Profile may set residentGroups for a Node Type next to its other pool options, so how many units it holds fits the machine rather than one number you picked for every machine: { cpu: 8 }, or a pin, { gpu: { from: [1] } }. Each field it leaves out falls back to your declaration's, field by field: a Profile setting cpu: 8 keeps a from you declared. It may add a group in your reach (a gpu only with a warning shown to the person, since your code may not use it), and never removes one you declared. The Host checks it as it checks your declaration.
So read what your Worker holds rather than repeating your number: since 0.11.0 each Worker of a Plugin whose peer range admits 0.11.0 finds the pool options it runs under in its config, as host.pool, with residentGroups always in one form, { <group>: { cost, from?, prefer? } }:
import type { HostConfig, Worker } from "fyflow-plugin-sdk";
export default class Classify implements Worker {
readonly #threads: number;
constructor(config: { host?: HostConfig }) {
// As many threads as `cpu` units this Worker holds: what fyflow booked for it. Absent on a Host before 0.11.0.
this.#threads = config.host?.pool?.residentGroups?.cpu?.cost ?? 1;
}
// …
}host.pool holds maxThreads, maxConcurrentTasks, idleTimeout and residentGroups, each only when your Node Type or its Profile sets it (absent means fyflow's default), and is fixed for the Worker's life: a Profile's change takes effect at the next Reload. A batching Worker sizes its batches by maxConcurrentTasks the same way. Which units of a group that names them it got is in grants, below. In Python, config["host"]["pool"].
Which units a Worker holds
Since 0.9.0, a group that names its units, such as gpu (a fyflow UnitGroup), tells whoever holds one which it is. grants maps each such group's key to the units held, always a list: { gpu: [1] } for the second GPU. A group that only counts grants nothing and is absent from it.
- A Worker's resident units are in its
PoolContext, the constructor's second argument, asgrants. They are there from the constructor on, sosetup()loads onto that device. A replacement instance may get another. - A Task's units are in its
WorkerContext,run's second argument, asgrants: those its Node Type'sgroupstook for it, and its Worker's resident ones, its own first where both name a group. They are granted afresh for each attempt, so read them in eachrunand never keep them.
import type { PoolContext, Worker, WorkerContext } from "fyflow-plugin-sdk";
export default class Model implements Worker {
readonly #device: string;
constructor(_config: object, pool?: PoolContext) {
const [gpu = 0] = pool?.grants?.gpu ?? [];
this.#device = `cuda:${gpu}`; // with maxThreads: 2 on a two-GPU Host, one instance gets 0 and the other 1
}
async setup() {
// load the model onto this.#device
}
async run(envelope, context: WorkerContext) {
// …
}
}A Node Type may also ask for particular units in pool.residentGroups, with a request in place of a number:
| Want | residentGroups |
| --- | --- |
| any GPU | [GPU_GROUP], or { [GPU_GROUP]: 1 } |
| only GPU 1 | { [GPU_GROUP]: { from: [1] } } |
| GPU 0 or 1, whichever is free | { [GPU_GROUP]: { from: [0, 1] } } |
| both, together | { [GPU_GROUP]: { cost: 2, from: [0, 1] } } |
| GPU 1 if free, else any | { [GPU_GROUP]: { prefer: [1] } } |
A request is judged against the Host's groups as they are: a Host whose gpu has fewer units than the request names skips the Plugin, saying which unit it lacks. Ask only for what the model needs (two cards, say), since a Plugin cannot know which GPUs a Host has. A Host before 0.9.0 refuses a request, so a Plugin using one says ^0.9.0.
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.
