Workflow interface
Declaring a workflow's public contract — the named inputs and outputs a caller can rely on, independent of the canvas.
A workflow's interface is its declared public contract: the named inputs a caller must supply and the named outputs it gets back. It is what lets a workflow be invoked as a sub-workflow, an agent tool, an HTTP endpoint, or a host-side action, without the caller reverse-engineering the graph.
This is a separate axis from port exposure. exposed controls whether a port is visible and wireable inside the canvas. The interface controls what is callable from outside the workflow entirely. The distinction mirrors Docker: EXPOSE documents a container's ports internally, while published ports are what the outside world actually reaches. A port can be exposed on the canvas without ever being published in the interface, and only exposed ports are eligible to be published at all — see Rules below.
Workflow.interface is optional and additive. A workflow with no interface key behaves exactly as it does today — there is no declared contract, and nothing about existing workflows changes.
Schema
interface PortBinding {
nodeId: string;
portId: string;
}
interface WorkflowInterfaceEntry {
id: string;
name?: string;
description?: string;
dataType: string;
required?: boolean; // inputs only; ignored on outputs
defaultValue?: unknown;
examples?: unknown[]; // inputs only; author metadata, never validated
schema?: object; // one port's JSON Schema fragment
bindings: PortBinding[];
meta?: Record<string, unknown>;
}
interface WorkflowInterface {
inputs?: WorkflowInterfaceEntry[];
outputs?: WorkflowInterfaceEntry[];
}| Field | Type | Description |
|---|---|---|
id | string | Stable public name. Unique within its direction (inputs or outputs). See Stable identity. |
name | string | Display label for callers and UI. Defaults to id when absent. |
description | string | Human-readable summary of what this input or output represents. |
dataType | string | The port's lane — same vocabulary as port data types, not the JSON Schema type. See Lane and schema. |
required | boolean | Inputs only. Whether a caller must supply a value. Ignored on outputs. |
defaultValue | unknown | Default value used when a caller omits this input. |
examples | unknown[] | Example values a caller could supply — author metadata for docs and launch forms. Inputs only; never validated. |
schema | object | The port's JSON Schema fragment — one property's schema ({ "type": "array" }), not a whole object schema. See Lane and schema. |
bindings | PortBinding[] | The inner port(s) this entry resolves to. See Bindings. |
meta | object | Opaque, server-defined metadata. See The meta passthrough. |
Array order in inputs and outputs is the caller-facing order — for example, the parameter order of a generated function signature or form.
Lane and schema
dataType and schema answer two different questions, and an entry carries both:
dataTypeis the lane — what the port is, and what it may connect to. It draws the handle's colour and decides edge compatibility.schemais the shape of the value — a JSON Schema fragment for this one port.
The two vocabularies overlap on string, number, boolean and array, which is why they are easy to confuse. They diverge exactly where it matters:
{
"id": "history",
"dataType": "messages",
"schema": { "type": "array" },
"bindings": [{ "nodeId": "chat_1", "portId": "messages" }]
}A messages port is a JSON array; an error port is a JSON object. Reading dataType as a JSON Schema type gets you array and object — true, but not what the port is. Read schema.type when you want the JSON Schema word.
schema carries the structural contract only. Anything the entry states itself — the lane, name, description, examples, required — is not repeated inside it.
Both keys are derived by the server from the inner port each entry binds to, and both are ignored on write. Sending a different dataType does not change what the next read returns; the bound port decides.
Bindings
An interface entry does not point at an inner port directly by embedding a flag on it. Instead it owns a stable id and a separate bindings list of { nodeId, portId } pointers into the graph. This indirection exists so that swapping or renaming a node does not silently break callers: the public id stays put even when the node underneath it changes.
Every entry — input or output — resolves to exactly one binding. An entry with more than one binding is invalid. An entry with an empty bindings array is a valid, deliberate draft state: the contract slot exists, but nothing backs it yet.
A binding carries no direction of its own. Which array the entry lives in decides the side of the node it points at: an entry under inputs binds to one of that node's input ports, an entry under outputs to one of its output ports. This matters because a portId only has to be unique within one side of a node — a node can declare both an input and an output named message, and the entry's direction is what distinguishes them.
{
"id": "article_text",
"name": "Article Text",
"description": "The body text to summarize",
"dataType": "string",
"required": true,
"bindings": [
{ "nodeId": "text_input.1", "portId": "content" }
]
}Stable identity
An entry's id is workflow-scoped and independent of whichever inner port currently backs it. This matters for two reasons:
- Node swaps and renames don't break callers. If a node is swapped for another, or an inner port is renamed, only the
bindingsentry needs to change — the publicida caller depends on is untouched. - On export to a server manifest,
idbecomes the manifest's wire identity — thenamea caller actually calls. Renaming an entry'sidis therefore a breaking change for callers, not a cosmetic one, in the same way renaming a function parameter in a public API is.
Rules
- Only canvas-exposed ports can be bound. A port that isn't exposed is hidden, not wireable, and not runtime-overridable — publishing it in the interface would contradict that. The two axes compose in one direction only: the external interface is always a subset of what's exposed on the canvas (
external ⊆ internal). - Control-flow ports are not bindable. Ports with the reserved
triggerortooldata types, and a loop'sloop_backport, are intra-graph orchestration rather than call signature. They never appear as binding candidates. - Deleting a node does not shrink the contract. If a node backing an interface entry is deleted, the entry's binding is left dangling rather than the entry being silently removed. A public contract can't shrink just because someone rearranged the canvas — a dangling entry is a visible problem to fix, not a quiet change to what callers can rely on.
- The interface is optional and additive. Absence of
Workflow.interfacemeans the workflow declares no contract at all — not an empty one. Existing workflows, serialization, and exports are unaffected until an interface is added.
The meta passthrough
meta on a WorkflowInterfaceEntry is an opaque bag that the FlowDrop library never reads or interprets. It round-trips verbatim through load, save, and export.
"Outside" doesn't mean one thing — a Drupal integration exposing a workflow as an action, an agent calling it over MCP, and a webhook accepting JSON all have different ideas of what publishing an input or output entails. The library's job is only to model that an interface exists; each server defines what "external" means for its own integration, using meta to carry that meaning:
{
"id": "article_text",
"dataType": "string",
"bindings": [{ "nodeId": "text_input.1", "portId": "content" }],
"meta": {
"http": { "in": "query" },
"mcp": { "toolParam": true }
}
}Keys under the fd. prefix are reserved for future use by the FlowDrop library itself. Don't use fd.* keys for your own server-specific conventions.
What starts a run
The interface describes a workflow's call signature — the parameters a run needs and the results it produces. It does not describe what causes a run to start. That's a separate, future concern: a trigger (a schedule, an incoming webhook, a host event) produces values that land in the interface's inputs, but a trigger is not itself an interface entry. A cron trigger might produce nothing at all; an "entity saved" event might produce a whole payload. The interface is the parameter list; a trigger is one of potentially several things that can fill it.
Next Steps
- Port System & Data Types — the canvas-level
exposedaxis that interface bindings depend on - Workflow Structure — where
interfacesits alongsidenodes,edges, andmetadataon the workflow document - Node Structure — how nodes declare the ports that bindings point at