FlowDrop Docs

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[];
}
FieldTypeDescription
idstringStable public name. Unique within its direction (inputs or outputs). See Stable identity.
namestringDisplay label for callers and UI. Defaults to id when absent.
descriptionstringHuman-readable summary of what this input or output represents.
dataTypestringThe port's lane — same vocabulary as port data types, not the JSON Schema type. See Lane and schema.
requiredbooleanInputs only. Whether a caller must supply a value. Ignored on outputs.
defaultValueunknownDefault value used when a caller omits this input.
examplesunknown[]Example values a caller could supply — author metadata for docs and launch forms. Inputs only; never validated.
schemaobjectThe port's JSON Schema fragment — one property's schema ({ "type": "array" }), not a whole object schema. See Lane and schema.
bindingsPortBinding[]The inner port(s) this entry resolves to. See Bindings.
metaobjectOpaque, 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:

  • dataType is the lane — what the port is, and what it may connect to. It draws the handle's colour and decides edge compatibility.
  • schema is 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 bindings entry needs to change — the public id a caller depends on is untouched.
  • On export to a server manifest, id becomes the manifest's wire identity — the name a caller actually calls. Renaming an entry's id is 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 trigger or tool data types, and a loop's loop_back port, 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.interface means 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

On this page