Documentation

Build plugins for VisualAutomate.

Everything you need to write a step, describe its fields and publish it.

01 Getting Started

A plugin is one step somebody can drop on a canvas. It is a Node module exporting an execute function, and a manifest.json beside it saying which fields the canvas should render and what the step hands on.

Where you write it

In the developer portal. The editor there knows the whole runtime: hover any part of context for its documentation, and it completes context.storage., context.state. and the rest from the same declarations this page is generated from.

A command-line tool — login, init, test, push, with a local runner that gives your plugin a working storage and state to test against — is built and not published yet.

What a step looks like

import type { PluginModule } from "./visualautomate"

const plugin: PluginModule = {
  async execute(input, config, context) {
    // input   what the steps before this one produced
    // config  the fields somebody filled in on the canvas
    // context storage, state, media, connections, and the run's ids

    const bytes = await context.storage.get(config.source as string)
    if (!bytes) {
      return { output: "error", data: { error: "No such file." } }
    }

    // `output` names which port the run leaves by, so it decides which
    // line on the canvas is followed. `data` is what the next step reads.
    return { output: "success", data: { size: bytes.length } }
  },
}

export default plugin

02 @schema Reference

Top-level Schema Fields

properties
objectrequired

Configurable node properties shown in the properties panel

inputs
string[]

Input port names. Default: ["input"]

outputs
string[]

Output port names. Default: ["success", "error"]

outputFields
object

What the step returns, for the variable picker: a description per field, or { type, description, fields } so the picker can show a list and its fields before the step has run. Types: text, number, true/false, list, object

icon
string

Icon name for the node (e.g., 'globe', 'database')

color
string

Accent color for the node (hex, e.g., '#3b82f6')

timeoutMs
number

Max execution time in ms (default: 10000, max: 60000)

memoryLimitMb
number

Max memory in MB (default: 32, max: 128)

description
string

Description shown in the plugin browser

Property Types

string

Text input. Supports pattern validation and default values.

number

Numeric input with min/max validation.

boolean

Toggle switch for true/false values.

select

Dropdown with predefined options.

multiselect

Multi-value selector with predefined options.

json

JSON editor for structured data (objects, arrays).

color

Color picker input.

date

Date picker input.

03 Execution Model

The execute function receives three arguments:

input

What every earlier step on the route produced, keyed by its step name: input.get_customer.email. One predecessor is still read by name.

config

User-configured properties from the @schema. Key-value pairs matching your property names.

context

Workflow metadata: workflowId, nodeId, userId, secrets, executionId.

Your function must return an object with output (the port name) and data (the result payload):

// Success output
return {
  output: "success",
  data: { message: "Hello!", count: 42 }
};

// Error output
return {
  output: "error",
  data: { errorMessage: "Something went wrong" }
};

In a Code step you write only the body of that function, and the return is forgiven: return anything and it becomes the step's data on the success port. Return an object and each key is a field the next step can read on its own, as {{step_name.data.field}}. Throwing fails the step with the message on the run, and console.log writes to the terminal beside the canvas.

Inside a loop, the turn's element is input.item, with input.index and input.count; where a loop ends, what every turn produced is input.collected.

04 Sandbox API

The ordinary language is there — fetch, JSON, crypto, URL, TextEncoder, timers, and the rest. What is worth reading is what the platform adds:

context.workflowId

The workflow this run belongs to.

context.nodeId

This step's id on the canvas.

context.userId

Whose run it is.

context.executionId

This run, distinct from every other run of the workflow.

context.startedAt

When the run started, as an ISO string.

context.secrets

Values the plugin was published with. Never the user's credentials.

context.storage.put(path, data, contentType?) => Promise<StorageResult>

Write a file to the account's storage. Data may be a string or bytes.

context.storage.get(path) => Promise<Uint8Array | null>

Read a file back. null when it is not there.

context.storage.delete(path) => Promise<void>

Remove a file.

context.storage.list(prefix?) => Promise<{ files, totalSize }>

Everything under a prefix, or everything.

context.storage.head(path) => Promise<{ size, contentType } | null>

Size and content type without moving the bytes.

context.storage.signedUrl(path, expiresInSeconds?) => Promise<string | null>

A link somebody else can fetch the file from, for a few minutes. For APIs that pull rather than take bytes.

context.storage.download(url, path, contentType?) => Promise<StorageResult>

Pull a URL straight into storage. The bytes never cross this step, so a large file costs the run nothing.

Only when the download Worker is configured on the server.

context.state.get(key) => Promise<unknown>

A value this workflow stored on an earlier run. null when never set.

context.state.set(key, value, { ttlSeconds }?) => Promise<void>

Remember something between runs — a cursor, a last-seen id, a counter.

context.state.delete(key) => Promise<void>

Forget one key.

context.state.list(prefix?) => Promise<{ keys }>

Which keys this workflow has stored.

context.media.run(operation, params) => Promise<Record<string, unknown>>

ffmpeg, by operation name. A plugin never builds a command line — the server does, one operation at a time.

Only when the sandbox and storage are both configured.

context.connections.get(propertyName) => Promise<{ id, request }>

One of the accounts this step was configured with. Takes your property name, not a connection id.

Only when the step declares a `connection` property and an account was chosen.

context.connections.get(...).request({ path, method?, query?, headers?, body? }) => Promise<{ ok, status, body }>

A request to the provider, with the credential attached on the way past. Relative paths only — never a host, and there is no getToken().

APIs a plugin does not get

Your code runs with no process, fs, child_process, os, cluster, eval, Function or __dirname, and require resolves nothing but the packages listed above. Reaching for any of them is also refused when you publish.

This is a sandbox, not a jail: every run gets a container of its own that is destroyed when the run ends, and that — rather than the list above — is what keeps one plugin out of anything that is not its own.

05 Publishing

Once your plugin is working correctly in the test environment:

  1. Run the step from the editor until it does what you meant
  2. Fill in outputFields in your manifest, so whoever puts a step after yours can pick your fields from a list instead of guessing them
  3. Save. A version snapshot is kept each time, so you can roll back from the dashboard
  4. Click Submit for Review on your dashboard when you want it in the marketplace

Pushing publishes to your own account and nobody else's. Review is only about the marketplace, so you can build and use a plugin in your own workflows without waiting for anyone.

After review and approval, your plugin becomes available in the VisualAutomate node browser for all users.