@visualautomate/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -16
  2. package/dist/index.js +112 -574
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1624,6 +1624,23 @@ var MAX_PAGES = 50;
1624
1624
  var MAX_MEMORY_MB = 128;
1625
1625
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1626
1626
  var isRelativePath = (path) => typeof path === "string" && path.trim() !== "" && !/^[a-z][a-z0-9+.-]*:/i.test(path) && !path.startsWith("//");
1627
+ var ACCOUNTS_ARE_OURS = `Only VisualAutomate's own apps act as a connected account: call the API at its own address instead (action.baseUrl, or fetch in code), with a key the user enters in a property of type "secret"`;
1628
+ function accountUseIn(manifest) {
1629
+ if (!isObject(manifest)) return [];
1630
+ const m = manifest;
1631
+ const uses = [];
1632
+ if (isObject(m.properties)) {
1633
+ for (const [key, value] of Object.entries(m.properties)) {
1634
+ if (!isObject(value)) continue;
1635
+ if (value.type === "connection") uses.push(`properties.${key} is a connection: an account to act as`);
1636
+ if (value.optionsFrom !== void 0) uses.push(`properties.${key}.optionsFrom reads its options as an account`);
1637
+ }
1638
+ }
1639
+ if (isObject(m.action) && m.action.connection !== void 0) uses.push("action.connection makes the call as an account");
1640
+ if (m.trigger !== void 0) uses.push("trigger watches an account");
1641
+ if (isObject(m.auth) && typeof m.auth.provider === "string" && m.auth.provider) uses.push(`auth.provider names "${m.auth.provider}"`);
1642
+ return uses;
1643
+ }
1627
1644
  function checkManifest(raw, hasCode) {
1628
1645
  const errors = [];
1629
1646
  const warnings = [];
@@ -1665,6 +1682,8 @@ function checkManifest(raw, hasCode) {
1665
1682
  else {
1666
1683
  if (typeof m.auth.type !== "string" || !AUTH_TYPES.has(m.auth.type)) {
1667
1684
  errors.push(`auth.type must be one of ${[...AUTH_TYPES].join(", ")}.`);
1685
+ } else if (m.auth.type !== "none" && !m.auth.provider) {
1686
+ errors.push(`auth.type "${m.auth.type}" needs a provider to sign in to, and only VisualAutomate's own apps name one: use { "type": "none" } and take the user's key in a property of type "secret".`);
1668
1687
  }
1669
1688
  for (const key of HOST_KEYS) {
1670
1689
  if (key in m.auth) errors.push(`auth.${key} is refused: a manifest may not name a host. The provider's base URL belongs to the platform.`);
@@ -1698,6 +1717,7 @@ function checkManifest(raw, hasCode) {
1698
1717
  if (kind === "code" && !hasCode) {
1699
1718
  errors.push("There is no index.js and no action or trigger block, so this step has nothing to run.");
1700
1719
  }
1720
+ for (const use of accountUseIn(m)) errors.push(`${use}. ${ACCOUNTS_ARE_OURS}`);
1701
1721
  return { ok: errors.length === 0, kind, errors, warnings };
1702
1722
  }
1703
1723
  function checkConnectionName(where, name, typeOf, errors) {
@@ -1806,7 +1826,7 @@ function checkOptionsFrom(key, property, typeOf, errors) {
1806
1826
  }
1807
1827
 
1808
1828
  // src/declarations.ts
1809
- var TYPE_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\nexport interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\nexport interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\nexport interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\nexport interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\nexport interface PluginStorage {\n /**\n * Write a file. Bytes are a string or a Uint8Array: there is no `Buffer` in a\n * step\'s context.\n */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * A link on VisualAutomate\'s own domain that opens the file, for somebody\n * with no account: a mail, a message, a page. The storage bucket\'s own\n * address (signedUrl) is right for an API that fetches the file; this one is\n * right for a person. Fifteen minutes unless `expiresInSeconds` says otherwise.\n */\n shareUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\nexport interface PluginState {\n /** The stored value, or `null` when the key has never been set or has expired. */\n get(key: string): Promise<unknown>\n /**\n * Store a JSON value, at most 128 KB once encoded. With `ttlSeconds`, which\n * must be at least 60, it is forgotten after that many seconds.\n */\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n /**\n * The keys under a prefix, each with when it expires (`null` for never).\n * `complete` is false when there were more than one call returns.\n */\n list(prefix?: string): Promise<{ keys: Array<{ key: string; expiresAt: string | null }>; complete: boolean }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\nexport interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\nexport interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\nexport interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\nexport interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\nexport interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\nexport interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /** The accounts this step was configured with. Absent when it has none. */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\nexport interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\nexport interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\nexport type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\nexport type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\nexport interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\nexport type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n /**\n * A key the user types in: an API key, a token. A string to your code and to\n * a declared call (`Bearer {{config.apiKey}}`), and masked wherever it is\n * shown, because it is the one value on a step that somebody looking over a\n * shoulder must not be able to read off.\n */\n | "secret"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\nexport interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n /** Longer help, shown on request rather than under the input. */\n help?: string\n /** Shown but not editable. */\n disabled?: boolean\n /**\n * For `select` and `multiselect`: read the options from the user\'s account\n * instead of listing them, e.g. the channels of the chosen Slack workspace.\n */\n optionsFrom?: OptionsSource\n /**\n * For `secret`: the properties that say where this secret is sent, such as\n * a server URL. A stored secret is only kept when those are unchanged, so\n * pointing the URL somewhere else cannot carry the key along.\n */\n sentTo?: string[]\n /**\n * For `secret`: the value is sent exactly as typed and never computed with,\n * which lets the platform keep it out of the step\'s code entirely.\n */\n verbatim?: boolean\n}\n\n/**\n * Where a select\'s options come from: one GET as the account in `connection`,\n * a list in the answer, and what each option shows and stores.\n */\nexport interface OptionsSource {\n /** The `connection` property whose account the options are read as. */\n connection: string\n request: { path: string; query?: Record<string, unknown>; headers?: Record<string, string> }\n /** Where the list is in the answer (`""` when the answer is the list), and how many to offer. */\n items: { path: string; limit?: number }\n paginate?: Record<string, unknown>\n /** What each option shows, e.g. `{{item.name}}`. */\n label: string\n /** What choosing it stores, e.g. `{{item.id}}`. */\n value: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\nexport interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\nexport interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\nexport interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\nexport interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\nexport interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\nexport interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\nexport type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\nexport interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\nexport type PluginSchema = PluginManifest\n';
1829
+ var TYPE_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\nexport interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\nexport interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\nexport interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\nexport interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\nexport interface PluginStorage {\n /**\n * Write a file. Bytes are a string or a Uint8Array: there is no `Buffer` in a\n * step\'s context.\n */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * A link on VisualAutomate\'s own domain that opens the file, for somebody\n * with no account: a mail, a message, a page. The storage bucket\'s own\n * address (signedUrl) is right for an API that fetches the file; this one is\n * right for a person. Fifteen minutes unless `expiresInSeconds` says otherwise.\n */\n shareUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\nexport interface PluginState {\n /** The stored value, or `null` when the key has never been set or has expired. */\n get(key: string): Promise<unknown>\n /**\n * Store a JSON value, at most 128 KB once encoded. With `ttlSeconds`, which\n * must be at least 60, it is forgotten after that many seconds.\n */\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n /**\n * The keys under a prefix, each with when it expires (`null` for never).\n * `complete` is false when there were more than one call returns.\n */\n list(prefix?: string): Promise<{ keys: Array<{ key: string; expiresAt: string | null }>; complete: boolean }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\nexport interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\nexport interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\nexport interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\nexport interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\nexport interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\nexport interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /**\n * The accounts this step was configured with. Absent when it has none.\n *\n * @deprecated Only VisualAutomate\'s own apps act as a connected account, so\n * a step you build never has this: the platform refuses a step that names\n * an account. Take the user\'s key in a property of type "secret" and send\n * it to the API\'s own host with fetch.\n */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\nexport interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\nexport interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\nexport type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\nexport type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\nexport interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\nexport type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n /**\n * A key the user types in: an API key, a token. A string to your code and to\n * a declared call (`Bearer {{config.apiKey}}`), and masked wherever it is\n * shown, because it is the one value on a step that somebody looking over a\n * shoulder must not be able to read off.\n */\n | "secret"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\nexport interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n /** Longer help, shown on request rather than under the input. */\n help?: string\n /** Shown but not editable. */\n disabled?: boolean\n /**\n * For `select` and `multiselect`: read the options from the user\'s account\n * instead of listing them, e.g. the channels of the chosen Slack workspace.\n */\n optionsFrom?: OptionsSource\n /**\n * For `secret`: the properties that say where this secret is sent, such as\n * a server URL. A stored secret is only kept when those are unchanged, so\n * pointing the URL somewhere else cannot carry the key along.\n */\n sentTo?: string[]\n /**\n * For `secret`: the value is sent exactly as typed and never computed with,\n * which lets the platform keep it out of the step\'s code entirely.\n */\n verbatim?: boolean\n}\n\n/**\n * Where a select\'s options come from: one GET as the account in `connection`,\n * a list in the answer, and what each option shows and stores.\n */\nexport interface OptionsSource {\n /** The `connection` property whose account the options are read as. */\n connection: string\n request: { path: string; query?: Record<string, unknown>; headers?: Record<string, string> }\n /** Where the list is in the answer (`""` when the answer is the list), and how many to offer. */\n items: { path: string; limit?: number }\n paginate?: Record<string, unknown>\n /** What each option shows, e.g. `{{item.name}}`. */\n label: string\n /** What choosing it stores, e.g. `{{item.id}}`. */\n value: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\nexport interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\nexport interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\nexport interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\nexport interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\nexport interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\nexport interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\nexport type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\nexport interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\nexport type PluginSchema = PluginManifest\n';
1810
1830
 
1811
1831
  // src/ai-files.ts
1812
1832
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -1881,27 +1901,22 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
1881
1901
  }
1882
1902
  }
1883
1903
  }
1884
- `, "examples/apps/deepl/README.md": '# DeepL: an app with an API key, and a module that needs code\n\nDeepL is reached with a key the user pastes, not an account the platform connects, so every module has a `secret` property for the key and names the host itself with `baseUrl`. The host depends on the user\'s plan, so `baseUrl` reads a field: `https://{{config.host}}/v2`. (`baseUrl` may read `config`, never `input`.)\n\n| | |\n| --- | --- |\n| Name | DeepL |\n| Connection | none: the key is a field on each module |\n| Hosts to allow for the code module | `api-free.deepl.com`, `api.deepl.com` |\n\n| Module | Kind | Notes |\n| --- | --- | --- |\n| `translate-text` | declared | `POST translate`, the key in an `Authorization: DeepL-Auth-Key \u2026` header, `{{response.translations.0.text}}` reads the first result |\n| `list-languages` | declared | The answer is a bare list, so `items.path` is `""` |\n| `usage` | declared | |\n| `translate-long-text` | code | One request takes at most 50 texts, so a long document is several requests stitched back together in order |\n\nThe declared modules are created on the portal (**New module \u2192 Call an API**, paste the manifest, Try it with your key). `translate-long-text` is published from the repository; its push test needs a key, which belongs in `context.secrets` set for the plugin, never in a committed test file.\n\nOptional body fields are left out on purpose: a declared body sends a blank field as `""`, which DeepL refuses. Empty `query` values are dropped, body values are not.\n\n`translate-long-text` runs on your machine without a key: its test checks the refusal when no key is filled in. With a real key, add `"apiKey"` to `config` and `"allowedDomains": ["api-free.deepl.com"]` locally, and never commit the key.\n', "examples/apps/github/modules/comment-on-issue/manifest.json": `{
1904
+ `, "examples/apps/deepl/README.md": '# DeepL: an app with an API key, and a module that needs code\n\nDeepL is reached with a key the user pastes, not an account the platform connects, so every module has a `secret` property for the key and names the host itself with `baseUrl`. The host depends on the user\'s plan, so `baseUrl` reads a field: `https://{{config.host}}/v2`. (`baseUrl` may read `config`, never `input`.)\n\n| | |\n| --- | --- |\n| Name | DeepL |\n| Connection | none: the key is a field on each module |\n| Hosts to allow for the code module | `api-free.deepl.com`, `api.deepl.com` |\n\n| Module | Kind | Notes |\n| --- | --- | --- |\n| `translate-text` | declared | `POST translate`, the key in an `Authorization: DeepL-Auth-Key \u2026` header, `{{response.translations.0.text}}` reads the first result |\n| `list-languages` | declared | The answer is a bare list, so `items.path` is `""` |\n| `usage` | declared | |\n| `translate-long-text` | code | One request takes at most 50 texts, so a long document is several requests stitched back together in order |\n\nThe declared modules are created on the portal (**New module \u2192 Call an API**, paste the manifest, **Test the call** with your key). `translate-long-text` is published from the repository; its push test needs a key, which belongs in `context.secrets` set for the plugin, never in a committed test file.\n\nOptional body fields are left out on purpose: a declared body sends a blank field as `""`, which DeepL refuses. Empty `query` values are dropped, body values are not.\n\n`translate-long-text` runs on your machine without a key: its test checks the refusal when no key is filled in. With a real key, add `"apiKey"` to `config` and `"allowedDomains": ["api-free.deepl.com"]` locally, and never commit the key.\n', "examples/apps/github/modules/comment-on-issue/manifest.json": `{
1885
1905
  "description": "Add a comment to an issue or a pull request",
1886
1906
  "icon": "message-square",
1887
1907
  "color": "#24292f",
1888
1908
  "timeoutMs": 30000,
1889
1909
  "version": 1,
1890
1910
  "auth": {
1891
- "type": "oauth2",
1892
- "provider": "github",
1893
- "scopes": [
1894
- "repo"
1895
- ]
1911
+ "type": "none"
1896
1912
  },
1897
1913
  "properties": {
1898
- "account": {
1899
- "label": "GitHub account",
1900
- "type": "connection",
1901
- "provider": "github",
1914
+ "token": {
1915
+ "label": "GitHub token",
1916
+ "type": "secret",
1902
1917
  "required": true,
1903
- "description": "Which GitHub account this step acts as",
1904
- "group": "Account"
1918
+ "group": "Account",
1919
+ "description": "A fine-grained personal access token with access to the repository"
1905
1920
  },
1906
1921
  "owner": {
1907
1922
  "label": "Owner",
@@ -1951,12 +1966,17 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
1951
1966
  }
1952
1967
  },
1953
1968
  "action": {
1954
- "connection": "account",
1969
+ "baseUrl": "https://api.github.com",
1955
1970
  "request": {
1956
1971
  "method": "POST",
1957
1972
  "path": "repos/{{config.owner}}/{{config.repo}}/issues/{{config.issue_number}}/comments",
1958
1973
  "body": {
1959
1974
  "body": "{{config.body}}"
1975
+ },
1976
+ "headers": {
1977
+ "Authorization": "Bearer {{config.token}}",
1978
+ "Accept": "application/vnd.github+json",
1979
+ "X-GitHub-Api-Version": "2022-11-28"
1960
1980
  }
1961
1981
  },
1962
1982
  "errorPath": "message",
@@ -1974,20 +1994,15 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
1974
1994
  "timeoutMs": 30000,
1975
1995
  "version": 1,
1976
1996
  "auth": {
1977
- "type": "oauth2",
1978
- "provider": "github",
1979
- "scopes": [
1980
- "repo"
1981
- ]
1997
+ "type": "none"
1982
1998
  },
1983
1999
  "properties": {
1984
- "account": {
1985
- "label": "GitHub account",
1986
- "type": "connection",
1987
- "provider": "github",
2000
+ "token": {
2001
+ "label": "GitHub token",
2002
+ "type": "secret",
1988
2003
  "required": true,
1989
- "description": "Which GitHub account this step acts as",
1990
- "group": "Account"
2004
+ "group": "Account",
2005
+ "description": "A fine-grained personal access token with access to the repository"
1991
2006
  },
1992
2007
  "owner": {
1993
2008
  "label": "Owner",
@@ -2053,7 +2068,7 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2053
2068
  }
2054
2069
  },
2055
2070
  "action": {
2056
- "connection": "account",
2071
+ "baseUrl": "https://api.github.com",
2057
2072
  "request": {
2058
2073
  "method": "POST",
2059
2074
  "path": "repos/{{config.owner}}/{{config.repo}}/issues",
@@ -2062,6 +2077,11 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2062
2077
  "body": "{{config.body}}",
2063
2078
  "labels": "{{config.labels | list}}",
2064
2079
  "assignees": "{{config.assignees | list}}"
2080
+ },
2081
+ "headers": {
2082
+ "Authorization": "Bearer {{config.token}}",
2083
+ "Accept": "application/vnd.github+json",
2084
+ "X-GitHub-Api-Version": "2022-11-28"
2065
2085
  }
2066
2086
  },
2067
2087
  "errorPath": "message",
@@ -2080,20 +2100,15 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2080
2100
  "timeoutMs": 30000,
2081
2101
  "version": 1,
2082
2102
  "auth": {
2083
- "type": "oauth2",
2084
- "provider": "github",
2085
- "scopes": [
2086
- "repo"
2087
- ]
2103
+ "type": "none"
2088
2104
  },
2089
2105
  "properties": {
2090
- "account": {
2091
- "label": "GitHub account",
2092
- "type": "connection",
2093
- "provider": "github",
2106
+ "token": {
2107
+ "label": "GitHub token",
2108
+ "type": "secret",
2094
2109
  "required": true,
2095
- "description": "Which GitHub account this step acts as",
2096
- "group": "Account"
2110
+ "group": "Account",
2111
+ "description": "A fine-grained personal access token with access to the repository"
2097
2112
  },
2098
2113
  "owner": {
2099
2114
  "label": "Owner",
@@ -2167,7 +2182,7 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2167
2182
  }
2168
2183
  },
2169
2184
  "action": {
2170
- "connection": "account",
2185
+ "baseUrl": "https://api.github.com",
2171
2186
  "request": {
2172
2187
  "method": "GET",
2173
2188
  "path": "repos/{{config.owner}}/{{config.repo}}/issues",
@@ -2175,6 +2190,11 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2175
2190
  "state": "{{config.state}}",
2176
2191
  "labels": "{{config.labels}}",
2177
2192
  "per_page": "{{config.per_page}}"
2193
+ },
2194
+ "headers": {
2195
+ "Authorization": "Bearer {{config.token}}",
2196
+ "Accept": "application/vnd.github+json",
2197
+ "X-GitHub-Api-Version": "2022-11-28"
2178
2198
  }
2179
2199
  },
2180
2200
  "errorPath": "message",
@@ -2191,20 +2211,15 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2191
2211
  "timeoutMs": 30000,
2192
2212
  "version": 1,
2193
2213
  "auth": {
2194
- "type": "oauth2",
2195
- "provider": "github",
2196
- "scopes": [
2197
- "repo"
2198
- ]
2214
+ "type": "none"
2199
2215
  },
2200
2216
  "properties": {
2201
- "account": {
2202
- "label": "GitHub account",
2203
- "type": "connection",
2204
- "provider": "github",
2217
+ "token": {
2218
+ "label": "GitHub token",
2219
+ "type": "secret",
2205
2220
  "required": true,
2206
- "description": "Which GitHub account this step acts as",
2207
- "group": "Account"
2221
+ "group": "Account",
2222
+ "description": "A fine-grained personal access token with access to the repository"
2208
2223
  },
2209
2224
  "owner": {
2210
2225
  "label": "Owner",
@@ -2283,7 +2298,7 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2283
2298
  }
2284
2299
  },
2285
2300
  "action": {
2286
- "connection": "account",
2301
+ "baseUrl": "https://api.github.com",
2287
2302
  "request": {
2288
2303
  "method": "PATCH",
2289
2304
  "path": "repos/{{config.owner}}/{{config.repo}}/issues/{{config.issue_number}}",
@@ -2292,6 +2307,11 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2292
2307
  "body": "{{config.body}}",
2293
2308
  "state": "{{config.state}}",
2294
2309
  "labels": "{{config.labels | list}}"
2310
+ },
2311
+ "headers": {
2312
+ "Authorization": "Bearer {{config.token}}",
2313
+ "Accept": "application/vnd.github+json",
2314
+ "X-GitHub-Api-Version": "2022-11-28"
2295
2315
  }
2296
2316
  },
2297
2317
  "errorPath": "message",
@@ -2302,302 +2322,22 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2302
2322
  }
2303
2323
  }
2304
2324
  }
2305
- `, "examples/apps/github/README.md": '# GitHub: an app without code\n\nFour modules, every one a single request to the GitHub REST API, so every one is declared. These are the platform\'s own GitHub modules, generated from its catalogue.\n\nOn the developer portal, the app is:\n\n| | |\n| --- | --- |\n| Name | GitHub |\n| Connection | GitHub, OAuth, scope `repo` |\n| Base URL (from the provider) | `https://api.github.com` |\n\n| Module | Request | Notes |\n| --- | --- | --- |\n| `create-issue` | `POST repos/{owner}/{repo}/issues` | `"{{config.labels \\| list}}"` turns "bug, urgent" into a list |\n| `list-issues` | `GET repos/{owner}/{repo}/issues` | Optional filters go in `query`, where empty values are left out |\n| `update-issue` | `PATCH repos/{owner}/{repo}/issues/{number}` | |\n| `comment-on-issue` | `POST repos/{owner}/{repo}/issues/{number}/comments` | |\n\n`errorPath` is `message`: where GitHub puts the reason it refused. `vsa test` at the app\'s root checks all four manifests. To publish them, create each on the portal with **New module \u2192 Call an API**, paste its `manifest.json` into the module\'s editor, press **Try it**, and publish. There is nothing here for GitHub to publish.\n', "examples/apps/gmail/modules/get-message/manifest.json": `{
2306
- "description": "Read one message: its headers, its snippet and its parts",
2307
- "icon": "mail-open",
2308
- "color": "#ea4335",
2309
- "timeoutMs": 30000,
2310
- "version": 1,
2311
- "auth": {
2312
- "type": "oauth2",
2313
- "provider": "google-mail",
2314
- "scopes": [
2315
- "https://www.googleapis.com/auth/gmail.modify"
2316
- ]
2317
- },
2318
- "properties": {
2319
- "account": {
2320
- "label": "Google account",
2321
- "type": "connection",
2322
- "provider": "google-mail",
2323
- "required": true,
2324
- "description": "Which Gmail account this step acts as",
2325
- "group": "Account"
2326
- },
2327
- "messageId": {
2328
- "label": "Message id",
2329
- "type": "string",
2330
- "required": true,
2331
- "group": "Message"
2332
- },
2333
- "format": {
2334
- "label": "How much",
2335
- "type": "select",
2336
- "default": "full",
2337
- "options": [
2338
- {
2339
- "label": "Everything",
2340
- "value": "full"
2341
- },
2342
- {
2343
- "label": "Headers and snippet only",
2344
- "value": "metadata"
2345
- },
2346
- {
2347
- "label": "The raw message",
2348
- "value": "raw"
2349
- }
2350
- ],
2351
- "group": "Message"
2352
- }
2353
- },
2354
- "inputs": [
2355
- "input"
2356
- ],
2357
- "outputs": [
2358
- "success",
2359
- "error"
2360
- ],
2361
- "outputFields": {
2362
- "id": {
2363
- "type": "text",
2364
- "description": "The message's id"
2365
- },
2366
- "threadId": {
2367
- "type": "text",
2368
- "description": "The conversation it belongs to"
2369
- },
2370
- "snippet": {
2371
- "type": "text",
2372
- "description": "The first line or so, as Gmail shows it"
2373
- },
2374
- "headers": {
2375
- "type": "list",
2376
- "description": "From, To, Subject and the rest, as a list of name and value"
2377
- },
2378
- "labelIds": {
2379
- "type": "list",
2380
- "description": "The labels on it"
2381
- },
2382
- "payload": {
2383
- "type": "list",
2384
- "description": "The body parts, base64url encoded as Gmail stores them"
2385
- }
2386
- },
2387
- "action": {
2388
- "connection": "account",
2389
- "request": {
2390
- "method": "GET",
2391
- "path": "gmail/v1/users/me/messages/{{config.messageId}}",
2392
- "query": {
2393
- "format": "{{config.format}}"
2394
- }
2395
- },
2396
- "errorPath": "error.message",
2397
- "outputs": {
2398
- "id": "{{response.id}}",
2399
- "threadId": "{{response.threadId}}",
2400
- "snippet": "{{response.snippet}}",
2401
- "headers": "{{response.payload.headers}}",
2402
- "labelIds": "{{response.labelIds}}",
2403
- "payload": "{{response.payload}}"
2404
- }
2405
- }
2406
- }
2407
- `, "examples/apps/gmail/modules/modify-labels/manifest.json": '{\n "description": "Label a message, archive it, or mark it read",\n "icon": "tag",\n "color": "#ea4335",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "oauth2",\n "provider": "google-mail",\n "scopes": [\n "https://www.googleapis.com/auth/gmail.modify"\n ]\n },\n "properties": {\n "account": {\n "label": "Google account",\n "type": "connection",\n "provider": "google-mail",\n "required": true,\n "description": "Which Gmail account this step acts as",\n "group": "Account"\n },\n "messageId": {\n "label": "Message id",\n "type": "string",\n "required": true,\n "group": "Message"\n },\n "addLabelIds": {\n "label": "Add labels",\n "type": "string",\n "description": "Label ids, separated by commas",\n "group": "Labels"\n },\n "removeLabelIds": {\n "label": "Remove labels",\n "type": "string",\n "description": "Ids. UNREAD marks it read, INBOX archives it.",\n "group": "Labels"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "id": {\n "type": "text",\n "description": "The message that changed"\n },\n "labelIds": {\n "type": "list",\n "description": "The labels on it afterwards"\n }\n },\n "action": {\n "connection": "account",\n "request": {\n "method": "POST",\n "path": "gmail/v1/users/me/messages/{{config.messageId}}/modify",\n "body": {\n "addLabelIds": "{{config.addLabelIds | list}}",\n "removeLabelIds": "{{config.removeLabelIds | list}}"\n }\n },\n "errorPath": "error.message",\n "outputs": {\n "id": "{{response.id}}",\n "labelIds": "{{response.labelIds}}"\n }\n }\n}\n', "examples/apps/gmail/modules/search-messages/manifest.json": `{
2408
- "description": "Find messages with Gmail's own search syntax",
2409
- "icon": "search",
2410
- "color": "#ea4335",
2411
- "timeoutMs": 30000,
2412
- "version": 1,
2413
- "auth": {
2414
- "type": "oauth2",
2415
- "provider": "google-mail",
2416
- "scopes": [
2417
- "https://www.googleapis.com/auth/gmail.modify"
2418
- ]
2419
- },
2420
- "properties": {
2421
- "account": {
2422
- "label": "Google account",
2423
- "type": "connection",
2424
- "provider": "google-mail",
2425
- "required": true,
2426
- "description": "Which Gmail account this step acts as",
2427
- "group": "Account"
2428
- },
2429
- "q": {
2430
- "label": "Search",
2431
- "type": "string",
2432
- "description": "e.g. from:ada@x.com is:unread newer_than:7d",
2433
- "group": "Search"
2434
- },
2435
- "labelIds": {
2436
- "label": "Labels",
2437
- "type": "string",
2438
- "description": "Label ids, separated by commas",
2439
- "group": "Search"
2440
- },
2441
- "maxResults": {
2442
- "label": "How many",
2443
- "type": "number",
2444
- "default": 25,
2445
- "min": 1,
2446
- "max": 500,
2447
- "group": "Search"
2448
- }
2449
- },
2450
- "inputs": [
2451
- "input"
2452
- ],
2453
- "outputs": [
2454
- "success",
2455
- "error"
2456
- ],
2457
- "outputFields": {
2458
- "messages": {
2459
- "type": "list",
2460
- "description": "The matches, each with an id and a thread id \u2014 read one to get its content",
2461
- "fields": {
2462
- "id": "text",
2463
- "threadId": "text",
2464
- "snippet": "text",
2465
- "labelIds": "list",
2466
- "payload": "list"
2467
- }
2468
- },
2469
- "count": {
2470
- "type": "number",
2471
- "description": "How many came back"
2472
- },
2473
- "estimate": {
2474
- "type": "number",
2475
- "description": "Gmail's guess at the total"
2476
- }
2477
- },
2478
- "action": {
2479
- "connection": "account",
2480
- "request": {
2481
- "method": "GET",
2482
- "path": "gmail/v1/users/me/messages",
2483
- "query": {
2484
- "q": "{{config.q}}",
2485
- "labelIds": "{{config.labelIds | list}}",
2486
- "maxResults": "{{config.maxResults}}"
2487
- }
2488
- },
2489
- "errorPath": "error.message",
2490
- "outputs": {
2491
- "messages": "{{response.messages}}",
2492
- "count": "{{response.messages | length}}",
2493
- "estimate": "{{response.resultSizeEstimate}}"
2494
- }
2495
- }
2496
- }
2497
- `, "examples/apps/gmail/modules/send-email/index.js": '// Gmail takes a whole RFC 2822 message, base64url-encoded, in one field. Building\n// that needs code: a declared step can fill in a JSON body, not encode one.\nconst { Base64 } = require("js-base64")\n\n/** "a@x.com, b@y.com" to ["a@x.com", "b@y.com"]. */\nconst addresses = (value) =>\n String(value || "")\n .split(",")\n .map((address) => address.trim())\n .filter(Boolean)\n\n/** A header value with non-ASCII text in it, encoded so every mail client reads it. */\nconst headerText = (text) => (/^[\\x20-\\x7e]*$/.test(text) ? text : `=?UTF-8?B?${Base64.encode(text)}?=`)\n\n/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n const to = addresses(config.to)\n if (to.length === 0) return { output: "error", data: { error: "Fill in who the email goes to." } }\n if (!context.connections) return { output: "error", data: { error: "Choose a Google account on this step." } }\n\n const cc = addresses(config.cc)\n const message = [\n `To: ${to.join(", ")}`,\n ...(cc.length ? [`Cc: ${cc.join(", ")}`] : []),\n `Subject: ${headerText(config.subject || "")}`,\n "MIME-Version: 1.0",\n `Content-Type: ${config.isHtml ? "text/html" : "text/plain"}; charset=UTF-8`,\n "Content-Transfer-Encoding: base64",\n "",\n Base64.encode(config.body || ""),\n ].join("\\r\\n")\n\n const account = await context.connections.get("account")\n const res = await account.request({\n method: "POST",\n path: "gmail/v1/users/me/messages/send",\n // URL-safe base64 without padding, as Gmail\'s "raw" field wants it.\n body: { raw: Base64.encodeURI(message) },\n })\n\n if (!res.ok) {\n const reason = res.body && res.body.error && res.body.error.message\n return { output: "error", data: { error: reason || `Gmail answered ${res.status}`, status: res.status } }\n }\n console.log(`sent to ${to.length} recipient(s)`)\n return { output: "success", data: { id: res.body.id, threadId: res.body.threadId, to } }\n },\n}\n', "examples/apps/gmail/modules/send-email/manifest.json": `{
2498
- "description": "Send an email from the connected Gmail account",
2499
- "icon": "send",
2500
- "color": "#ea4335",
2501
- "timeoutMs": 30000,
2502
- "version": 1,
2503
- "auth": {
2504
- "type": "oauth2",
2505
- "provider": "google-mail",
2506
- "scopes": [
2507
- "https://www.googleapis.com/auth/gmail.modify"
2508
- ]
2509
- },
2510
- "properties": {
2511
- "account": {
2512
- "label": "Google account",
2513
- "type": "connection",
2514
- "provider": "google-mail",
2515
- "required": true,
2516
- "description": "Which Gmail account this step acts as",
2517
- "group": "Account"
2518
- },
2519
- "to": {
2520
- "label": "To",
2521
- "type": "string",
2522
- "required": true,
2523
- "description": "Addresses, separated by commas",
2524
- "group": "Message"
2525
- },
2526
- "cc": {
2527
- "label": "Cc",
2528
- "type": "string",
2529
- "description": "Addresses, separated by commas",
2530
- "group": "Message"
2531
- },
2532
- "subject": {
2533
- "label": "Subject",
2534
- "type": "string",
2535
- "required": true,
2536
- "group": "Message"
2537
- },
2538
- "body": {
2539
- "label": "Message",
2540
- "type": "string",
2541
- "required": true,
2542
- "group": "Message"
2543
- },
2544
- "isHtml": {
2545
- "label": "Send as HTML",
2546
- "type": "boolean",
2547
- "default": false,
2548
- "group": "Message"
2549
- }
2550
- },
2551
- "inputs": [
2552
- "input"
2553
- ],
2554
- "outputs": [
2555
- "success",
2556
- "error"
2557
- ],
2558
- "outputFields": {
2559
- "id": {
2560
- "type": "text",
2561
- "description": "The sent message's id"
2562
- },
2563
- "threadId": {
2564
- "type": "text",
2565
- "description": "The conversation it belongs to"
2566
- },
2567
- "to": {
2568
- "type": "list",
2569
- "description": "Who it went to"
2570
- }
2571
- }
2572
- }
2573
- `, "examples/apps/gmail/modules/send-email/visualautomate.test.json": '{\n "config": {\n "to": "",\n "subject": "Hello",\n "body": "From a test"\n },\n "input": {},\n "expectOutput": "error"\n}\n', "examples/apps/gmail/README.md": '# Gmail: a mixed app\n\nReading and labelling mail is one request each, so those modules are declared (generated from the platform\'s catalogue). Sending is not: Gmail takes the whole message as an RFC 2822 text, base64url-encoded, in one `raw` field. A declaration can fill in a JSON body but cannot encode one, so `send-email` is code.\n\n| | |\n| --- | --- |\n| Name | Gmail |\n| Connection | Google (`google-mail`), OAuth, scope `gmail.modify` |\n| Base URL (from the provider) | `https://gmail.googleapis.com` |\n\n| Module | Kind | Notes |\n| --- | --- | --- |\n| `search-messages` | declared | Gmail\'s own search syntax in `q` |\n| `get-message` | declared | Headers, snippet and parts of one message |\n| `modify-labels` | declared | Archive, mark read, add or remove labels |\n| `send-email` | code | Builds the MIME message with `js-base64` (an allowed package), sends it with `context.connections` |\n\nThe three declared modules are created on the portal (**New module \u2192 Call an API**, paste the manifest); `send-email` is a **Write code** module, published from the repository after a push test on `success`.\n\n`send-email` acts as a connected account, and there is none on your machine, so its test file checks the clean refusal (no recipient). That passes locally and on push, but **does not count for publishing**, which needs a push test on `success`: before publishing, put your Google account\'s id in `config.account`, a real recipient (yourself) in `config.to`, and `"expectOutput": "success"`. Every push then sends that mail, so keep it to yourself.\n', "examples/apps/README.md": "# App examples\n\nAn **app** is several steps (its **modules**) for one service, under one name and one connection. In a repository it is one folder per module under `modules/`:\n\n```text\nmy-app/\n modules/\n create-issue/\n manifest.json a declared module: no index.js\n send-email/\n manifest.json\n index.js a code module\n visualautomate.test.json\n```\n\nThe app itself has no file: its name, description, logo and **connection** (what a user connects when they add the app: an OAuth sign-in, or a key) are set on the developer portal. Every module that acts as the account declares the same `connection` property, here always called `account`.\n\n**Code modules** are published from the repository: a push tests them, and a commit that passed on `success` is published. **Declared modules** are kept here as `manifest.json` so `vsa test` can check them, but they are published from the portal: **New module \u2192 Call an API**, paste the manifest into the module's editor, **Try it**, publish.\n\n| App | Modules | Connection | Shows |\n| --- | --- | --- | --- |\n| `github/` | 4 declared | GitHub, OAuth | An app without code: create, list, update and comment, as the platform's own catalogue ships them |\n| `slack/` | 4 declared | Slack, OAuth | An API that answers 200 to a failure, caught with `okPath` |\n| `gmail/` | 3 declared, 1 code | Google, OAuth | A mixed app: reads are declared; sending needs code to build a base64url MIME message |\n| `deepl/` | 3 declared, 1 code | none: an API key the user pastes | `baseUrl` that reads a field, a `secret` key in a header, and code for many calls stitched together |\n\nChoose per module, not per app: a module is declared when one request does the job, and code when it needs more.\n", "examples/apps/slack/modules/add-reaction/manifest.json": '{\n "description": "Put an emoji reaction on a message",\n "icon": "smile",\n "color": "#4a154b",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "oauth2",\n "provider": "slack",\n "scopes": [\n "chat:write",\n "channels:read",\n "channels:history",\n "groups:read",\n "users:read",\n "users:read.email",\n "reactions:write",\n "channels:manage"\n ]\n },\n "properties": {\n "account": {\n "label": "Slack workspace",\n "type": "connection",\n "provider": "slack",\n "required": true,\n "description": "Which Slack account this step acts as",\n "group": "Account"\n },\n "channel": {\n "label": "Channel id",\n "type": "string",\n "required": true,\n "group": "Message"\n },\n "timestamp": {\n "label": "Message timestamp",\n "type": "string",\n "required": true,\n "group": "Message"\n },\n "name": {\n "label": "Emoji",\n "type": "string",\n "required": true,\n "description": "The name without colons, e.g. thumbsup",\n "group": "Message"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "added": {\n "type": "true/false",\n "description": "Whether Slack accepted it"\n },\n "name": {\n "type": "text",\n "description": "The emoji used"\n },\n "timestamp": {\n "type": "text",\n "description": "The message it went on"\n }\n },\n "action": {\n "connection": "account",\n "request": {\n "method": "POST",\n "path": "reactions.add",\n "body": {\n "channel": "{{config.channel}}",\n "timestamp": "{{config.timestamp}}",\n "name": "{{config.name}}"\n }\n },\n "okPath": "ok",\n "errorPath": "error",\n "outputs": {\n "added": true,\n "name": "{{config.name}}",\n "timestamp": "{{config.timestamp}}"\n }\n }\n}\n', "examples/apps/slack/modules/find-user-by-email/manifest.json": '{\n "description": "Look up a member of the workspace by their address",\n "icon": "search",\n "color": "#4a154b",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "oauth2",\n "provider": "slack",\n "scopes": [\n "chat:write",\n "channels:read",\n "channels:history",\n "groups:read",\n "users:read",\n "users:read.email",\n "reactions:write",\n "channels:manage"\n ]\n },\n "properties": {\n "account": {\n "label": "Slack workspace",\n "type": "connection",\n "provider": "slack",\n "required": true,\n "description": "Which Slack account this step acts as",\n "group": "Account"\n },\n "email": {\n "label": "Email",\n "type": "string",\n "required": true,\n "group": "Search"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "id": {\n "type": "text",\n "description": "Their user id, which every other method wants"\n },\n "name": {\n "type": "text",\n "description": "Their display name"\n },\n "email": {\n "type": "text",\n "description": "The address that was looked up"\n }\n },\n "action": {\n "connection": "account",\n "request": {\n "method": "GET",\n "path": "users.lookupByEmail",\n "query": {\n "email": "{{config.email}}"\n }\n },\n "okPath": "ok",\n "errorPath": "error",\n "outputs": {\n "id": "{{response.user.id}}",\n "name": "{{response.user.real_name}}",\n "email": "{{config.email}}"\n }\n }\n}\n', "examples/apps/slack/modules/list-channels/manifest.json": `{
2325
+ `, "examples/apps/github/README.md": '# GitHub: an app without code\n\nFour modules, every one a single request to the GitHub REST API, so every one is declared. Each is reached with a token the user pastes: only VisualAutomate\'s own apps act as a connected account, so a GitHub app of yours uses a fine-grained personal access token in a `secret` field.\n\nOn the developer portal, the app is:\n\n| | |\n| --- | --- |\n| Name | GitHub |\n| Connection | none: the token is a `secret` field on each module |\n| `baseUrl` | `https://api.github.com`, with `Authorization: Bearer {{config.token}}`, `Accept: application/vnd.github+json` and the API version in the headers |\n\n| Module | Request | Notes |\n| --- | --- | --- |\n| `create-issue` | `POST repos/{owner}/{repo}/issues` | `"{{config.labels \\| list}}"` turns "bug, urgent" into a list |\n| `list-issues` | `GET repos/{owner}/{repo}/issues` | Optional filters go in `query`, where empty values are left out |\n| `update-issue` | `PATCH repos/{owner}/{repo}/issues/{number}` | |\n| `comment-on-issue` | `POST repos/{owner}/{repo}/issues/{number}/comments` | |\n\n`errorPath` is `message`: where GitHub puts the reason it refused. `vsa test` at the app\'s root checks all four manifests. To publish them, create each on the portal with **New module \u2192 Call an API**, paste its `manifest.json` into the module\'s editor, fill in a token, press **Test the call**, and publish. There is nothing here for GitHub to publish.\n', "examples/apps/README.md": "# App examples\n\nAn **app** is several steps (its **modules**) for one service, under one name. In a repository it is one folder per module under `modules/`:\n\n```text\nmy-app/\n modules/\n create-issue/\n manifest.json a declared module: no index.js\n translate-long-text/\n manifest.json\n index.js a code module\n visualautomate.test.json\n```\n\nThe app itself has no file: its name, description and logo are set on the developer portal. It connects no account: only VisualAutomate's own apps act as one. So every module asks for the service's key or token the same way, a `secret` property with the same name, and names the API's host itself.\n\n**Code modules** are published from the repository: a push tests them, and a commit that passed on `success` is published. **Declared modules** are kept here as `manifest.json` so `vsa test` can check them, but they are published from the portal: **New module \u2192 Call an API**, paste the manifest into the module's editor, **Test the call**, publish.\n\n| App | Modules | Reached with | Shows |\n| --- | --- | --- | --- |\n| `github/` | 4 declared | a personal access token the user pastes | An app without code: create, list, update and comment |\n| `slack/` | 4 declared | a bot token the user pastes | An API that answers 200 to a failure, caught with `okPath` |\n| `deepl/` | 3 declared, 1 code | none: an API key the user pastes | `baseUrl` that reads a field, a `secret` key in a header, and code for many calls stitched together |\n\nChoose per module, not per app: a module is declared when one request does the job, and code when it needs more.\n", "examples/apps/slack/modules/add-reaction/manifest.json": '{\n "description": "Put an emoji reaction on a message",\n "icon": "smile",\n "color": "#4a154b",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "none"\n },\n "properties": {\n "token": {\n "label": "Slack bot token",\n "type": "secret",\n "required": true,\n "group": "Account",\n "description": "The xoxb- token of your Slack app, installed in the workspace"\n },\n "channel": {\n "label": "Channel id",\n "type": "string",\n "required": true,\n "group": "Message"\n },\n "timestamp": {\n "label": "Message timestamp",\n "type": "string",\n "required": true,\n "group": "Message"\n },\n "name": {\n "label": "Emoji",\n "type": "string",\n "required": true,\n "description": "The name without colons, e.g. thumbsup",\n "group": "Message"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "added": {\n "type": "true/false",\n "description": "Whether Slack accepted it"\n },\n "name": {\n "type": "text",\n "description": "The emoji used"\n },\n "timestamp": {\n "type": "text",\n "description": "The message it went on"\n }\n },\n "action": {\n "baseUrl": "https://slack.com/api",\n "request": {\n "method": "POST",\n "path": "reactions.add",\n "body": {\n "channel": "{{config.channel}}",\n "timestamp": "{{config.timestamp}}",\n "name": "{{config.name}}"\n },\n "headers": {\n "Authorization": "Bearer {{config.token}}"\n }\n },\n "okPath": "ok",\n "errorPath": "error",\n "outputs": {\n "added": true,\n "name": "{{config.name}}",\n "timestamp": "{{config.timestamp}}"\n }\n }\n}\n', "examples/apps/slack/modules/find-user-by-email/manifest.json": '{\n "description": "Look up a member of the workspace by their address",\n "icon": "search",\n "color": "#4a154b",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "none"\n },\n "properties": {\n "token": {\n "label": "Slack bot token",\n "type": "secret",\n "required": true,\n "group": "Account",\n "description": "The xoxb- token of your Slack app, installed in the workspace"\n },\n "email": {\n "label": "Email",\n "type": "string",\n "required": true,\n "group": "Search"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "id": {\n "type": "text",\n "description": "Their user id, which every other method wants"\n },\n "name": {\n "type": "text",\n "description": "Their display name"\n },\n "email": {\n "type": "text",\n "description": "The address that was looked up"\n }\n },\n "action": {\n "baseUrl": "https://slack.com/api",\n "request": {\n "method": "GET",\n "path": "users.lookupByEmail",\n "query": {\n "email": "{{config.email}}"\n },\n "headers": {\n "Authorization": "Bearer {{config.token}}"\n }\n },\n "okPath": "ok",\n "errorPath": "error",\n "outputs": {\n "id": "{{response.user.id}}",\n "name": "{{response.user.real_name}}",\n "email": "{{config.email}}"\n }\n }\n}\n', "examples/apps/slack/modules/list-channels/manifest.json": `{
2574
2326
  "description": "List the channels in the workspace, with their ids",
2575
2327
  "icon": "list",
2576
2328
  "color": "#4a154b",
2577
2329
  "timeoutMs": 30000,
2578
2330
  "version": 1,
2579
2331
  "auth": {
2580
- "type": "oauth2",
2581
- "provider": "slack",
2582
- "scopes": [
2583
- "chat:write",
2584
- "channels:read",
2585
- "channels:history",
2586
- "groups:read",
2587
- "users:read",
2588
- "users:read.email",
2589
- "reactions:write",
2590
- "channels:manage"
2591
- ]
2332
+ "type": "none"
2592
2333
  },
2593
2334
  "properties": {
2594
- "account": {
2595
- "label": "Slack workspace",
2596
- "type": "connection",
2597
- "provider": "slack",
2335
+ "token": {
2336
+ "label": "Slack bot token",
2337
+ "type": "secret",
2598
2338
  "required": true,
2599
- "description": "Which Slack account this step acts as",
2600
- "group": "Account"
2339
+ "group": "Account",
2340
+ "description": "The xoxb- token of your Slack app, installed in the workspace"
2601
2341
  },
2602
2342
  "types": {
2603
2343
  "label": "Kinds",
@@ -2660,7 +2400,7 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2660
2400
  }
2661
2401
  },
2662
2402
  "action": {
2663
- "connection": "account",
2403
+ "baseUrl": "https://slack.com/api",
2664
2404
  "request": {
2665
2405
  "method": "GET",
2666
2406
  "path": "conversations.list",
@@ -2668,6 +2408,9 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2668
2408
  "types": "{{config.types}}",
2669
2409
  "limit": "{{config.limit}}",
2670
2410
  "exclude_archived": "{{config.exclude_archived}}"
2411
+ },
2412
+ "headers": {
2413
+ "Authorization": "Bearer {{config.token}}"
2671
2414
  }
2672
2415
  },
2673
2416
  "okPath": "ok",
@@ -2686,27 +2429,15 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2686
2429
  "timeoutMs": 30000,
2687
2430
  "version": 1,
2688
2431
  "auth": {
2689
- "type": "oauth2",
2690
- "provider": "slack",
2691
- "scopes": [
2692
- "chat:write",
2693
- "channels:read",
2694
- "channels:history",
2695
- "groups:read",
2696
- "users:read",
2697
- "users:read.email",
2698
- "reactions:write",
2699
- "channels:manage"
2700
- ]
2432
+ "type": "none"
2701
2433
  },
2702
2434
  "properties": {
2703
- "account": {
2704
- "label": "Slack workspace",
2705
- "type": "connection",
2706
- "provider": "slack",
2435
+ "token": {
2436
+ "label": "Slack bot token",
2437
+ "type": "secret",
2707
2438
  "required": true,
2708
- "description": "Which Slack account this step acts as",
2709
- "group": "Account"
2439
+ "group": "Account",
2440
+ "description": "The xoxb- token of your Slack app, installed in the workspace"
2710
2441
  },
2711
2442
  "channel": {
2712
2443
  "label": "Channel id",
@@ -2761,7 +2492,7 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2761
2492
  }
2762
2493
  },
2763
2494
  "action": {
2764
- "connection": "account",
2495
+ "baseUrl": "https://slack.com/api",
2765
2496
  "request": {
2766
2497
  "method": "POST",
2767
2498
  "path": "chat.postMessage",
@@ -2770,6 +2501,9 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2770
2501
  "text": "{{config.text}}",
2771
2502
  "thread_ts": "{{config.thread_ts}}",
2772
2503
  "unfurl_links": "{{config.unfurl_links}}"
2504
+ },
2505
+ "headers": {
2506
+ "Authorization": "Bearer {{config.token}}"
2773
2507
  }
2774
2508
  },
2775
2509
  "okPath": "ok",
@@ -2782,37 +2516,7 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2782
2516
  }
2783
2517
  }
2784
2518
  }
2785
- `, "examples/apps/slack/README.md": '# Slack: an app without code, and a 200 that is a failure\n\nFour declared modules, generated from the platform\'s catalogue. Like every declared module, each is created on the portal (**New module \u2192 Call an API**, paste its `manifest.json`, **Try it**) and published there, not from GitHub.\n\n| | |\n| --- | --- |\n| Name | Slack |\n| Connection | Slack, OAuth, scopes for messages, channels, users and reactions |\n| Base URL (from the provider) | `https://slack.com/api` |\n\nSlack answers `200` even when it refuses: `{ "ok": false, "error": "channel_not_found" }`. Every module therefore has `"okPath": "ok"` and `"errorPath": "error"`, so a refusal leaves by the `error` port instead of passing as a success with nothing in it.\n\n| Module | Request | Notes |\n| --- | --- | --- |\n| `post-message` | `POST chat.postMessage` | Hands on the message\'s `ts`, which `add-reaction` and threads need |\n| `list-channels` | `GET conversations.list` | A list, with the ids the other modules ask for |\n| `add-reaction` | `POST reactions.add` | |\n| `find-user-by-email` | `GET users.lookupByEmail` | |\n', "examples/branching-outputs/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config) => {\n const score = Number(input.score);\n if (Number.isNaN(score)) {\n return { output: "error", data: { reason: "input.score is not a number" } };\n }\n const high = config.highFrom ?? 80;\n const medium = config.mediumFrom ?? 50;\n const output = score >= high ? "high" : score >= medium ? "medium" : "low";\n return { output, data: { score } };\n },\n};\n', "examples/branching-outputs/manifest.json": '{\n "description": "Sorts a score into high, medium or low",\n "icon": "split",\n "properties": {\n "highFrom": {\n "label": "High from",\n "type": "number",\n "default": 80\n },\n "mediumFrom": {\n "label": "Medium from",\n "type": "number",\n "default": 50\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "high",\n "medium",\n "low",\n "error"\n ],\n "outputFields": {\n "score": "The score that was sorted"\n }\n}\n', "examples/branching-outputs/visualautomate.test.json": '{\n "config": {},\n "input": {\n "score": 91\n },\n "expectOutput": "high"\n}\n', "examples/connection-account/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n if (!context.connections) {\n return { output: "error", data: { reason: "No account was chosen for this step" } };\n }\n\n // Asked for by the property name in manifest.json, not by an id.\n const account = await context.connections.get("account");\n const res = await account.request({\n path: "user/repos", // relative to the provider: never a full URL\n method: "GET",\n query: { per_page: config.perPage || 10, sort: "pushed" },\n });\n\n if (!res.ok) {\n return { output: "error", data: { status: res.status, body: res.body } };\n }\n return { output: "success", data: { repositories: res.body.map((r) => r.full_name) } };\n },\n};\n', "examples/connection-account/manifest.json": `{
2786
- "description": "Lists the connected GitHub account's repositories",
2787
- "icon": "github",
2788
- "properties": {
2789
- "account": {
2790
- "label": "GitHub account",
2791
- "type": "connection",
2792
- "provider": "github",
2793
- "required": true,
2794
- "description": "Which connected account to act as"
2795
- },
2796
- "perPage": {
2797
- "label": "How many",
2798
- "type": "number",
2799
- "default": 10,
2800
- "min": 1,
2801
- "max": 100
2802
- }
2803
- },
2804
- "inputs": [
2805
- "input"
2806
- ],
2807
- "outputs": [
2808
- "success",
2809
- "error"
2810
- ],
2811
- "outputFields": {
2812
- "repositories": "Names of the account's repositories"
2813
- }
2814
- }
2815
- `, "examples/connection-account/visualautomate.test.json": '{\n "config": {},\n "input": {},\n "expectOutput": "error"\n}\n', "examples/declared-api-key/manifest.json": '{\n "description": "Gets the current weather for a city",\n "icon": "cloud-sun",\n "auth": {\n "type": "none"\n },\n "properties": {\n "apiKey": {\n "label": "API key",\n "type": "secret",\n "required": true,\n "description": "From your account on the weather service"\n },\n "city": {\n "label": "City",\n "type": "string",\n "required": true\n },\n "units": {\n "label": "Units",\n "type": "select",\n "default": "metric",\n "options": [\n {\n "label": "Celsius",\n "value": "metric"\n },\n {\n "label": "Fahrenheit",\n "value": "imperial"\n }\n ]\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "temperature": "The temperature now",\n "summary": "The weather in words",\n "city": "The city it is for"\n },\n "action": {\n "baseUrl": "https://api.example.com/v1",\n "request": {\n "method": "GET",\n "path": "weather",\n "query": {\n "q": "{{config.city}}",\n "units": "{{config.units}}"\n },\n "headers": {\n "Authorization": "Bearer {{config.apiKey}}"\n }\n },\n "errorPath": "error.message",\n "outputs": {\n "temperature": "{{response.current.temp}}",\n "summary": "{{response.current.summary}}",\n "city": "{{config.city}}"\n }\n }\n}\n', "examples/declared-connection-list/manifest.json": '{\n "description": "Lists the repositories of a connected GitHub account",\n "icon": "github",\n "auth": {\n "type": "oauth2",\n "provider": "github"\n },\n "properties": {\n "account": {\n "label": "GitHub account",\n "type": "connection",\n "provider": "github",\n "required": true\n },\n "sort": {\n "label": "Sort by",\n "type": "select",\n "default": "pushed",\n "options": [\n {\n "label": "Last pushed",\n "value": "pushed"\n },\n {\n "label": "Name",\n "value": "full_name"\n }\n ]\n },\n "limit": {\n "label": "At most",\n "type": "number",\n "default": 50,\n "min": 1,\n "max": 300\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "items": {\n "type": "list",\n "description": "One entry per repository: name, url, private"\n },\n "count": {\n "type": "number",\n "description": "How many came back"\n }\n },\n "action": {\n "connection": "account",\n "request": {\n "method": "GET",\n "path": "user/repos",\n "query": {\n "sort": "{{config.sort}}",\n "per_page": "100",\n "page": "{{cursor}}"\n }\n },\n "items": {\n "path": "",\n "limit": 300\n },\n "paginate": {\n "from": "page",\n "start": 1,\n "maxPages": 3\n },\n "errorPath": "message",\n "outputs": {\n "name": "{{item.full_name}}",\n "url": "{{item.html_url}}",\n "private": "{{item.private}}"\n }\n }\n}\n', "examples/declared-reshape/manifest.json": '{\n "description": "Turns a comma-separated list of addresses into a list",\n "icon": "list",\n "auth": {\n "type": "none"\n },\n "properties": {\n "addresses": {\n "label": "Addresses",\n "type": "string",\n "required": true,\n "description": "Separated by commas"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "recipients": "The addresses, as a list",\n "first": "The first address",\n "count": "How many there are"\n },\n "action": {\n "outputs": {\n "recipients": "{{config.addresses | list}}",\n "first": "{{config.addresses | first}}",\n "count": "{{config.addresses | length}}"\n }\n }\n}\n', "examples/hello-world/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n if (!config.name) {\n return { output: "error", data: { reason: "No name given" } };\n }\n return { output: "success", data: { greeting: `Hello, ${config.name}!` } };\n },\n};\n', "examples/hello-world/manifest.json": '{\n "description": "Greets someone",\n "icon": "sparkles",\n "properties": {\n "name": {\n "label": "Name",\n "type": "string",\n "required": true,\n "description": "Who to greet"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "greeting": "The greeting text"\n }\n}\n', "examples/hello-world/visualautomate.test.json": '{\n "config": {\n "name": "VisualAutomate"\n },\n "input": {},\n "expectOutput": "success"\n}\n', "examples/http-request/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config) => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), (config.timeoutSeconds || 10) * 1000);\n\n try {\n const hasBody = config.method === "POST" || config.method === "PUT";\n const res = await fetch(config.url, {\n method: config.method || "GET",\n headers: hasBody ? { "Content-Type": "application/json" } : {},\n body: hasBody ? JSON.stringify(config.body ?? {}) : undefined,\n signal: controller.signal,\n });\n\n const text = await res.text();\n let body = text;\n try { body = JSON.parse(text); } catch { /* not JSON: keep the text */ }\n\n return { output: res.ok ? "success" : "error", data: { status: res.status, body } };\n } catch (err) {\n return { output: "error", data: { status: 0, reason: String(err) } };\n } finally {\n clearTimeout(timer);\n }\n },\n};\n', "examples/http-request/manifest.json": '{\n "description": "Calls any HTTP endpoint",\n "icon": "globe",\n "timeoutMs": 20000,\n "properties": {\n "url": {\n "label": "URL",\n "type": "string",\n "required": true,\n "pattern": "^https://"\n },\n "method": {\n "label": "Method",\n "type": "select",\n "default": "GET",\n "options": [\n {\n "label": "GET",\n "value": "GET"\n },\n {\n "label": "POST",\n "value": "POST"\n },\n {\n "label": "PUT",\n "value": "PUT"\n },\n {\n "label": "DELETE",\n "value": "DELETE"\n }\n ]\n },\n "body": {\n "label": "Body",\n "type": "json",\n "description": "Sent as JSON for POST and PUT"\n },\n "timeoutSeconds": {\n "label": "Give up after (s)",\n "type": "number",\n "default": 10,\n "min": 1,\n "max": 15\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "status": "HTTP status",\n "body": "The parsed response, or text"\n }\n}\n', "examples/http-request/visualautomate.test.json": '{\n "config": {\n "url": "https://httpbin.org/get",\n "method": "GET"\n },\n "input": {},\n "expectOutput": "success"\n}\n', "examples/packages/index.js": 'const _ = require("lodash");\nconst { format } = require("date-fns");\nconst { z } = require("zod");\n\nconst Order = z.object({ id: z.string(), total: z.number(), createdAt: z.string() });\n\n/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input) => {\n const parsed = z.array(Order).safeParse(input.orders ?? []);\n if (!parsed.success) {\n return { output: "error", data: { reason: parsed.error.issues.map((i) => i.message) } };\n }\n\n const byDay = _.mapValues(\n _.groupBy(parsed.data, (o) => format(new Date(o.createdAt), "yyyy-MM-dd")),\n (orders) => _.sumBy(orders, "total"),\n );\n return { output: "success", data: { byDay } };\n },\n};\n', "examples/packages/manifest.json": '{\n "description": "Validates orders and groups them by day",\n "icon": "package",\n "properties": {},\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "byDay": "Order totals per day"\n }\n}\n', "examples/packages/visualautomate.test.json": '{\n "config": {},\n "input": {\n "orders": [\n {\n "id": "a",\n "total": 10,\n "createdAt": "2026-09-14T10:00:00Z"\n },\n {\n "id": "b",\n "total": 5,\n "createdAt": "2026-09-14T12:00:00Z"\n }\n ]\n },\n "expectOutput": "success"\n}\n', "examples/README.md": "# Examples\n\nWorking steps to start from. Copy the closest one into the repository (a plugin's root, or `modules/<slug>/` in an app) and change it. For a whole app, start from `apps/`.\n\nEvery code example passes the platform's code check, and the ones that need no network, account or extra package are run with `vsa test` by this repository's tests, so they cannot quietly go stale.\n\n| Folder | Kind | Shows |\n| --- | --- | --- |\n| `hello-world/` | code | The smallest working step: one property, success and error ports |\n| `branching-outputs/` | code | More than two ports, so a workflow can branch per result |\n| `state-dedupe/` | code | `context.state`: let each item through once, a short lock with `ttlSeconds` |\n| `storage-files/` | code | `context.storage`: write, read, list, and a signed link |\n| `storage-paths-media/` | code | `path`/`paths` properties and `context.media` to trim a video |\n| `http-request/` | code | `fetch` with a select, a number and a JSON field; failures by the error port |\n| `connection-account/` | code | A `connection` property and `context.connections`; the test checks the clean refusal without an account |\n| `secrets/` | code | `context.secrets` for a key the plugin itself holds |\n| `packages/` | code | `require` of allowed packages: lodash, date-fns, zod |\n| `declared-api-key/` | declared | One request to an API with the user's own key: `baseUrl` and a `secret` property |\n| `declared-connection-list/` | declared | A list from a connected account, with `items` and page-by-page `paginate` |\n| `declared-reshape/` | declared | No request at all: outputs built from the fields with filters |\n| `trigger-new-mail/` | trigger | A starting point that polls a mailbox, from now on |\n| `apps/` | apps | Whole apps in the `modules/` shape: GitHub and Slack without code, Gmail and DeepL with declared and code modules side by side. See `apps/README.md` |\n\nDeclared steps and triggers have no `index.js`: `vsa test` checks their manifests. A declared step is published from the developer portal, not GitHub: **New module \u2192 Call an API**, paste its `manifest.json`, **Try it**, publish.\n\nThe test files here are what passes **locally**, where there is no account. To publish a step, its test must come back on `success` in a push check: for a step that acts as an account, put the id of one of your connected accounts in `config` (the module's page on the portal lists them) and set `expectOutput` to `success`.\n", "examples/secrets/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n // Provided for the step at run time. Never put a key in index.js: this repository, a fork\n // and a reviewer can all read it.\n const apiKey = context.secrets.API_KEY;\n if (!apiKey) {\n return { output: "error", data: { reason: "API_KEY is not set for this step" } };\n }\n\n const res = await fetch(`https://api.example.com/search?q=${encodeURIComponent(config.query)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n return { output: res.ok ? "success" : "error", data: { status: res.status } };\n },\n};\n', "examples/secrets/manifest.json": '{\n "description": "Calls an API that needs a key",\n "icon": "key",\n "properties": {\n "query": {\n "label": "Search for",\n "type": "string",\n "required": true\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ]\n}\n', "examples/secrets/visualautomate.test.json": '{\n "config": {\n "query": "workflows"\n },\n "input": {},\n "expectOutput": "error"\n}\n', "examples/state-dedupe/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n const id = input.id;\n if (id === undefined || id === null) {\n return { output: "error", data: { reason: "input.id is missing" } };\n }\n\n const key = config.key || "seen-ids";\n // Values are JSON and at most 128 KB, so keep a bounded list rather than everything ever seen.\n const seen = (await context.state.get(key)) ?? [];\n if (seen.includes(id)) return { output: "duplicate", data: { id } };\n\n seen.push(id);\n await context.state.set(key, seen.slice(-500));\n\n // A value that expires on its own: a lock for five minutes (the minimum is 60 seconds).\n await context.state.set(`lock:${id}`, true, { ttlSeconds: 300 });\n\n return { output: "new", data: { id } };\n },\n};\n', "examples/state-dedupe/manifest.json": `{
2519
+ `, "examples/apps/slack/README.md": '# Slack: an app without code, and a 200 that is a failure\n\nFour declared modules, reached with the bot token of the user\'s own Slack app (`xoxb-\u2026`), pasted into a `secret` field: only VisualAutomate\'s own apps act as a connected account. Like every declared module, each is created on the portal (**New module \u2192 Call an API**, paste its `manifest.json`, **Test the call** with a token) and published there, not from GitHub.\n\n| | |\n| --- | --- |\n| Name | Slack |\n| Connection | none: the bot token is a `secret` field on each module, with the scopes the user\'s Slack app was given |\n| `baseUrl` | `https://slack.com/api`, with `Authorization: Bearer {{config.token}}` |\n\nSlack answers `200` even when it refuses: `{ "ok": false, "error": "channel_not_found" }`. Every module therefore has `"okPath": "ok"` and `"errorPath": "error"`, so a refusal leaves by the `error` port instead of passing as a success with nothing in it.\n\n| Module | Request | Notes |\n| --- | --- | --- |\n| `post-message` | `POST chat.postMessage` | Hands on the message\'s `ts`, which `add-reaction` and threads need |\n| `list-channels` | `GET conversations.list` | A list, with the ids the other modules ask for |\n| `add-reaction` | `POST reactions.add` | |\n| `find-user-by-email` | `GET users.lookupByEmail` | |\n', "examples/branching-outputs/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config) => {\n const score = Number(input.score);\n if (Number.isNaN(score)) {\n return { output: "error", data: { reason: "input.score is not a number" } };\n }\n const high = config.highFrom ?? 80;\n const medium = config.mediumFrom ?? 50;\n const output = score >= high ? "high" : score >= medium ? "medium" : "low";\n return { output, data: { score } };\n },\n};\n', "examples/branching-outputs/manifest.json": '{\n "description": "Sorts a score into high, medium or low",\n "icon": "split",\n "properties": {\n "highFrom": {\n "label": "High from",\n "type": "number",\n "default": 80\n },\n "mediumFrom": {\n "label": "Medium from",\n "type": "number",\n "default": 50\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "high",\n "medium",\n "low",\n "error"\n ],\n "outputFields": {\n "score": "The score that was sorted"\n }\n}\n', "examples/branching-outputs/visualautomate.test.json": '{\n "config": {},\n "input": {\n "score": 91\n },\n "expectOutput": "high"\n}\n', "examples/declared-api-key/manifest.json": '{\n "description": "Gets the current weather for a city",\n "icon": "cloud-sun",\n "auth": {\n "type": "none"\n },\n "properties": {\n "apiKey": {\n "label": "API key",\n "type": "secret",\n "required": true,\n "description": "From your account on the weather service"\n },\n "city": {\n "label": "City",\n "type": "string",\n "required": true\n },\n "units": {\n "label": "Units",\n "type": "select",\n "default": "metric",\n "options": [\n {\n "label": "Celsius",\n "value": "metric"\n },\n {\n "label": "Fahrenheit",\n "value": "imperial"\n }\n ]\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "temperature": "The temperature now",\n "summary": "The weather in words",\n "city": "The city it is for"\n },\n "action": {\n "baseUrl": "https://api.example.com/v1",\n "request": {\n "method": "GET",\n "path": "weather",\n "query": {\n "q": "{{config.city}}",\n "units": "{{config.units}}"\n },\n "headers": {\n "Authorization": "Bearer {{config.apiKey}}"\n }\n },\n "errorPath": "error.message",\n "outputs": {\n "temperature": "{{response.current.temp}}",\n "summary": "{{response.current.summary}}",\n "city": "{{config.city}}"\n }\n }\n}\n', "examples/declared-reshape/manifest.json": '{\n "description": "Turns a comma-separated list of addresses into a list",\n "icon": "list",\n "auth": {\n "type": "none"\n },\n "properties": {\n "addresses": {\n "label": "Addresses",\n "type": "string",\n "required": true,\n "description": "Separated by commas"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "recipients": "The addresses, as a list",\n "first": "The first address",\n "count": "How many there are"\n },\n "action": {\n "outputs": {\n "recipients": "{{config.addresses | list}}",\n "first": "{{config.addresses | first}}",\n "count": "{{config.addresses | length}}"\n }\n }\n}\n', "examples/hello-world/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n if (!config.name) {\n return { output: "error", data: { reason: "No name given" } };\n }\n return { output: "success", data: { greeting: `Hello, ${config.name}!` } };\n },\n};\n', "examples/hello-world/manifest.json": '{\n "description": "Greets someone",\n "icon": "sparkles",\n "properties": {\n "name": {\n "label": "Name",\n "type": "string",\n "required": true,\n "description": "Who to greet"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "greeting": "The greeting text"\n }\n}\n', "examples/hello-world/visualautomate.test.json": '{\n "config": {\n "name": "VisualAutomate"\n },\n "input": {},\n "expectOutput": "success"\n}\n', "examples/http-request/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config) => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), (config.timeoutSeconds || 10) * 1000);\n\n try {\n const hasBody = config.method === "POST" || config.method === "PUT";\n const res = await fetch(config.url, {\n method: config.method || "GET",\n headers: hasBody ? { "Content-Type": "application/json" } : {},\n body: hasBody ? JSON.stringify(config.body ?? {}) : undefined,\n signal: controller.signal,\n });\n\n const text = await res.text();\n let body = text;\n try { body = JSON.parse(text); } catch { /* not JSON: keep the text */ }\n\n return { output: res.ok ? "success" : "error", data: { status: res.status, body } };\n } catch (err) {\n return { output: "error", data: { status: 0, reason: String(err) } };\n } finally {\n clearTimeout(timer);\n }\n },\n};\n', "examples/http-request/manifest.json": '{\n "description": "Calls any HTTP endpoint",\n "icon": "globe",\n "timeoutMs": 20000,\n "properties": {\n "url": {\n "label": "URL",\n "type": "string",\n "required": true,\n "pattern": "^https://"\n },\n "method": {\n "label": "Method",\n "type": "select",\n "default": "GET",\n "options": [\n {\n "label": "GET",\n "value": "GET"\n },\n {\n "label": "POST",\n "value": "POST"\n },\n {\n "label": "PUT",\n "value": "PUT"\n },\n {\n "label": "DELETE",\n "value": "DELETE"\n }\n ]\n },\n "body": {\n "label": "Body",\n "type": "json",\n "description": "Sent as JSON for POST and PUT"\n },\n "timeoutSeconds": {\n "label": "Give up after (s)",\n "type": "number",\n "default": 10,\n "min": 1,\n "max": 15\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "status": "HTTP status",\n "body": "The parsed response, or text"\n }\n}\n', "examples/http-request/visualautomate.test.json": '{\n "config": {\n "url": "https://httpbin.org/get",\n "method": "GET"\n },\n "input": {},\n "expectOutput": "success"\n}\n', "examples/packages/index.js": 'const _ = require("lodash");\nconst { format } = require("date-fns");\nconst { z } = require("zod");\n\nconst Order = z.object({ id: z.string(), total: z.number(), createdAt: z.string() });\n\n/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input) => {\n const parsed = z.array(Order).safeParse(input.orders ?? []);\n if (!parsed.success) {\n return { output: "error", data: { reason: parsed.error.issues.map((i) => i.message) } };\n }\n\n const byDay = _.mapValues(\n _.groupBy(parsed.data, (o) => format(new Date(o.createdAt), "yyyy-MM-dd")),\n (orders) => _.sumBy(orders, "total"),\n );\n return { output: "success", data: { byDay } };\n },\n};\n', "examples/packages/manifest.json": '{\n "description": "Validates orders and groups them by day",\n "icon": "package",\n "properties": {},\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "byDay": "Order totals per day"\n }\n}\n', "examples/packages/visualautomate.test.json": '{\n "config": {},\n "input": {\n "orders": [\n {\n "id": "a",\n "total": 10,\n "createdAt": "2026-09-14T10:00:00Z"\n },\n {\n "id": "b",\n "total": 5,\n "createdAt": "2026-09-14T12:00:00Z"\n }\n ]\n },\n "expectOutput": "success"\n}\n', "examples/README.md": "# Examples\n\nWorking steps to start from. Copy the closest one into the repository (a plugin's root, or `modules/<slug>/` in an app) and change it. For a whole app, start from `apps/`.\n\nEvery code example passes the platform's code check, and the ones that need no network or extra package are run with `vsa test` by this repository's tests, so they cannot quietly go stale. None uses a connected account: only VisualAutomate's own apps act as one.\n\n| Folder | Kind | Shows |\n| --- | --- | --- |\n| `hello-world/` | code | The smallest working step: one property, success and error ports |\n| `branching-outputs/` | code | More than two ports, so a workflow can branch per result |\n| `state-dedupe/` | code | `context.state`: let each item through once, a short lock with `ttlSeconds` |\n| `storage-files/` | code | `context.storage`: write, read, list, and a signed link |\n| `storage-paths-media/` | code | `path`/`paths` properties and `context.media` to trim a video |\n| `http-request/` | code | `fetch` with a select, a number and a JSON field; failures by the error port |\n| `secrets/` | code | `context.secrets` for a key the plugin itself holds |\n| `packages/` | code | `require` of allowed packages: lodash, date-fns, zod |\n| `declared-api-key/` | declared | One request to an API with the user's own key: `baseUrl` and a `secret` property |\n| `declared-reshape/` | declared | No request at all: outputs built from the fields with filters |\n| `apps/` | apps | Whole apps in the `modules/` shape: GitHub and Slack without code, DeepL with declared and code modules side by side. See `apps/README.md` |\n\nDeclared steps have no `index.js`: `vsa test` checks their manifests. A declared step is published from the developer portal, not GitHub: **New module \u2192 Call an API**, paste its `manifest.json`, **Test the call**, publish.\n\nThe test files here are what passes **locally**. To publish a step, its test must come back on `success` in a push check; a real key never goes in a committed test file (see `apps/deepl`).\n", "examples/secrets/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n // Provided for the step at run time. Never put a key in index.js: this repository, a fork\n // and a reviewer can all read it.\n const apiKey = context.secrets.API_KEY;\n if (!apiKey) {\n return { output: "error", data: { reason: "API_KEY is not set for this step" } };\n }\n\n const res = await fetch(`https://api.example.com/search?q=${encodeURIComponent(config.query)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n return { output: res.ok ? "success" : "error", data: { status: res.status } };\n },\n};\n', "examples/secrets/manifest.json": '{\n "description": "Calls an API that needs a key",\n "icon": "key",\n "properties": {\n "query": {\n "label": "Search for",\n "type": "string",\n "required": true\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ]\n}\n', "examples/secrets/visualautomate.test.json": '{\n "config": {\n "query": "workflows"\n },\n "input": {},\n "expectOutput": "error"\n}\n', "examples/state-dedupe/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n const id = input.id;\n if (id === undefined || id === null) {\n return { output: "error", data: { reason: "input.id is missing" } };\n }\n\n const key = config.key || "seen-ids";\n // Values are JSON and at most 128 KB, so keep a bounded list rather than everything ever seen.\n const seen = (await context.state.get(key)) ?? [];\n if (seen.includes(id)) return { output: "duplicate", data: { id } };\n\n seen.push(id);\n await context.state.set(key, seen.slice(-500));\n\n // A value that expires on its own: a lock for five minutes (the minimum is 60 seconds).\n await context.state.set(`lock:${id}`, true, { ttlSeconds: 300 });\n\n return { output: "new", data: { id } };\n },\n};\n', "examples/state-dedupe/manifest.json": `{
2816
2520
  "description": "Lets each item through once",
2817
2521
  "icon": "database",
2818
2522
  "properties": {
@@ -2834,56 +2538,7 @@ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json":
2834
2538
  "id": "The item's id"
2835
2539
  }
2836
2540
  }
2837
- `, "examples/state-dedupe/visualautomate.test.json": '{\n "config": {},\n "input": {\n "id": "item-1"\n },\n "expectOutput": "new"\n}\n', "examples/storage-files/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n const folder = config.folder || "reports";\n const path = `${folder}/${new Date().toISOString().slice(0, 10)}.json`;\n\n // Write. Strings, bytes or a Uint8Array; the content type is optional.\n const saved = await context.storage.put(path, JSON.stringify(input, null, 2), "application/json");\n\n // Read it back. get() answers null for a path that is not there.\n const back = await context.storage.get(path);\n if (!back) return { output: "error", data: { reason: "The file did not save" } };\n\n // A link somebody can open without an account, valid for an hour.\n const link = await context.storage.signedUrl(path, 3600);\n\n // Stream a URL straight into storage without it passing through this sandbox.\n if (config.sourceUrl && context.storage.download) {\n await context.storage.download(config.sourceUrl, `${folder}/source`, undefined);\n }\n\n const { files, totalSize } = await context.storage.list(`${folder}/`);\n return {\n output: "success",\n data: { path, size: saved.size, expiresAt: saved.expiresAt, link, files: files.map((f) => f.pathname), totalSize },\n };\n },\n};\n', "examples/storage-files/manifest.json": '{\n "description": "Saves a report and returns a link to it",\n "icon": "file",\n "properties": {\n "folder": {\n "label": "Folder",\n "type": "string",\n "default": "reports"\n },\n "sourceUrl": {\n "label": "Also download from",\n "type": "string",\n "description": "Optional URL to store beside it"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "path": "Where the report was saved",\n "link": "A link valid for an hour",\n "files": "What is in the folder now"\n }\n}\n', "examples/storage-files/visualautomate.test.json": '{\n "config": {\n "folder": "examples"\n },\n "input": {\n "hello": "storage"\n },\n "expectOutput": "success"\n}\n', "examples/storage-paths-media/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n // A path property holds the path; the file is not read into the step.\n const info = await context.storage.head(config.source);\n if (!info) return { output: "error", data: { reason: `${config.source} is not in storage` } };\n\n // media is only present where the server has ffmpeg configured.\n if (!context.media) return { output: "error", data: { reason: "Media is not available here" } };\n\n const output = config.source.replace(/(\\.[a-z0-9]+)?$/i, "-trimmed.mp4");\n const result = await context.media.run("trim", {\n input: config.source,\n start: config.start ?? 0,\n duration: config.duration ?? 10,\n output,\n });\n\n if (!result.ok) return { output: "error", data: { logs: result.logs } };\n return { output: "success", data: { output, files: result.files, extras: config.extras ?? [] } };\n },\n};\n', "examples/storage-paths-media/manifest.json": '{\n "description": "Trims a stored video",\n "icon": "film",\n "timeoutMs": 60000,\n "properties": {\n "source": {\n "label": "Video",\n "type": "path",\n "required": true,\n "description": "A video already in storage"\n },\n "start": {\n "label": "Start (s)",\n "type": "number",\n "default": 0,\n "min": 0\n },\n "duration": {\n "label": "Length (s)",\n "type": "number",\n "default": 10,\n "min": 1\n },\n "extras": {\n "label": "Other files",\n "type": "paths",\n "description": "One per line; only listed, not read"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "output": "Where the trimmed video was saved"\n }\n}\n', "examples/storage-paths-media/visualautomate.test.json": '{\n "config": {\n "source": "examples/missing.mp4"\n },\n "input": {},\n "expectOutput": "error"\n}\n', "examples/trigger-new-mail/manifest.json": `{
2838
- "description": "Starts a run for each new email",
2839
- "icon": "mail",
2840
- "auth": {
2841
- "type": "oauth2",
2842
- "provider": "outlook",
2843
- "scopes": [
2844
- "Mail.Read"
2845
- ]
2846
- },
2847
- "properties": {
2848
- "account": {
2849
- "label": "Mailbox",
2850
- "type": "connection",
2851
- "provider": "outlook",
2852
- "required": true
2853
- },
2854
- "poll_minutes": {
2855
- "label": "Check every (minutes)",
2856
- "type": "number",
2857
- "default": 5,
2858
- "min": 1
2859
- }
2860
- },
2861
- "inputs": [],
2862
- "outputs": [
2863
- "success"
2864
- ],
2865
- "outputFields": {
2866
- "subject": "The email's subject",
2867
- "from": "Who sent it"
2868
- },
2869
- "trigger": {
2870
- "connection": "account",
2871
- "poll": "poll_minutes",
2872
- "request": {
2873
- "path": "/v1.0/me/mailFolders/inbox/messages/delta"
2874
- },
2875
- "items": "value",
2876
- "id": "id",
2877
- "cursor": {
2878
- "from": "response",
2879
- "path": "@odata.deltaLink",
2880
- "as": "url",
2881
- "initial": "now"
2882
- },
2883
- "maxPerPoll": 10
2884
- }
2885
- }
2886
- `, "references/code-steps.md": '# Code steps: index.js\n\nA code step is one function, `execute(input, config, context)`, run in an isolated sandbox each time the step runs (once per item when the user makes it repeat).\n\n```js\n/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n if (!config.project) {\n return { output: "error", data: { error: "Choose a project on this step." } }\n }\n\n const res = await fetch("https://api.example.com/tasks", {\n method: "POST",\n headers: { Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json" },\n body: JSON.stringify({ project: config.project, title: config.title }),\n })\n\n const text = await res.text()\n let body = text\n try { body = JSON.parse(text) } catch { /* not JSON: keep the text */ }\n\n if (!res.ok) {\n return { output: "error", data: { error: `The API answered ${res.status}`, status: res.status, body } }\n }\n return { output: "success", data: { id: body.id, url: body.url } }\n },\n}\n```\n\nRules for the file: CommonJS, loaded as written with no build step. No `import` anywhere (comments included). `require` only allowed packages by bare name. See `sandbox.md`.\n\nAccepted export forms: `module.exports = { execute }`, `module.exports.execute = fn`, `module.exports = fn`, or `module.exports.default = fn` (a function). A `default` that is an object with `execute` on it is **not** found by the runner.\n\nThe file is loaded in a `vm` context with the platform\'s globals only (see `sandbox.md`): no `Buffer`, `process`, `globalThis` or `structuredClone`, and a `console` with `log`, `error`, `warn` and `info`. End the file with a newline.\n\n## `input`\n\nThe output data of every step on the route that led here, keyed by **step name** (and node id). Routes that meet here are merged.\n\n```js\nconst invoices = input.list_invoices.invoices // a step named "list_invoices" returned { invoices }\n```\n\nIn a loop, also `input.item`, `input.index` (from 0) and `input.count`; after a loop, `input.collected` (every turn\'s result) and `input.count`. In a test file, `input` is given directly.\n\nPrefer reading values from `config`: a field lets the user choose the source with a data bubble, so the step does not depend on what the previous step is called.\n\n## `config`\n\nThe fields, by property name, with data bubbles already resolved. Types as in `manifest.md`. Empty fields may be missing: always default.\n\n## `context`\n\n| Member | What | Present |\n| --- | --- | --- |\n| `workflowId`, `nodeId`, `userId`, `executionId`, `startedAt` | facts about the run (`startedAt` is ISO) | always |\n| `secrets` | values set for the plugin itself, e.g. `context.secrets.API_KEY`. Not the user\'s credentials | always, may be empty |\n| `storage` | the account\'s files | always |\n| `state` | values that outlive one run, per workflow | always |\n| `connections` | the accounts chosen on this step | only with a `connection` property and a chosen account |\n| `media` | ffmpeg | only where the platform has it configured |\n\n### `context.storage`\n\nPaths are scoped to the account; never prefix them.\n\n| Method | Returns |\n| --- | --- |\n| `put(path, data, contentType?)` | `{ url, size, contentType, uploadedAt, expiresAt }`. `data`: a string or a `Uint8Array` (`Buffer` is not available) |\n| `get(path)` | `Uint8Array`, or `null` when missing |\n| `delete(path)` | nothing |\n| `list(prefix?)` | `{ files: [{ url, pathname, size, uploadedAt }], totalSize }` |\n| `head(path)` | `{ size, contentType }` or `null` |\n| `signedUrl(path, seconds?)` | the storage bucket\'s link, for an API that fetches the file: 60 to 3600 seconds (default 900) |\n| `shareUrl(path, seconds?)` | a link on VisualAutomate\'s own domain, for a person: a mail, a message (default 900 seconds) |\n| `download(url, path, contentType?)` | pulls a URL straight into storage. Optional: check `if (context.storage.download)` |\n\nDecode text with `new TextDecoder().decode(bytes)`.\n\n### `context.state`\n\n| Method | Notes |\n| --- | --- |\n| `get(key)` | the value, or `null` if never set |\n| `set(key, value, { ttlSeconds? })` | any JSON value, at most 128 KB. `ttlSeconds` at least 60 |\n| `delete(key)` | |\n| `list(prefix?)` | `{ keys: [{ key, expiresAt }], complete }`: each key with when it expires (`null` for never); `complete` is false when there were more |\n\nFor cursors, seen-ids (keep them bounded: `seen.slice(-500)`), counters, short locks. Large data goes in storage, with its path in state.\n\n### `context.connections`\n\n```js\nif (!context.connections) {\n return { output: "error", data: { error: "No account was chosen for this step." } }\n}\nconst account = await context.connections.get("account") // YOUR property\'s name, not an id\nconst res = await account.request({\n method: "GET", // GET, POST, PUT, PATCH, DELETE\n path: "user/repos", // relative to the provider: never a full URL\n query: { per_page: 10 },\n headers: {}, // never an Authorization header: the platform adds it\n body: undefined,\n})\n// res: { ok, status, headers?, body } (the provider\'s own reply, body parsed when JSON)\n```\n\nThere is no way to read the token, and no way to choose the host. `context.connections` is absent when no account was chosen, which is also how `vsa test` runs a step locally: check for it. For the push check, and for publishing, name an account in the test file\'s `config` (see `test-and-publish.md`).\n\n### `context.media`\n\n```js\nif (!context.media) return { output: "error", data: { error: "Media is not available here." } }\nconst result = await context.media.run("trim", { input: config.source, start: 0, duration: 10, output: "clips/out.mp4" })\nif (!result.ok) return { output: "error", data: { error: "The trim failed", logs: result.logs } }\n```\n\nName an operation and pass values; never build an ffmpeg command line. Operations: `probe`, `convert`, `trim`, `resize`, `framerate`, `extract-audio`, `remove-audio`, `replace-audio`, `thumbnail`, `frames`, `concat`, `watermark`, `text`, `crop`, `rotate`, `speed`, `volume`, `fade`, `gif`, `compress`, `reframe`, `subtitles`, `metadata`, `loop`. Their values match the fields of the Media steps on the canvas (`trim`: `input`, `start`, `end` or `duration`, `output`). Files are storage paths (`path`/`paths` properties). Not available locally: test only the code around it.\n\n## Return value\n\n```js\nreturn { output: "success", data: { id: "t_42", url: "https://..." } }\n```\n\n- `output`: one of the manifest\'s `outputs`.\n- `data`: a flat object; its keys are what `outputFields` describes and what the next steps read.\n- Optional `metadata: { logs?: string[] }`.\n\nExpected failure: `{ output: "error", data: { error: "<a sentence saying what to do>" } }`. Throw only for what should never happen: a thrown error fails the step with its message.\n\n`console.log`/`warn`/`error` write to the step\'s log. Never log secrets or whole customer records.\n\n## In a loop\n\n`execute` runs once per item. Build storage paths and state keys from `input.item` or `input.index` so turns do not overwrite each other; keep per-item work small (a loop is at most 200 items).\n', "references/declared-steps.md": '# Declared steps: the `action` block\n\nOne HTTP request described in the manifest, made by the platform. No `index.js`, no sandbox, one credit per run for the user, and reviewable at a glance. Use it whenever a step is a single call (or no call at all). It cannot transform: no base64, no combining two calls, no arithmetic. That needs code.\n\n**Where it lives.** A declared module is not published from GitHub: a push publishes only code. It is created on the developer portal with **New module \u2192 Call an API**, and its `manifest.json` is edited on the module\'s action page, as a form or as JSON (both edit the same file). So write the manifest, keep a copy in the repository if you like (`modules/<slug>/manifest.json`), check it with `vsa test`, and paste it into the portal.\n\n`vsa test` checks the manifest (connection or `baseUrl`, a relative path, `https://`, no `{{input}}` in `baseUrl`, items and pages) but does not run it. The action page runs it with **Try it**, against an account the user picks. Publishing needs a passing Try it, on the success port, on the saved version. `$map` and `$if` cannot be edited in the form; paste them in the JSON.\n\n## Shape\n\n```json\n"action": {\n "connection": "account",\n "request": {\n "method": "POST",\n "path": "v1.0/me/sendMail",\n "query": {},\n "headers": {},\n "body": {\n "message": {\n "subject": "{{config.subject}}",\n "body": {\n "contentType": { "$if": "{{config.isHtml}}", "then": "HTML", "else": "Text" },\n "content": "{{config.body}}"\n },\n "toRecipients": { "$map": "{{config.to | list}}", "to": { "emailAddress": { "address": "{{item}}" } } }\n }\n }\n },\n "errorPath": "error.message",\n "outputs": { "sent": true, "to": "{{config.to | list}}", "statusCode": "{{status}}" }\n}\n```\n\n| Key | Notes |\n| --- | --- |\n| `connection` | Name of your `connection` property. The request goes as that account, under the provider\'s base URL |\n| `baseUrl` | For an API without a connection: `"https://api.example.com/v1"`. `https://` only. May use `{{config.\u2026}}`, never `{{input.\u2026}}`. Exactly one of `connection`/`baseUrl` when there is a request |\n| `request.method` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |\n| `request.path` | Relative to the base URL. Never a full URL |\n| `request.query`, `request.headers`, `request.body` | Values may contain tokens |\n| `request.bodyType` | `json` (default), `form` (urlencoded, nested as `a[b]=c`: Stripe, Twilio), `multipart` (a `file` property becomes a file part) |\n| `outputs` | What the step hands on. Literals stay literal (`"sent": true` is a boolean) |\n| `errorPath` | Where the provider puts its refusal reason, e.g. `error.message`. Absent: the whole body |\n| `okPath` / `okValue` | For APIs that answer 200 to a failure. `"okPath": "ok"` (Slack); `"okPath": "status", "okValue": "OK"` (Google Places) |\n| `items` | `{ "path": "data", "limit": 50 }`: the answer is a list at `path`; each item goes through `outputs` reading `{{item.\u2026}}`, and the step hands on `{ items, count }`. `limit` default 100, max 1000 |\n| `paginate` | See below |\n\nLeave out `request` for a step that only reshapes its fields:\n\n```json\n"action": { "outputs": { "recipients": "{{config.to | list}}", "howMany": "{{config.to | length}}" } }\n```\n\n## Tokens\n\n| Token | Is |\n| --- | --- |\n| `{{config.x}}` | a field |\n| `{{input.step_name.field}}` | data from an earlier step |\n| `{{response.a.b}}` | the answer (in `outputs`) |\n| `{{status}}` | HTTP status |\n| `{{item}}`, `{{item.id}}` | the current item in `$map` or with `items` |\n| `{{cursor}}` | the next page\'s cursor, with `paginate` |\n\nA string that is exactly one token keeps its type (`"{{config.isHtml}}"` sends a boolean). Text around a token makes it a string.\n\n**Empty values:** a `query` parameter that resolves to nothing (empty, `null`, missing) is left out of the request. A `body` field is sent as it resolves, so a field the user left blank can arrive as `""`. So put optional values in `query` where the API allows it; for an optional body field that an API refuses when empty, leave the field out of the step, or write code. Dotted paths try a key containing dots first, so `{{response.@odata.nextLink}}` works.\n\nFilters after a bar: `list` (value or comma-separated text to a list), `length`, `first`, `last`, `upper`, `lower`, `trim`, `number`, `json`.\n\nOperators: `{ "$map": "<list token>", "to": <template using {{item}}> }` builds one entry per item; `{ "$if": "<token>", "then": a, "else": b }` picks by truthiness.\n\n## An API with the user\'s own key\n\n```json\n{\n "description": "Get the current weather for a city",\n "auth": { "type": "none" },\n "properties": {\n "apiKey": { "label": "API key", "type": "secret", "required": true },\n "city": { "label": "City", "type": "string", "required": true }\n },\n "outputs": ["success", "error"],\n "outputFields": { "temperature": "Degrees Celsius", "summary": "In words" },\n "action": {\n "baseUrl": "https://api.example.com/v1",\n "request": {\n "method": "GET",\n "path": "weather",\n "query": { "q": "{{config.city}}" },\n "headers": { "Authorization": "Bearer {{config.apiKey}}" }\n },\n "errorPath": "error.message",\n "outputs": { "temperature": "{{response.current.temp}}", "summary": "{{response.current.summary}}" }\n }\n}\n```\n\n## Pagination\n\n```json\n"request": { "method": "GET", "path": "v1/customers", "query": { "limit": "100", "starting_after": "{{cursor}}" } },\n"items": { "path": "data", "limit": 500 },\n"paginate": { "from": "item", "path": "id", "more": "has_more" }\n```\n\n| `from` | Next page from |\n| --- | --- |\n| `response` | a value in the answer (`response_metadata.next_cursor`, `paging.next.after`). With `"as": "url"` a next link becomes the next request (`@odata.nextLink`) |\n| `item` | a value on the last item (Stripe `id` for `starting_after`) |\n| `page` | a page number from `start` |\n| `offset` | items so far |\n\nStops at `limit`, an empty page, `more` false, no next value, or `maxPages` (default 10, max 50).\n\n## Checklist (nothing runs it for you)\n\n- The JSON parses, and every `{{config.x}}` names a property that exists.\n- `connection` names a property of type `connection`; or `baseUrl` is `https://` and reads no `input`.\n- `path` is relative; no Authorization header for a connection.\n- `outputs` covers every key in `outputFields`, and `errorPath`/`okPath` match the provider\'s documented error shape.\n- Lists have `items` with a `limit`.\n', "references/manifest.md": '# manifest.json\n\nDeclares a step: the fields the canvas draws, the ports it leaves by, what it hands on, the account it acts as, and its limits. Strict JSON on the platform (the CLI strips `//` comments in local files). Keys not listed here are dropped when the step is saved.\n\n```json\n{\n "description": "Create a task in a project",\n "icon": "check-square",\n "color": "#5e6ad2",\n "auth": { "type": "none" },\n "properties": {\n "apiKey": { "label": "API key", "type": "secret", "required": true, "group": "Account" },\n "project": { "label": "Project", "type": "string", "required": true },\n "title": { "label": "Title", "type": "string", "required": true },\n "priority": {\n "label": "Priority",\n "type": "select",\n "default": "normal",\n "options": [{ "label": "Normal", "value": "normal" }, { "label": "Urgent", "value": "urgent" }]\n }\n },\n "inputs": ["input"],\n "outputs": ["success", "error"],\n "outputFields": { "id": "The new task\'s id", "url": "A link to it" },\n "timeoutMs": 20000\n}\n```\n\n## Top-level keys\n\n| Key | Type | Notes |\n| --- | --- | --- |\n| `description` | string | One line, shown in the step picker. Start with a verb |\n| `icon` | string | A Lucide icon name: `globe`, `database`, `mail`, `file-text`, `sparkles`, ... |\n| `color` | string | Hex ring colour on the canvas |\n| `version` | number | Bump when the declaration changes shape. Default 1 |\n| `properties` | object | The fields. See below |\n| `inputs` | string[] | Default `["input"]`. A trigger has `[]` |\n| `outputs` | string[] | Ports. Default `["success", "error"]`. Each is a line a user can draw from |\n| `outputFields` | object | Describes the keys of `data` for the next step\'s picker: `{ "id": "The new id" }`, or `{ "items": { "type": "list", "description": "..." } }` |\n| `auth` | object | `{ "type": "oauth2" \\| "api_key" \\| "basic" \\| "none", "provider"?: "...", "scopes"?: [...] }` |\n| `timeoutMs` | number | Default 10000 on a canvas and 30000 in the push check, max 60000, and never more than the user\'s plan allows. The push check reads it here, not from the test file |\n| `memoryLimitMb` | number | Default 32, max 128 |\n| `action` | object | A declared step. See `declared-steps.md` |\n| `trigger` | object | A trigger. See `triggers.md` |\n\n`auth` may not contain `url`, `baseUrl`, `base_url` or `host`: refused outright.\n\n## Properties\n\nThe key is the name in code: property `title` is `config.title`.\n\n| Key | Notes |\n| --- | --- |\n| `label` | Shown above the input. Always set it, in words |\n| `type` | See the table below |\n| `required` | The workflow will not run while it is empty; the step shows a red dot |\n| `default` | Starting value |\n| `description` | Helper text under the input |\n| `group` | Fields with the same group are shown together |\n| `options` | For `select`/`multiselect`: `[{ "label": "Fast", "value": "fast" }]` |\n| `min`, `max` | For `number` |\n| `pattern` | For `string`: a regex the value must match, e.g. `"^https://"` |\n| `help` | Longer help, shown on request |\n| `disabled` | Shown but not editable |\n| `optionsFrom` | For `select`/`multiselect`: read the options from the account, `{ "connection": "account", "request": { "path": "\u2026" }, "items": { "path": "\u2026" }, "label": "{{item.name}}", "value": "{{item.id}}" }`. GET only |\n| `sentTo` | For `secret`: the properties that say where it is sent (a server URL); the stored key is kept only while they are unchanged |\n| `verbatim` | For `secret`: sent exactly as typed, never computed with |\n| `provider` | For `connection`: the provider id, e.g. `outlook`, `github`. A module inherits its app\'s |\n\n### Types\n\n| Type | Canvas | Arrives in `config` as |\n| --- | --- | --- |\n| `string` | text box that takes data bubbles | text, with bubbles already filled in |\n| `number` | number input | number |\n| `boolean` | switch | `true`/`false` |\n| `select` | dropdown | the chosen `value` |\n| `multiselect` | several choices | array of values |\n| `json` | JSON editor | the parsed value |\n| `code` | code editor | the text exactly as typed, never parsed |\n| `secret` | masked input for a key the user pastes | text; masked wherever shown |\n| `color` | colour picker | hex string |\n| `date` | date picker | ISO date |\n| `connection` | picker of the user\'s connected accounts | do not read it; use `context.connections.get("<name>")` |\n| `file` | picker over the account\'s storage | the file read: `{ name, contentType, size, contentBytes }`. For a PDF attachment, never a video |\n| `path` | the same picker | the path only, nothing read. What media steps take |\n| `paths` | the same picker, appending | several paths, one per line |\n\nA field the user left empty may be missing: default it in code (`config.limit ?? 10`).\n\n## Ports\n\n```json\n"outputs": ["success", "error", "empty"]\n```\n\nReturn the name of one as `output`. `success` and `error` are what users expect; an error route on the canvas catches `error`. Add a port only for a third outcome a workflow should branch on (`duplicate`, `not_found`, `high`/`medium`/`low`).\n\n## Connections\n\n```json\n"auth": { "type": "oauth2", "provider": "outlook" },\n"properties": {\n "account": { "label": "Outlook account", "type": "connection", "provider": "outlook", "required": true }\n}\n```\n\nIn an app, the app holds the connection; each module still declares its `connection` property (it may omit `provider`, which it inherits).\n', "references/sandbox.md": "# The sandbox\n\nCode steps run in an isolated sandbox created for the run and destroyed afterwards, on <!-- node:start -->**Node.js 24**<!-- node:end -->. The runner compiles `index.js` into a `vm` context of its own, as the body of a function given `module`, `exports` and a `require` that hands out only the allowed packages. `vsa test` loads code exactly that way, and `vsa dev` and `vsa test --docker` also run on the platform's Node version, in the sandbox image (`ghcr.io/visualautomate/plugin-sandbox`).\n\nA step's context has the language's own objects (`Object`, `Array`, `JSON`, `Math`, `Promise`, `Map`, `Date`, `RegExp`, \u2026) and these globals, nothing more: <!-- provided:start -->`fetch`, `Request`, `Response`, `Headers`, `URL`, `URLSearchParams`, `btoa`, `atob`, `console`, `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `crypto`, `TextEncoder`, `TextDecoder`, `ArrayBuffer`, `Uint8Array`, `Uint16Array`, `Uint32Array`, `Int8Array`, `Int16Array`, `Int32Array`, `Float32Array`, `Float64Array`, `DataView`, `Blob`, `FormData`, `AbortController`, `AbortSignal`<!-- provided:end -->.\n\nThese are set to undefined: <!-- blocked:start -->`process`, `global`, `module`, `exports`, `__dirname`, `__filename`, `eval`, `Function`, `child_process`, `fs`, `os`, `cluster`, `dgram`, `dns`, `net`, `http`, `https`, `path`, `readline`, `stream`, `tls`, `zlib`, `buffer`, `Buffer`, `globalThis`, `self`, `window`, `SharedArrayBuffer`, `Atomics`, `WeakRef`, `FinalizationRegistry`, `AsyncFunction`, `GeneratorFunction`, `AsyncGeneratorFunction`, `WebAssembly`<!-- blocked:end -->. Anything else Node has (`structuredClone`, `setImmediate`, `queueMicrotask`, `performance`) is simply not there.\n\n`console` has four methods: `log`, `error`, `warn` and `info`. `console.debug` and `console.table` do not exist.\n\nThe code is wrapped on one line with nothing added after it, so **end `index.js` with a newline**: a last line that is a `//` comment with no newline after it comments out the wrapper's closing bracket and the step does not load.\n\n## Not available\n\n<!-- globals:start -->\n| Name | Instead |\n| --- | --- |\n| `process` | Ids and secrets are on `context`. |\n| `fs` | Use context.storage. |\n| `child_process` | Not available. For ffmpeg, use context.media.run. |\n| `os` | Not available. |\n| `net` | Use fetch. |\n| `http` | Use fetch. |\n| `https` | Use fetch. |\n| `eval` | Not available. |\n| `Function` | Not available as a constructor. |\n| `Buffer` | Use Uint8Array and TextEncoder/TextDecoder. |\n| `__dirname` | There is no meaningful filesystem. |\n<!-- globals:end -->\n\nNo environment variables, no filesystem, no child processes. Files go through `context.storage`; keys through a `secret` property or `context.secrets`. Bytes are `Uint8Array`: decode with `new TextDecoder().decode(bytes)`, encode with `new TextEncoder().encode(text)`.\n\n## npm packages\n\n`require` with a literal string and a bare package name: `require(\"lodash\")`, never `require(\"lodash/get\")` or `require(name)`. They are pre-installed in the sandbox, so there is nothing to install or bundle for the platform. Locally, `vsa test` without Docker loads them from the project and prints the `npm install` line for a missing one.\n\n<!-- packages:start -->\n`nodemailer`, `lodash`, `ramda`, `underscore`, `immer`, `immutable`, `deepmerge`, `defu`, `rfdc`, `klona`, `fast-copy`, `fast-deep-equal`, `dequal`, `object-hash`, `hash-sum`, `flat`, `dot-prop`, `object-path`, `sort-array`, `natural-orderby`, `deep-object-diff`, `microdiff`, `fast-json-patch`, `rfc6902`, `just-clone`, `just-diff`, `just-diff-apply`, `just-omit`, `just-pick`, `just-group-by`, `just-order-by`, `just-unique`, `just-flatten-it`, `just-safe-get`, `just-safe-set`, `just-typeof`, `just-range`, `just-zip-it`, `just-compare`, `just-intersect`, `just-split`, `just-truncate`, `semver`, `compare-versions`, `tiny-invariant`, `json-stringify-safe`, `fast-safe-stringify`, `flatted`, `superjson`, `devalue`, `lru-cache`, `quick-lru`, `memoize-one`, `fast-memoize`, `uuid`, `nanoid`, `ulid`, `@paralleldrive/cuid2`, `slugify`, `slug`, `sanitize-filename`, `filenamify`, `seedrandom`, `random-js`, `chance`, `@faker-js/faker`, `lorem-ipsum`, `randomcolor`, `date-fns`, `date-fns-tz`, `dayjs`, `luxon`, `moment`, `moment-timezone`, `ms`, `pretty-ms`, `parse-duration`, `humanize-duration`, `timeago.js`, `chrono-node`, `rrule`, `cron-parser`, `cronstrue`, `date-and-time`, `fecha`, `dateformat`, `iso8601-duration`, `ics`, `zod`, `yup`, `joi`, `ajv`, `ajv-formats`, `ajv-errors`, `superstruct`, `valibot`, `jsonschema`, `@sinclair/typebox`, `validator`, `email-validator`, `libphonenumber-js`, `iban`, `credit-card-type`, `card-validator`, `psl`, `tldts`, `normalize-url`, `valid-url`, `is-ip`, `ipaddr.js`, `ip-address`, `netmask`, `ua-parser-js`, `bowser`, `papaparse`, `csv-parse`, `csv-stringify`, `json-2-csv`, `js-yaml`, `yaml`, `json5`, `jsonc-parser`, `hjson`, `toml`, `@iarna/toml`, `ini`, `fast-xml-parser`, `xml2js`, `xml-js`, `xmlbuilder2`, `@xmldom/xmldom`, `xpath`, `cheerio`, `htmlparser2`, `node-html-parser`, `parse5`, `sanitize-html`, `marked`, `markdown-it`, `showdown`, `turndown`, `html-to-text`, `he`, `entities`, `striptags`, `front-matter`, `qs`, `query-string`, `url-parse`, `mime-types`, `mime`, `content-type`, `cookie`, `set-cookie-parser`, `jsonpath-plus`, `jmespath`, `jsonata`, `exceljs`, `change-case`, `camelcase`, `decamelize`, `title-case`, `pluralize`, `inflection`, `voca`, `underscore.string`, `string-similarity`, `fastest-levenshtein`, `leven`, `diff`, `diff-match-patch`, `remove-accents`, `transliteration`, `latinize`, `franc`, `stopword`, `emoji-regex`, `node-emoji`, `string-width`, `word-wrap`, `wrap-ansi`, `linkify-it`, `linkifyjs`, `fuse.js`, `minisearch`, `lunr`, `flexsearch`, `mustache`, `liquidjs`, `intl-messageformat`, `gpt-tokenizer`, `js-tiktoken`, `decimal.js`, `big.js`, `bignumber.js`, `currency.js`, `dinero.js`, `numeral`, `accounting`, `fraction.js`, `convert-units`, `js-quantities`, `mathjs`, `simple-statistics`, `ml-matrix`, `d3-array`, `d3-format`, `d3-time-format`, `geolib`, `haversine-distance`, `filesize`, `bytes`, `human-format`, `jsonwebtoken`, `jose`, `jws`, `jwt-decode`, `crypto-js`, `bcryptjs`, `otplib`, `otpauth`, `tweetnacl`, `tweetnacl-util`, `node-forge`, `elliptic`, `bn.js`, `js-base64`, `base64-js`, `base-64`, `hi-base32`, `thirty-two`, `js-sha256`, `js-sha512`, `js-sha1`, `js-md5`, `hash.js`, `spark-md5`, `blueimp-md5`, `crc-32`, `crc`, `utf8`, `iconv-lite`, `punycode`, `pako`, `fflate`, `lz-string`, `color`, `tinycolor2`, `chroma-js`, `color-convert`, `colord`, `i18n-iso-countries`, `iso-3166-1`, `iso-639-1`, `currency-codes`, `qrcode-generator`, `p-limit`, `p-retry`, `p-map`, `p-queue`, `p-timeout`, `p-all`, `p-props`, `p-settle`, `p-throttle`, `p-debounce`, `async`, `bluebird`, `promise-retry`, `retry`, `exponential-backoff`, `bottleneck`, `throttle-debounce`, `delay`, `graphlib`\n<!-- packages:end -->\n\nUseful picks: dates `date-fns`, `dayjs`, `luxon`; validation `zod`; CSV `papaparse`, `csv-parse`; XML `fast-xml-parser`; HTML `cheerio`; Markdown `marked`; money `decimal.js`, `currency.js`; JWT `jose`; retries `p-retry`; concurrency `p-limit`; ids `nanoid`, `uuid`; spreadsheets `exceljs`; templates `mustache`, `liquidjs`.\n\n## Refused code\n\nThe platform refuses `index.js` when `require` is called with anything but a string literal naming an allowed package, or when any of these regular expressions matches **anywhere in the text, comments and strings included**:\n\n<!-- patterns:start -->\n```text\n\\bprocess\\s*\\.\\s*exit\n\\bprocess\\s*\\.\\s*env\n\\bprocess\\s*\\.\\s*binding\n\\bprocess\\s*\\.\\s*mainModule\n\\bprocess\\s*\\[\n\\bprocess\\s*\\(\n\\beval\\s*\\(\n\\bFunction\\s*\\(\n\\bnew\\s+Function\\b\n\\bAsyncFunction\\b\n\\bGeneratorFunction\\b\n\\bglobalThis\\b\n\\bself\\s*\\[\n\\bimport\\s+\n\\bimport\\s*\\(\n\\bReflect\\b\n\\bProxy\\b\n\\b__proto__\\b\n\\bprototype\\s*\\[\n\\bprototype\\s*\\.\\s*constructor\\b\n\\bconstructor\\b\n\\bthis\\s*\\.\\s*constructor\\b\n\\barguments\\s*\\.\\s*callee\\b\n\\bObject\\s*\\.\\s*defineProperty\\b\n\\b__defineGetter__\\b\n\\b__defineSetter__\\b\n\\b__lookupGetter__\\b\n\\b__lookupSetter__\\b\n\\bmodule\\s*\\.\\s*(?:constructor|extensions|_compile|_path|_sourceMaps)\\b\n\\brequire\\s*\\.\\s*cache\\b\n\\brequire\\s*\\.\\s*resolve\\b\n\\bError\\s*\\.\\s*captureStackTrace\\b\n\\bWebAssembly\\b\n```\n<!-- patterns:end -->\n\nSo: no `import` (not even `import type`, or the word \"import\" followed by a space in a comment), no `.constructor` and no variable called `constructor` (`constructorName` is fine: not a whole word), no `globalThis`, `Reflect`, `Proxy`, `eval(` or `process.env`. Rephrase comments that would contain them.\n\n## Network\n\n- `fetch` may reach the hosts allowed for the plugin on the platform (a domain covers its subdomains), plus a host in the step's own configuration, such as a URL field. The manifest cannot widen this.\n- Never localhost, private networks or cloud metadata addresses.\n- A connected account is reached only through `context.connections`, under the provider's base URL.\n- Locally, `allowedDomains` in the test file applies the same rule; without it, `vsa test` lists every host the platform would refuse.\n\n## Limits\n\n| | Default | Max |\n| --- | --- | --- |\n| `timeoutMs`, one run of the step | 10000 on a canvas, 30000 in the push check | 60000, and the user's plan |\n| `index.js` | | 100,000 characters |\n| `memoryLimitMb` | 32 | 128 |\n| One state value | | 128 KB |\n| `ttlSeconds` on a state value | at least 60 | |\n| A loop the user puts the step in | | 200 items |\n\nMachine tiers (`vsa dev --tier`), the first being the default: <!-- tiers:start -->`standard` 0.5 vCPU and 4 GB, `boosted` 1 vCPU and 6 GB, `high` 2 vCPU and 8 GB<!-- tiers:end -->.\n", "references/test-and-publish.md": '# Testing and publishing\n\n## The CLI\n\n```bash\nnpm install -g @visualautomate/cli # vsa, va and visualautomate are the same command\n```\n\nThe CLI runs on Node 20 or newer; steps run on the platform\'s Node 24, which `vsa dev` and `vsa test --docker` use.\n\n| Command | Does |\n| --- | --- |\n| `vsa test [<module>]` | The push check, here: the manifest is checked; a code step\'s code is checked with the upload\'s rules, loaded the way the runner loads it, and run. A declared step or a trigger is checked, not run |\n| `vsa test --docker` | The same, inside the sandbox image, on Node 24 |\n| `vsa dev [<module>]` | Runs in the sandbox image and again on every save. `--local` without Docker, `--tier standard\\|boosted\\|high` |\n| `vsa types` | Writes `visualautomate.d.ts` and `jsconfig.json` so `/** @type {PluginModule} */` gives completion |\n| `vsa add:property <type> <name> [description] [default]` | Adds a field to `manifest.json` and a value to the test file. `--required`, `--label`, `--options "A:a,B:b"`, `--provider <p>`, `--module <m>` |\n| `vsa init <name> --template blank\\|http-request\\|connection\\|file` | A TypeScript playground (`plugin.ts`). Not the publishing shape |\n| `vsa skill [--global]` | Installs this skill for Claude Code, and writes AGENTS.md / CLAUDE.md |\n\nAt the root of an app\'s repository, `vsa test` runs every folder in `modules/`, code or not; `vsa test send` runs the one whose name matches best.\n\nNothing in the CLI signs in or uploads. Publishing goes through GitHub and the developer portal.\n\n## The test file: visualautomate.test.json\n\n```json\n{\n "config": { "account": "<connection id>", "project": "Website", "title": "Check the forms" },\n "input": {},\n "expectOutput": "success",\n "storage": { "notes/hello.txt": "hello from a file" },\n "state": { "runs": 0 },\n "secrets": { "API_KEY": "test-key" },\n "allowedDomains": ["api.example.com"]\n}\n```\n\n| Key | Read by | Is |\n| --- | --- | --- |\n| `config` | push check and `vsa test` | The fields, as if filled in. For an account property, the **id of one of the developer\'s connected accounts**: the push check then acts as that account |\n| `input` | push check and `vsa test` | What earlier steps handed on |\n| `expectOutput` | push check and `vsa test` | The port the run must leave by. **Default `success`** when it is left out |\n| `storage`, `state`, `secrets` | `vsa test` only | Files, state and `context.secrets` to start with |\n| `allowedDomains` | `vsa test` only | Hosts `fetch` may reach; anything else fails, as on the platform |\n\nThe run\'s time limit is **`timeoutMs` in manifest.json** (30000 when it names none, at most 60000). The push check does not read a `timeoutMs` in the test file, and `vsa test` warns about one.\n\nA run passes when it succeeds and leaves by `expectOutput`. Invalid JSON stops the run.\n\n## Publishing needs a run on success\n\nThe portal refuses to publish code that has not passed a push test **on the `success` port**: once, on that exact code, within the last 30 days. A test that expects `error` and gets it passes the check, but proves nothing, and publishing stays refused. So every code step needs a test file that reaches `success` on the platform:\n\n- **A step that acts as an account:** put the account\'s id in `config`, under the connection property\'s name (`"account": "\u2026"`). The ids are listed on the module\'s page in the developer portal; ask the user for one. The push check then makes real calls as that account **on every push**, so choose a test that is safe to repeat: a read, a draft, a message to a test channel.\n- **A step that needs the user\'s key:** a `secret` field in `config` would be committed to the repository, so never put a real key there. Use a key the plugin holds in `context.secrets`, a public endpoint the plugin may reach, or ask the user how they want to prove it.\n- **A pure step:** realistic `input` and `config`, `expectOutput` `success`.\n\nLocally there is no account: `context.connections` is absent, as on the platform when no account is chosen, and `vsa test` says when an account property has none named for the push check. `context.media` is not available locally.\n\nA **declared step** is not published from GitHub. It is created on the portal with **New module \u2192 Call an API**, its `manifest.json` pasted into the module\'s editor, and it needs a passing **Try it** on its saved version, on the success port, before it can be published there.\n\nOutput of a passing run:\n\n```text\n\u2713 passed success, 3ms\noutput success (expected success, the default)\ndata { "id": "t_42" }\n```\n\n## Publishing (tell the user; you cannot do it for them)\n\n1. Push the repository to GitHub.\n2. On https://developers.visualautomate.com create a **plugin**, or an **app** (name, description, PNG logo up to 2 MB, and the connection users will connect) with a **New module** per step: **Write code** for a code module, **Call an API** for a declared one.\n3. Open **GitHub repository** and import the repository. A repository belongs to one plugin or app.\n4. Every push is tested against the code modules it touched; the portal shows each commit\'s result. Declared modules are pasted into their action page, tried there with **Try it**, and published there.\n5. Publish a commit whose test came back on `success`. **Private**: only the user, always free. **Public**: reviewed first (editing is locked while in review), then listed in the marketplace; can be priced per run and/or per month in credits.\n6. A published version is fixed. A change is a new commit, a new check and a new version.\n', "references/triggers.md": '# Triggers\n\nA trigger starts a workflow for each new item somewhere: a mail, a row, an order. It is **never code that waits**: a sandbox bills by the second. It is a `trigger` block in the manifest; the platform\'s scheduler polls the provider without starting a machine, and starts one run per new item, with the item as the step\'s output. A trigger has no `index.js`, `inputs: []`, and usually `outputs: ["success"]`.\n\n`vsa test` checks it (and warns when `"initial": "now"` is missing) but does not run it. A push publishes only modules with an `index.js` that passes the push check, so a manifest-only trigger cannot be published from GitHub. Ask the user how their app is meant to add one before building it.\n\n## Example: Outlook\'s "New email", as it ships\n\n```json\n{\n "description": "Starts the workflow when new mail arrives",\n "auth": { "type": "oauth2", "provider": "outlook" },\n "properties": {\n "account": { "label": "Outlook account", "type": "connection", "required": true },\n "poll_minutes": {\n "label": "Double-check every",\n "type": "select",\n "default": 30,\n "options": [{ "label": "Every 30 minutes", "value": "30" }, { "label": "Every hour", "value": "60" }]\n }\n },\n "inputs": [],\n "outputs": ["success"],\n "outputFields": { "subject": "The subject line", "from": "Who sent it" },\n "trigger": {\n "connection": "account",\n "poll": "poll_minutes",\n "push": { "resource": "/me/mailFolders(\'inbox\')/messages", "changeType": "created", "maxMinutes": 4230 },\n "request": {\n "path": "/v1.0/me/mailFolders/inbox/messages",\n "query": { "$filter": "receivedDateTime gt {{cursor}}", "$orderby": "receivedDateTime asc", "$top": "25" }\n },\n "items": "value",\n "id": "id",\n "cursor": { "from": "item", "path": "receivedDateTime", "as": "value", "initial": "now" },\n "maxPerPoll": 10\n }\n}\n```\n\n## Keys\n\n| Key | Notes |\n| --- | --- |\n| `connection` | Required. Name of your `connection` property: the account to watch |\n| `request` | `{ method?: "GET" \\| "POST", path, query?, headers?, body? }`. `path` relative to the provider |\n| `items` | Dotted path to the list in the answer (`value`, `data`, `messages`). Empty when the answer is the list |\n| `id` | Dotted path within an item to what makes it unique. Seen ids never start a second run |\n| `poll` | Minutes between checks: a number, or the name of a property so the user picks it. Default 5; the user\'s plan sets the minimum |\n| `cursor` | Where the next poll continues: `{ from: "response" \\| "item", path, as: "url" \\| "value", initial?: "now" }` |\n| `hydrate` | A second request per item, when the list returns only ids: `{ path: ".../messages/{{id}}", query? }`. The result replaces the item |\n| `push` | For providers that notify (Microsoft Graph): `{ resource, changeType, maxMinutes? }`. Still polled underneath as a safety net |\n| `maxPerPoll` | Items one poll may start runs for. Default 25; the rest wait for the next poll |\n\n### cursor\n\n- `from: "response"` reads it from the envelope (a delta link); `from: "item"` from the newest item (a timestamp, a history id).\n- `as: "url"`: the value is the next request outright (delta links carry their own query). `as: "value"`: it is substituted wherever `{{cursor}}` appears in `request`.\n- `initial: "now"`: the first poll only marks the position and starts nothing. **Use it** unless starting from the past is the point: without it, switching the trigger on starts one run, and costs the user credits, for every item already there.\n\n## Checklist\n\n- The provider\'s list endpoint sorts oldest first, or the cursor reads the newest item correctly.\n- `id` is stable and unique per item.\n- `initial: "now"` is set.\n- `maxPerPoll` is modest (10 to 25).\n- `outputFields` describes the item\'s useful fields, since the item is what the next steps read.\n', "SKILL.md": '---\nname: visualautomate-steps\ndescription: Build, fix or extend VisualAutomate steps (plugins, apps and their modules, triggers) end to end - manifest.json, index.js, declared action blocks, visualautomate.test.json, running `vsa test`, and getting the step published on the developer portal. Use whenever someone wants a new VisualAutomate step or integration ("make a VisualAutomate step that...", "add a module to my VisualAutomate app", "connect X to VisualAutomate"), mentions the `vsa`/`va`/`visualautomate` CLI or @visualautomate/plugin-sdk, or is working in a repository with manifest.json + index.js + visualautomate.test.json or a modules/ folder of them.\n---\n\n# Building VisualAutomate steps\n\nVisualAutomate is a workflow automation platform: people drag **steps** onto a canvas and join them with lines. You are building one or more of those steps. Done well, the user describes what the step should do and gets back a repository that passes the platform\'s checks and is ready to push.\n\nRead this file first. Open a file in `references/` when you reach the part it covers; copy from `examples/`, which are held to the platform\'s checks by this repository\'s tests.\n\n| File | Read it when |\n| --- | --- |\n| `references/manifest.md` | writing any `manifest.json` |\n| `references/code-steps.md` | writing `index.js`: `input`, `config`, `context`, return values |\n| `references/declared-steps.md` | the step is one HTTP request, or only reshapes data |\n| `references/triggers.md` | the step should *start* workflows when something new appears |\n| `references/sandbox.md` | requiring an npm package, calling `fetch`, or `vsa test` refuses code |\n| `references/test-and-publish.md` | writing the test file, running the CLI, handing over |\n| `examples/` | before writing a step of the same kind: start from the closest one |\n| `examples/apps/` | building an app with several modules: GitHub and Slack (declared only), Gmail and DeepL (declared and code mixed) |\n\n## The workflow\n\n### 1. Understand the step\n\nGet these straight before writing anything. Ask only for what you cannot sensibly decide yourself, in one short message.\n\n- **What it does**, in one sentence a user would read in the step picker: "Create an invoice in Moneybird".\n- **Which service and how it is reached:**\n - an account the user connects on VisualAutomate (OAuth or a key held by the platform) \u2192 a `connection` property;\n - an API key the user pastes into the step \u2192 a `secret` property;\n - nothing external \u2192 neither.\n- **The fields** a user fills in on the canvas, which are required, and sensible defaults.\n- **What it hands on**: the few named values the next steps need (an id, a URL, a list and its count).\n- **How it can fail**, and whether there is a third outcome worth its own port ("not found", "duplicate", "empty").\n\nIf the user names an API, read its documentation for the exact endpoint, method, body and response shape. Never guess field names of a real API.\n\n### 2. Choose the kind of step\n\nPick the first one that can do the job:\n\n1. **Declared step, no request**: only reshapes what reached it (pick, rename, count, join). An `action` block with `outputs` only.\n2. **Declared step, one request**: one HTTP call built from the fields, with parts of the answer handed on. An `action` block with `request`. No code, no sandbox, one credit per run for the user, and a reviewer can read exactly what it does. Most app modules are this.\n3. **Code step**: anything a single request cannot express: two calls combined, arithmetic, parsing, files, ffmpeg, npm packages, state. An `execute` function in `index.js`, run in a sandbox.\n4. **Trigger**: starts a workflow for every new item somewhere (a mail, a row, an order). Always a `trigger` block in the manifest, never code that waits.\n\nSay which you chose and why in one line when you hand over.\n\n### 3. Choose the repository shape\n\n| Building | Shape |\n| --- | --- |\n| One step that stands alone (a **plugin**) | `manifest.json`, `index.js`, `visualautomate.test.json` at the repository root |\n| Several steps for one service (an **app** with **modules**) | `modules/<module-slug>/` with those three files in each folder. The app itself has no code |\n| A **declared** module (an `action` block, no code) | Not published from GitHub. It is created on the developer portal (**New module \u2192 Call an API**), with its `manifest.json` pasted into the module\'s manifest editor. Write it as `modules/<slug>/manifest.json` all the same, check it with `vsa test`, and tell the user to paste it |\n\nA push publishes only modules with an `index.js` that passes the push check; a declared module or a manifest-only trigger in the repository is checked by the portal and skipped. Module folder names are short kebab-case slugs: `send-message`, `list-invoices`.\n\nIn an app, decide per module: the reads and simple writes are usually declared, and only the modules that need more are code (see `examples/apps/gmail`, where sending needs code and the rest does not). Every module that acts as the account declares the same `connection` property (`account`) with the same provider and `auth`; the app\'s name, logo and connection are set on the portal, not in the repository.\n\nIf the user is already in a repository, follow its shape. If they ran `vsa init`, they have a TypeScript playground (`plugin.ts`); the repository shape above is what gets published.\n\n### 4. Write the files\n\nStart from the closest example. Then:\n\n- `manifest.json`: see `references/manifest.md`. Every field has a `label`; required fields first; `outputFields` describes every key of `data`.\n- `index.js`: see `references/code-steps.md`. CommonJS, `module.exports`, `/** @type {PluginModule} */` above it.\n- `visualautomate.test.json`: see `references/test-and-publish.md`. A realistic `config` and `input` that reach `success` on the platform: publishing needs one push whose test comes back on `success`. For an account property, that means the id of a connected account in `config` (ask the user).\n\n### 5. Check it\n\nFor a **code step**, run the platform\'s check and fix until it passes:\n\n```bash\nvsa test # in the plugin\'s folder, or at an app\'s root (every module)\nvsa test send-message # one module of an app\n```\n\n`vsa test` judges as the push check does: the manifest is checked, the code is loaded the way the platform\'s runner loads it (its globals and no others, a four-method console), the run gets manifest.json\'s `timeoutMs`, and it must leave by `expectOutput`, which is `success` when the test file names none.\n\nIf `vsa` is not installed: `npm install -g @visualautomate/cli` (Node 20+ for the CLI). With Docker available, `vsa test --docker` runs inside the real sandbox image on the platform\'s Node 24.\n\nWhat a red result can mean, and what to do:\n\n- **refused**: the code breaks a rule below. Fix the code, never the check.\n- **`"x" is available in the sandbox but not installed here`**: an allowed package is missing locally. Run the `npm install --save-dev` line it prints (it is only for local runs), or skip the local run and say so.\n- **`\u2717 manifest`**: the manifest breaks a rule the portal enforces. Fix the manifest.\n- **`Cannot read properties of undefined (reading \'get\')`**: the step uses `context.connections` without checking it. Locally there is no account, as on the platform when none is chosen: check for it and leave by `error` with a sentence. To prove the step works, name an account in the test file\'s `config` for the push check.\n- **`\u2026 is not defined` / `undefined`** for `Buffer`, `process`, `structuredClone` and the like: the platform does not have them either. See `references/sandbox.md`.\n- **`context.media is not available locally`**: expected for a step that uses ffmpeg; it runs on the platform.\n- **a network failure**: the API needs the user\'s key or a host this machine cannot reach. Make sure the step fails cleanly by its `error` port, and say what you could not verify.\n\nA **declared step** is checked by `vsa test` (`\u2713 checked \u2026 the manifest is sound`) but not run, here or by the push check. Fix every `\u2717 manifest` line, read the warnings, and check the request against the API\'s documentation. Then it goes to the developer portal: **New module \u2192 Call an API**, paste the `manifest.json` into the module\'s manifest editor (the form above it edits the same file), press **Try it** with an account, and publish there. Publishing needs a Try it that came back on `success`, on the saved version.\n\n### 6. Hand over\n\nTell the user, briefly:\n\n- what you built and which kind of step it is;\n- the files, and what each field and output is;\n- what `vsa test` said (or what you could not test, and why);\n- the next steps, for code modules: push to GitHub; on https://developers.visualautomate.com create the plugin or app (and a **Write code** module), import the repository under **GitHub repository**, wait for the commit\'s check to pass **on success**, then publish. Private steps are usable at once; public ones are reviewed first.\n- for declared modules: on the portal, **New module \u2192 Call an API**, paste the `manifest.json`, **Try it**, publish. They are never published from GitHub.\n- if an account property has no account named in the test file: that the user must add one (the module\'s page lists the ids) before the step can be published.\n\n## Hard rules\n\nThese are enforced by the platform. Breaking one means the step is refused or fails for every user.\n\n1. **No `import`, anywhere in `index.js`**, not even in a comment or a type import. CommonJS only: `module.exports`, `require`.\n2. **`require` only the allowed npm packages**, by bare name (`lodash`, not `lodash/get`), with a literal string. No `fs`, `http`, `child_process`, `os`, `path`. The list is in `references/sandbox.md`.\n3. **None of the refused words**, even in comments or strings: `eval(`, `Function(`, `new Function`, `process.env`, `process.exit`, `globalThis`, `Reflect`, `Proxy`, `constructor`, `__proto__`, `require.cache`, `WebAssembly` and the others in `references/sandbox.md`. Name variables so they do not contain these as whole words (`ctorName`, not `constructor`).\n4. **Never hold or read a credential for a connected account.** Use `context.connections.get("<your connection property>")` and `request({ path })`. There is no token to read.\n5. **Never name a host for a connected account.** Request paths are relative to the provider. A manifest may not contain `url`, `baseUrl`, `base_url` or `host` in `auth`; a declared `action` uses either `connection` or `baseUrl`, never both, and `baseUrl` may read `config` but never `input`.\n6. **No secrets in code or in the repository.** A user\'s own key is a `secret` property; a key the plugin itself uses on everybody\'s behalf comes from `context.secrets`.\n7. **Return `{ output, data }`** where `output` is one of the manifest\'s `outputs`. Expected failures go out by `"error"` with a readable `data.error`; do not throw for them.\n8. **A trigger never waits in code.** It is a `trigger` block; the platform polls. Use `"initial": "now"` on its cursor unless starting from the past is the point.\n9. **Stay inside the limits**: `timeoutMs` at most 60000 (default 10000 on a canvas, 30000 in the push check), `memoryLimitMb` at most 128 (default 32), state values at most 128 KB each with `ttlSeconds` at least 60, `index.js` at most 100,000 characters.\n10. **Only the platform\'s globals.** No `Buffer`, `process`, `globalThis`, `structuredClone`, `setImmediate`; `console` has `log`, `error`, `warn` and `info` only. End `index.js` with a newline.\n11. **Export what the runner accepts**: `module.exports = { execute }`, `module.exports.execute = fn`, or a function. A `default` that is an object is not found.\n\n## What makes a good step\n\n- **The description is a sentence a user picks from**, starting with a verb: "Create an invoice", "List open tickets".\n- **Labels are words, not keys**: "Customer email", not `customerEmail`. Add a `description` where a field needs explaining, and a `group` when there are more than five.\n- **Defaults make the first run work**: a sensible `limit`, a default `select` option.\n- **Outputs are flat and few**: `id`, `url`, `items` and `count` beat the whole raw response. Describe each one in `outputFields`; that is what the next step\'s user sees.\n- **Error messages say what to do**: "Choose a project on this step." beats "Bad request".\n- **Read the body as text before parsing it.** An API having a bad day answers with HTML.\n- **Lists stay small**: a declared step with `items` has a `limit`; a code step that pages through results stops at a sensible maximum.\n- **Logs explain, they do not dump**: `console.log("found 12 invoices")`, never a customer\'s record or a key.\n' };
2541
+ `, "examples/state-dedupe/visualautomate.test.json": '{\n "config": {},\n "input": {\n "id": "item-1"\n },\n "expectOutput": "new"\n}\n', "examples/storage-files/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n const folder = config.folder || "reports";\n const path = `${folder}/${new Date().toISOString().slice(0, 10)}.json`;\n\n // Write. Strings, bytes or a Uint8Array; the content type is optional.\n const saved = await context.storage.put(path, JSON.stringify(input, null, 2), "application/json");\n\n // Read it back. get() answers null for a path that is not there.\n const back = await context.storage.get(path);\n if (!back) return { output: "error", data: { reason: "The file did not save" } };\n\n // A link somebody can open without an account, valid for an hour.\n const link = await context.storage.signedUrl(path, 3600);\n\n // Stream a URL straight into storage without it passing through this sandbox.\n if (config.sourceUrl && context.storage.download) {\n await context.storage.download(config.sourceUrl, `${folder}/source`, undefined);\n }\n\n const { files, totalSize } = await context.storage.list(`${folder}/`);\n return {\n output: "success",\n data: { path, size: saved.size, expiresAt: saved.expiresAt, link, files: files.map((f) => f.pathname), totalSize },\n };\n },\n};\n', "examples/storage-files/manifest.json": '{\n "description": "Saves a report and returns a link to it",\n "icon": "file",\n "properties": {\n "folder": {\n "label": "Folder",\n "type": "string",\n "default": "reports"\n },\n "sourceUrl": {\n "label": "Also download from",\n "type": "string",\n "description": "Optional URL to store beside it"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "path": "Where the report was saved",\n "link": "A link valid for an hour",\n "files": "What is in the folder now"\n }\n}\n', "examples/storage-files/visualautomate.test.json": '{\n "config": {\n "folder": "examples"\n },\n "input": {\n "hello": "storage"\n },\n "expectOutput": "success"\n}\n', "examples/storage-paths-media/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n // A path property holds the path; the file is not read into the step.\n const info = await context.storage.head(config.source);\n if (!info) return { output: "error", data: { reason: `${config.source} is not in storage` } };\n\n // media is only present where the server has ffmpeg configured.\n if (!context.media) return { output: "error", data: { reason: "Media is not available here" } };\n\n const output = config.source.replace(/(\\.[a-z0-9]+)?$/i, "-trimmed.mp4");\n const result = await context.media.run("trim", {\n input: config.source,\n start: config.start ?? 0,\n duration: config.duration ?? 10,\n output,\n });\n\n if (!result.ok) return { output: "error", data: { logs: result.logs } };\n return { output: "success", data: { output, files: result.files, extras: config.extras ?? [] } };\n },\n};\n', "examples/storage-paths-media/manifest.json": '{\n "description": "Trims a stored video",\n "icon": "film",\n "timeoutMs": 60000,\n "properties": {\n "source": {\n "label": "Video",\n "type": "path",\n "required": true,\n "description": "A video already in storage"\n },\n "start": {\n "label": "Start (s)",\n "type": "number",\n "default": 0,\n "min": 0\n },\n "duration": {\n "label": "Length (s)",\n "type": "number",\n "default": 10,\n "min": 1\n },\n "extras": {\n "label": "Other files",\n "type": "paths",\n "description": "One per line; only listed, not read"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "output": "Where the trimmed video was saved"\n }\n}\n', "examples/storage-paths-media/visualautomate.test.json": '{\n "config": {\n "source": "examples/missing.mp4"\n },\n "input": {},\n "expectOutput": "error"\n}\n', "references/code-steps.md": '# Code steps: index.js\n\nA code step is one function, `execute(input, config, context)`, run in an isolated sandbox each time the step runs (once per item when the user makes it repeat).\n\n```js\n/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n if (!config.project) {\n return { output: "error", data: { error: "Choose a project on this step." } }\n }\n\n const res = await fetch("https://api.example.com/tasks", {\n method: "POST",\n headers: { Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json" },\n body: JSON.stringify({ project: config.project, title: config.title }),\n })\n\n const text = await res.text()\n let body = text\n try { body = JSON.parse(text) } catch { /* not JSON: keep the text */ }\n\n if (!res.ok) {\n return { output: "error", data: { error: `The API answered ${res.status}`, status: res.status, body } }\n }\n return { output: "success", data: { id: body.id, url: body.url } }\n },\n}\n```\n\nRules for the file: CommonJS, loaded as written with no build step. No `import` anywhere (comments included). `require` only allowed packages by bare name. See `sandbox.md`.\n\nAccepted export forms: `module.exports = { execute }`, `module.exports.execute = fn`, `module.exports = fn`, or `module.exports.default = fn` (a function). A `default` that is an object with `execute` on it is **not** found by the runner.\n\nThe file is loaded in a `vm` context with the platform\'s globals only (see `sandbox.md`): no `Buffer`, `process`, `globalThis` or `structuredClone`, and a `console` with `log`, `error`, `warn` and `info`. End the file with a newline.\n\n## `input`\n\nThe output data of every step on the route that led here, keyed by **step name** (and node id). Routes that meet here are merged.\n\n```js\nconst invoices = input.list_invoices.invoices // a step named "list_invoices" returned { invoices }\n```\n\nIn a loop, also `input.item`, `input.index` (from 0) and `input.count`; after a loop, `input.collected` (every turn\'s result) and `input.count`. In a test file, `input` is given directly.\n\nPrefer reading values from `config`: a field lets the user choose the source with a data bubble, so the step does not depend on what the previous step is called.\n\n## `config`\n\nThe fields, by property name, with data bubbles already resolved. Types as in `manifest.md`. Empty fields may be missing: always default.\n\n## `context`\n\n| Member | What | Present |\n| --- | --- | --- |\n| `workflowId`, `nodeId`, `userId`, `executionId`, `startedAt` | facts about the run (`startedAt` is ISO) | always |\n| `secrets` | values set for the plugin itself, e.g. `context.secrets.API_KEY`. Not the user\'s credentials | always, may be empty |\n| `storage` | the account\'s files | always |\n| `state` | values that outlive one run, per workflow | always |\n| `media` | ffmpeg | only where the platform has it configured |\n\n### `context.storage`\n\nPaths are scoped to the account; never prefix them.\n\n| Method | Returns |\n| --- | --- |\n| `put(path, data, contentType?)` | `{ url, size, contentType, uploadedAt, expiresAt }`. `data`: a string or a `Uint8Array` (`Buffer` is not available) |\n| `get(path)` | `Uint8Array`, or `null` when missing |\n| `delete(path)` | nothing |\n| `list(prefix?)` | `{ files: [{ url, pathname, size, uploadedAt }], totalSize }` |\n| `head(path)` | `{ size, contentType }` or `null` |\n| `signedUrl(path, seconds?)` | the storage bucket\'s link, for an API that fetches the file: 60 to 3600 seconds (default 900) |\n| `shareUrl(path, seconds?)` | a link on VisualAutomate\'s own domain, for a person: a mail, a message (default 900 seconds) |\n| `download(url, path, contentType?)` | pulls a URL straight into storage. Optional: check `if (context.storage.download)` |\n\nDecode text with `new TextDecoder().decode(bytes)`.\n\n### `context.state`\n\n| Method | Notes |\n| --- | --- |\n| `get(key)` | the value, or `null` if never set |\n| `set(key, value, { ttlSeconds? })` | any JSON value, at most 128 KB. `ttlSeconds` at least 60 |\n| `delete(key)` | |\n| `list(prefix?)` | `{ keys: [{ key, expiresAt }], complete }`: each key with when it expires (`null` for never); `complete` is false when there were more |\n\nFor cursors, seen-ids (keep them bounded: `seen.slice(-500)`), counters, short locks. Large data goes in storage, with its path in state.\n\n### `context.media`\n\n```js\nif (!context.media) return { output: "error", data: { error: "Media is not available here." } }\nconst result = await context.media.run("trim", { input: config.source, start: 0, duration: 10, output: "clips/out.mp4" })\nif (!result.ok) return { output: "error", data: { error: "The trim failed", logs: result.logs } }\n```\n\nName an operation and pass values; never build an ffmpeg command line. Operations: `probe`, `convert`, `trim`, `resize`, `framerate`, `extract-audio`, `remove-audio`, `replace-audio`, `thumbnail`, `frames`, `concat`, `watermark`, `text`, `crop`, `rotate`, `speed`, `volume`, `fade`, `gif`, `compress`, `reframe`, `subtitles`, `metadata`, `loop`. Their values match the fields of the Media steps on the canvas (`trim`: `input`, `start`, `end` or `duration`, `output`). Files are storage paths (`path`/`paths` properties). Not available locally: test only the code around it.\n\n## Return value\n\n```js\nreturn { output: "success", data: { id: "t_42", url: "https://..." } }\n```\n\n- `output`: one of the manifest\'s `outputs`.\n- `data`: a flat object; its keys are what `outputFields` describes and what the next steps read.\n- Optional `metadata: { logs?: string[] }`.\n\nExpected failure: `{ output: "error", data: { error: "<a sentence saying what to do>" } }`. Throw only for what should never happen: a thrown error fails the step with its message.\n\n`console.log`/`warn`/`error` write to the step\'s log. Never log secrets or whole customer records.\n\n## In a loop\n\n`execute` runs once per item. Build storage paths and state keys from `input.item` or `input.index` so turns do not overwrite each other; keep per-item work small (a loop is at most 200 items).\n', "references/declared-steps.md": '# Declared steps: the `action` block\n\nOne HTTP request described in the manifest, made by the platform. No `index.js`, no sandbox, one credit per run for the user, and reviewable at a glance. Use it whenever a step is a single call (or no call at all). It cannot transform: no base64, no combining two calls, no arithmetic. That needs code.\n\n**Where it lives.** A declared module is not published from GitHub: a push publishes only code. It is created on the developer portal with **New module \u2192 Call an API**, and its `manifest.json` is edited on the module\'s action page, as a form or as JSON (both edit the same file). So write the manifest, keep a copy in the repository if you like (`modules/<slug>/manifest.json`), check it with `vsa test`, and paste it into the portal.\n\n`vsa test` checks the manifest (an `https://` `baseUrl` with no `{{input}}`, a relative path, items and pages) but does not run it. The action page runs it with **Test the call** ("Save and test" while there are unsaved changes), with a real key filled in for the test. Publishing needs a test that came back on the success port, on the saved version. `$map` and `$if` cannot be edited in the form; paste them in the JSON.\n\n## Shape\n\n```json\n"action": {\n "baseUrl": "https://api.example.com/v1",\n "request": {\n "method": "POST",\n "path": "messages",\n "query": {},\n "headers": { "Authorization": "Bearer {{config.apiKey}}" },\n "body": {\n "subject": "{{config.subject}}",\n "format": { "$if": "{{config.isHtml}}", "then": "html", "else": "text" },\n "content": "{{config.body}}",\n "recipients": { "$map": "{{config.to | list}}", "to": { "email": "{{item}}" } }\n }\n },\n "errorPath": "error.message",\n "outputs": { "sent": true, "to": "{{config.to | list}}", "statusCode": "{{status}}" }\n}\n```\n\n| Key | Notes |\n| --- | --- |\n| `baseUrl` | The API\'s host, e.g. `"https://api.example.com/v1"`. `https://` only. May use `{{config.\u2026}}`, never `{{input.\u2026}}`. Required when there is a request: only VisualAutomate\'s own apps make a call as a connected account (`connection`) |\n| `request.method` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |\n| `request.path` | Relative to the base URL. Never a full URL |\n| `request.query`, `request.headers`, `request.body` | Values may contain tokens |\n| `request.bodyType` | `json` (default), `form` (urlencoded, nested as `a[b]=c`: Stripe, Twilio), `multipart` (a `file` property becomes a file part) |\n| `outputs` | What the step hands on. Literals stay literal (`"sent": true` is a boolean) |\n| `errorPath` | Where the provider puts its refusal reason, e.g. `error.message`. Absent: the whole body |\n| `okPath` / `okValue` | For APIs that answer 200 to a failure. `"okPath": "ok"` (Slack); `"okPath": "status", "okValue": "OK"` (Google Places) |\n| `items` | `{ "path": "data", "limit": 50 }`: the answer is a list at `path`; each item goes through `outputs` reading `{{item.\u2026}}`, and the step hands on `{ items, count }`. `limit` default 100, max 1000 |\n| `paginate` | See below |\n\nLeave out `request` for a step that only reshapes its fields:\n\n```json\n"action": { "outputs": { "recipients": "{{config.to | list}}", "howMany": "{{config.to | length}}" } }\n```\n\n## Tokens\n\n| Token | Is |\n| --- | --- |\n| `{{config.x}}` | a field |\n| `{{input.step_name.field}}` | data from an earlier step |\n| `{{response.a.b}}` | the answer (in `outputs`) |\n| `{{status}}` | HTTP status |\n| `{{item}}`, `{{item.id}}` | the current item in `$map` or with `items` |\n| `{{cursor}}` | the next page\'s cursor, with `paginate` |\n\nA string that is exactly one token keeps its type (`"{{config.isHtml}}"` sends a boolean). Text around a token makes it a string.\n\n**Empty values:** a `query` parameter that resolves to nothing (empty, `null`, missing) is left out of the request. A `body` field is sent as it resolves, so a field the user left blank can arrive as `""`. So put optional values in `query` where the API allows it; for an optional body field that an API refuses when empty, leave the field out of the step, or write code. Dotted paths try a key containing dots first, so `{{response.@odata.nextLink}}` works.\n\nFilters after a bar: `list` (value or comma-separated text to a list), `length`, `first`, `last`, `upper`, `lower`, `trim`, `number`, `json`.\n\nOperators: `{ "$map": "<list token>", "to": <template using {{item}}> }` builds one entry per item; `{ "$if": "<token>", "then": a, "else": b }` picks by truthiness.\n\n## An API with the user\'s own key\n\n```json\n{\n "description": "Get the current weather for a city",\n "auth": { "type": "none" },\n "properties": {\n "apiKey": { "label": "API key", "type": "secret", "required": true },\n "city": { "label": "City", "type": "string", "required": true }\n },\n "outputs": ["success", "error"],\n "outputFields": { "temperature": "Degrees Celsius", "summary": "In words" },\n "action": {\n "baseUrl": "https://api.example.com/v1",\n "request": {\n "method": "GET",\n "path": "weather",\n "query": { "q": "{{config.city}}" },\n "headers": { "Authorization": "Bearer {{config.apiKey}}" }\n },\n "errorPath": "error.message",\n "outputs": { "temperature": "{{response.current.temp}}", "summary": "{{response.current.summary}}" }\n }\n}\n```\n\n## Pagination\n\n```json\n"request": { "method": "GET", "path": "v1/customers", "query": { "limit": "100", "starting_after": "{{cursor}}" } },\n"items": { "path": "data", "limit": 500 },\n"paginate": { "from": "item", "path": "id", "more": "has_more" }\n```\n\n| `from` | Next page from |\n| --- | --- |\n| `response` | a value in the answer (`response_metadata.next_cursor`, `paging.next.after`). With `"as": "url"` a next link becomes the next request (`@odata.nextLink`) |\n| `item` | a value on the last item (Stripe `id` for `starting_after`) |\n| `page` | a page number from `start` |\n| `offset` | items so far |\n\nStops at `limit`, an empty page, `more` false, no next value, or `maxPages` (default 10, max 50).\n\n## Checklist (nothing runs it for you)\n\n- The JSON parses, and every `{{config.x}}` names a property that exists.\n- `baseUrl` is `https://` and reads no `input`; there is no `connection`.\n- `path` is relative, and the key goes in a header from a `secret` property.\n- `outputs` covers every key in `outputFields`, and `errorPath`/`okPath` match the provider\'s documented error shape.\n- Lists have `items` with a `limit`.\n', "references/manifest.md": '# manifest.json\n\nDeclares a step: the fields the canvas draws, the ports it leaves by, what it hands on, and its limits. Strict JSON on the platform (the CLI strips `//` comments in local files). Keys not listed here are dropped when the step is saved.\n\n```json\n{\n "description": "Create a task in a project",\n "icon": "check-square",\n "color": "#5e6ad2",\n "auth": { "type": "none" },\n "properties": {\n "apiKey": { "label": "API key", "type": "secret", "required": true, "group": "Account" },\n "project": { "label": "Project", "type": "string", "required": true },\n "title": { "label": "Title", "type": "string", "required": true },\n "priority": {\n "label": "Priority",\n "type": "select",\n "default": "normal",\n "options": [{ "label": "Normal", "value": "normal" }, { "label": "Urgent", "value": "urgent" }]\n }\n },\n "inputs": ["input"],\n "outputs": ["success", "error"],\n "outputFields": { "id": "The new task\'s id", "url": "A link to it" },\n "timeoutMs": 20000\n}\n```\n\n## Top-level keys\n\n| Key | Type | Notes |\n| --- | --- | --- |\n| `description` | string | One line, shown in the step picker. Start with a verb |\n| `icon` | string | A Lucide icon name: `globe`, `database`, `mail`, `file-text`, `sparkles`, ... |\n| `color` | string | Hex ring colour on the canvas |\n| `version` | number | Bump when the declaration changes shape. Default 1 |\n| `properties` | object | The fields. See below |\n| `inputs` | string[] | Default `["input"]` |\n| `outputs` | string[] | Ports. Default `["success", "error"]`. Each is a line a user can draw from |\n| `outputFields` | object | Describes the keys of `data` for the next step\'s picker: `{ "id": "The new id" }`, or `{ "items": { "type": "list", "description": "..." } }` |\n| `auth` | object | `{ "type": "none" }`, or leave it out. The other types (`oauth2`, `api_key`, `basic`) name a provider to sign in to, which only VisualAutomate\'s own apps do. A user\'s key goes in a `secret` property |\n| `timeoutMs` | number | Default 10000 on a canvas and 30000 in the push check, max 60000, and never more than the user\'s plan allows. The push check reads it here, not from the test file |\n| `memoryLimitMb` | number | Default 32, max 128 |\n| `action` | object | A declared step. See `declared-steps.md` |\n\n`auth` may not contain `url`, `baseUrl`, `base_url` or `host`: refused outright.\n\n## Properties\n\nThe key is the name in code: property `title` is `config.title`.\n\n| Key | Notes |\n| --- | --- |\n| `label` | Shown above the input. Always set it, in words |\n| `type` | See the table below |\n| `required` | The workflow will not run while it is empty; the step shows a red dot |\n| `default` | Starting value |\n| `description` | Helper text under the input |\n| `group` | Fields with the same group are shown together |\n| `options` | For `select`/`multiselect`: `[{ "label": "Fast", "value": "fast" }]` |\n| `min`, `max` | For `number` |\n| `pattern` | For `string`: a regex the value must match, e.g. `"^https://"` |\n| `help` | Longer help, shown on request |\n| `disabled` | Shown but not editable |\n| `sentTo` | For `secret`: the properties that say where it is sent (a server URL); the stored key is kept only while they are unchanged |\n| `verbatim` | For `secret`: sent exactly as typed, never computed with |\n\n### Types\n\n| Type | Canvas | Arrives in `config` as |\n| --- | --- | --- |\n| `string` | text box that takes data bubbles | text, with bubbles already filled in |\n| `number` | number input | number |\n| `boolean` | switch | `true`/`false` |\n| `select` | dropdown | the chosen `value` |\n| `multiselect` | several choices | array of values |\n| `json` | JSON editor | the parsed value |\n| `code` | code editor | the text exactly as typed, never parsed |\n| `secret` | masked input for a key the user pastes | text; masked wherever shown |\n| `color` | colour picker | hex string |\n| `date` | date picker | ISO date |\n| `file` | picker over the account\'s storage | the file read: `{ name, contentType, size, contentBytes }`. For a PDF attachment, never a video |\n| `path` | the same picker | the path only, nothing read. What media steps take |\n| `paths` | the same picker, appending | several paths, one per line |\n\nA field the user left empty may be missing: default it in code (`config.limit ?? 10`).\n\n## Ports\n\n```json\n"outputs": ["success", "error", "empty"]\n```\n\nReturn the name of one as `output`. `success` and `error` are what users expect; an error route on the canvas catches `error`. Add a port only for a third outcome a workflow should branch on (`duplicate`, `not_found`, `high`/`medium`/`low`).\n\n## No accounts\n\nOnly VisualAutomate\'s own apps act as somebody\'s connected account. A step built here has no `connection` property, `optionsFrom`, `action.connection`, `trigger` or `auth.provider`, and `vsa test` refuses each of them. It reaches an API at its own address, with the user\'s key in a `secret` property:\n\n```json\n"auth": { "type": "none" },\n"properties": {\n "apiKey": { "label": "API key", "type": "secret", "required": true, "description": "Each user enters their own" }\n}\n```\n\nA declared step sends it in a header (`"Authorization": "Bearer {{config.apiKey}}"`, see `declared-steps.md`); code reads `config.apiKey`.\n', "references/sandbox.md": "# The sandbox\n\nCode steps run in an isolated sandbox created for the run and destroyed afterwards, on <!-- node:start -->**Node.js 24**<!-- node:end -->. The runner compiles `index.js` into a `vm` context of its own, as the body of a function given `module`, `exports` and a `require` that hands out only the allowed packages. `vsa test` loads code exactly that way, and `vsa dev` and `vsa test --docker` also run on the platform's Node version, in the sandbox image (`ghcr.io/visualautomate/plugin-sandbox`).\n\nA step's context has the language's own objects (`Object`, `Array`, `JSON`, `Math`, `Promise`, `Map`, `Date`, `RegExp`, \u2026) and these globals, nothing more: <!-- provided:start -->`fetch`, `Request`, `Response`, `Headers`, `URL`, `URLSearchParams`, `btoa`, `atob`, `console`, `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `crypto`, `TextEncoder`, `TextDecoder`, `ArrayBuffer`, `Uint8Array`, `Uint16Array`, `Uint32Array`, `Int8Array`, `Int16Array`, `Int32Array`, `Float32Array`, `Float64Array`, `DataView`, `Blob`, `FormData`, `AbortController`, `AbortSignal`<!-- provided:end -->.\n\nThese are set to undefined: <!-- blocked:start -->`process`, `global`, `module`, `exports`, `__dirname`, `__filename`, `eval`, `Function`, `child_process`, `fs`, `os`, `cluster`, `dgram`, `dns`, `net`, `http`, `https`, `path`, `readline`, `stream`, `tls`, `zlib`, `buffer`, `Buffer`, `globalThis`, `self`, `window`, `SharedArrayBuffer`, `Atomics`, `WeakRef`, `FinalizationRegistry`, `AsyncFunction`, `GeneratorFunction`, `AsyncGeneratorFunction`, `WebAssembly`<!-- blocked:end -->. Anything else Node has (`structuredClone`, `setImmediate`, `queueMicrotask`, `performance`) is simply not there.\n\n`console` has four methods: `log`, `error`, `warn` and `info`. `console.debug` and `console.table` do not exist.\n\nThe code is wrapped on one line with nothing added after it, so **end `index.js` with a newline**: a last line that is a `//` comment with no newline after it comments out the wrapper's closing bracket and the step does not load.\n\n## Not available\n\n<!-- globals:start -->\n| Name | Instead |\n| --- | --- |\n| `process` | Ids and secrets are on `context`. |\n| `fs` | Use context.storage. |\n| `child_process` | Not available. For ffmpeg, use context.media.run. |\n| `os` | Not available. |\n| `net` | Use fetch. |\n| `http` | Use fetch. |\n| `https` | Use fetch. |\n| `eval` | Not available. |\n| `Function` | Not available as a constructor. |\n| `Buffer` | Use Uint8Array and TextEncoder/TextDecoder. |\n| `__dirname` | There is no meaningful filesystem. |\n<!-- globals:end -->\n\nNo environment variables, no filesystem, no child processes. Files go through `context.storage`; keys through a `secret` property or `context.secrets`. Bytes are `Uint8Array`: decode with `new TextDecoder().decode(bytes)`, encode with `new TextEncoder().encode(text)`.\n\n## npm packages\n\n`require` with a literal string and a bare package name: `require(\"lodash\")`, never `require(\"lodash/get\")` or `require(name)`. They are pre-installed in the sandbox, so there is nothing to install or bundle for the platform. Locally, `vsa test` without Docker loads them from the project and prints the `npm install` line for a missing one.\n\n<!-- packages:start -->\n`nodemailer`, `lodash`, `ramda`, `underscore`, `immer`, `immutable`, `deepmerge`, `defu`, `rfdc`, `klona`, `fast-copy`, `fast-deep-equal`, `dequal`, `object-hash`, `hash-sum`, `flat`, `dot-prop`, `object-path`, `sort-array`, `natural-orderby`, `deep-object-diff`, `microdiff`, `fast-json-patch`, `rfc6902`, `just-clone`, `just-diff`, `just-diff-apply`, `just-omit`, `just-pick`, `just-group-by`, `just-order-by`, `just-unique`, `just-flatten-it`, `just-safe-get`, `just-safe-set`, `just-typeof`, `just-range`, `just-zip-it`, `just-compare`, `just-intersect`, `just-split`, `just-truncate`, `semver`, `compare-versions`, `tiny-invariant`, `json-stringify-safe`, `fast-safe-stringify`, `flatted`, `superjson`, `devalue`, `lru-cache`, `quick-lru`, `memoize-one`, `fast-memoize`, `uuid`, `nanoid`, `ulid`, `@paralleldrive/cuid2`, `slugify`, `slug`, `sanitize-filename`, `filenamify`, `seedrandom`, `random-js`, `chance`, `@faker-js/faker`, `lorem-ipsum`, `randomcolor`, `date-fns`, `date-fns-tz`, `dayjs`, `luxon`, `moment`, `moment-timezone`, `ms`, `pretty-ms`, `parse-duration`, `humanize-duration`, `timeago.js`, `chrono-node`, `rrule`, `cron-parser`, `cronstrue`, `date-and-time`, `fecha`, `dateformat`, `iso8601-duration`, `ics`, `zod`, `yup`, `joi`, `ajv`, `ajv-formats`, `ajv-errors`, `superstruct`, `valibot`, `jsonschema`, `@sinclair/typebox`, `validator`, `email-validator`, `libphonenumber-js`, `iban`, `credit-card-type`, `card-validator`, `psl`, `tldts`, `normalize-url`, `valid-url`, `is-ip`, `ipaddr.js`, `ip-address`, `netmask`, `ua-parser-js`, `bowser`, `papaparse`, `csv-parse`, `csv-stringify`, `json-2-csv`, `js-yaml`, `yaml`, `json5`, `jsonc-parser`, `hjson`, `toml`, `@iarna/toml`, `ini`, `fast-xml-parser`, `xml2js`, `xml-js`, `xmlbuilder2`, `@xmldom/xmldom`, `xpath`, `cheerio`, `htmlparser2`, `node-html-parser`, `parse5`, `sanitize-html`, `marked`, `markdown-it`, `showdown`, `turndown`, `html-to-text`, `he`, `entities`, `striptags`, `front-matter`, `qs`, `query-string`, `url-parse`, `mime-types`, `mime`, `content-type`, `cookie`, `set-cookie-parser`, `jsonpath-plus`, `jmespath`, `jsonata`, `exceljs`, `change-case`, `camelcase`, `decamelize`, `title-case`, `pluralize`, `inflection`, `voca`, `underscore.string`, `string-similarity`, `fastest-levenshtein`, `leven`, `diff`, `diff-match-patch`, `remove-accents`, `transliteration`, `latinize`, `franc`, `stopword`, `emoji-regex`, `node-emoji`, `string-width`, `word-wrap`, `wrap-ansi`, `linkify-it`, `linkifyjs`, `fuse.js`, `minisearch`, `lunr`, `flexsearch`, `mustache`, `liquidjs`, `intl-messageformat`, `gpt-tokenizer`, `js-tiktoken`, `decimal.js`, `big.js`, `bignumber.js`, `currency.js`, `dinero.js`, `numeral`, `accounting`, `fraction.js`, `convert-units`, `js-quantities`, `mathjs`, `simple-statistics`, `ml-matrix`, `d3-array`, `d3-format`, `d3-time-format`, `geolib`, `haversine-distance`, `filesize`, `bytes`, `human-format`, `jsonwebtoken`, `jose`, `jws`, `jwt-decode`, `crypto-js`, `bcryptjs`, `otplib`, `otpauth`, `tweetnacl`, `tweetnacl-util`, `node-forge`, `elliptic`, `bn.js`, `js-base64`, `base64-js`, `base-64`, `hi-base32`, `thirty-two`, `js-sha256`, `js-sha512`, `js-sha1`, `js-md5`, `hash.js`, `spark-md5`, `blueimp-md5`, `crc-32`, `crc`, `utf8`, `iconv-lite`, `punycode`, `pako`, `fflate`, `lz-string`, `color`, `tinycolor2`, `chroma-js`, `color-convert`, `colord`, `i18n-iso-countries`, `iso-3166-1`, `iso-639-1`, `currency-codes`, `qrcode-generator`, `p-limit`, `p-retry`, `p-map`, `p-queue`, `p-timeout`, `p-all`, `p-props`, `p-settle`, `p-throttle`, `p-debounce`, `async`, `bluebird`, `promise-retry`, `retry`, `exponential-backoff`, `bottleneck`, `throttle-debounce`, `delay`, `graphlib`\n<!-- packages:end -->\n\nUseful picks: dates `date-fns`, `dayjs`, `luxon`; validation `zod`; CSV `papaparse`, `csv-parse`; XML `fast-xml-parser`; HTML `cheerio`; Markdown `marked`; money `decimal.js`, `currency.js`; JWT `jose`; retries `p-retry`; concurrency `p-limit`; ids `nanoid`, `uuid`; spreadsheets `exceljs`; templates `mustache`, `liquidjs`.\n\n## Refused code\n\nThe platform refuses `index.js` when `require` is called with anything but a string literal naming an allowed package, or when any of these regular expressions matches **anywhere in the text, comments and strings included**:\n\n<!-- patterns:start -->\n```text\n\\bprocess\\s*\\.\\s*exit\n\\bprocess\\s*\\.\\s*env\n\\bprocess\\s*\\.\\s*binding\n\\bprocess\\s*\\.\\s*mainModule\n\\bprocess\\s*\\[\n\\bprocess\\s*\\(\n\\beval\\s*\\(\n\\bFunction\\s*\\(\n\\bnew\\s+Function\\b\n\\bAsyncFunction\\b\n\\bGeneratorFunction\\b\n\\bglobalThis\\b\n\\bself\\s*\\[\n\\bimport\\s+\n\\bimport\\s*\\(\n\\bReflect\\b\n\\bProxy\\b\n\\b__proto__\\b\n\\bprototype\\s*\\[\n\\bprototype\\s*\\.\\s*constructor\\b\n\\bconstructor\\b\n\\bthis\\s*\\.\\s*constructor\\b\n\\barguments\\s*\\.\\s*callee\\b\n\\bObject\\s*\\.\\s*defineProperty\\b\n\\b__defineGetter__\\b\n\\b__defineSetter__\\b\n\\b__lookupGetter__\\b\n\\b__lookupSetter__\\b\n\\bmodule\\s*\\.\\s*(?:constructor|extensions|_compile|_path|_sourceMaps)\\b\n\\brequire\\s*\\.\\s*cache\\b\n\\brequire\\s*\\.\\s*resolve\\b\n\\bError\\s*\\.\\s*captureStackTrace\\b\n\\bWebAssembly\\b\n```\n<!-- patterns:end -->\n\nSo: no `import` (not even `import type`, or the word \"import\" followed by a space in a comment), no `.constructor` and no variable called `constructor` (`constructorName` is fine: not a whole word), no `globalThis`, `Reflect`, `Proxy`, `eval(` or `process.env`. Rephrase comments that would contain them.\n\n## Network\n\n- `fetch` may reach the hosts allowed for the plugin on the platform (a domain covers its subdomains), plus a host in the step's own configuration, such as a URL field. The manifest cannot widen this.\n- Never localhost, private networks or cloud metadata addresses.\n- Locally, `allowedDomains` in the test file applies the same rule; without it, `vsa test` lists every host the platform would refuse.\n\n## Limits\n\n| | Default | Max |\n| --- | --- | --- |\n| `timeoutMs`, one run of the step | 10000 on a canvas, 30000 in the push check | 60000, and the user's plan |\n| `index.js` | | 100,000 characters |\n| `memoryLimitMb` | 32 | 128 |\n| One state value | | 128 KB |\n| `ttlSeconds` on a state value | at least 60 | |\n| A loop the user puts the step in | | 200 items |\n\nMachine tiers (`vsa dev --tier`), the first being the default: <!-- tiers:start -->`standard` 0.5 vCPU and 4 GB, `boosted` 1 vCPU and 6 GB, `high` 2 vCPU and 8 GB<!-- tiers:end -->.\n", "references/test-and-publish.md": '# Testing and publishing\n\n## The CLI\n\n```bash\nnpm install -g @visualautomate/cli # vsa, va and visualautomate are the same command\n```\n\nThe CLI runs on Node 20 or newer; steps run on the platform\'s Node 24, which `vsa dev` and `vsa test --docker` use.\n\n| Command | Does |\n| --- | --- |\n| `vsa test [<module>]` | The push check, here: the manifest is checked; a code step\'s code is checked with the upload\'s rules, loaded the way the runner loads it, and run. A declared step or a trigger is checked, not run |\n| `vsa test --docker` | The same, inside the sandbox image, on Node 24 |\n| `vsa dev [<module>]` | Runs in the sandbox image and again on every save. `--local` without Docker, `--tier standard\\|boosted\\|high` |\n| `vsa types` | Writes `visualautomate.d.ts` and `jsconfig.json` so `/** @type {PluginModule} */` gives completion |\n| `vsa add:property <type> <name> [description] [default]` | Adds a field to `manifest.json` and a value to the test file. `--required`, `--label`, `--options "A:a,B:b"`, `--provider <p>`, `--module <m>` |\n| `vsa init <name> --template blank\\|http-request\\|file` | A TypeScript playground (`plugin.ts`). Not the publishing shape |\n| `vsa skill [--global]` | Installs this skill for Claude Code, and writes AGENTS.md / CLAUDE.md |\n\nAt the root of an app\'s repository, `vsa test` runs every folder in `modules/`, code or not; `vsa test send` runs the one whose name matches best.\n\nNothing in the CLI signs in or uploads. Publishing goes through GitHub and the developer portal.\n\n## The test file: visualautomate.test.json\n\n```json\n{\n "config": { "project": "Website", "title": "Check the forms" },\n "input": {},\n "expectOutput": "success",\n "storage": { "notes/hello.txt": "hello from a file" },\n "state": { "runs": 0 },\n "secrets": { "API_KEY": "test-key" },\n "allowedDomains": ["api.example.com"]\n}\n```\n\n| Key | Read by | Is |\n| --- | --- | --- |\n| `config` | push check and `vsa test` | The fields, as if filled in. Never a real key: the file is committed |\n| `input` | push check and `vsa test` | What earlier steps handed on |\n| `expectOutput` | push check and `vsa test` | The port the run must leave by. **Default `success`** when it is left out |\n| `storage`, `state`, `secrets` | `vsa test` only | Files, state and `context.secrets` to start with |\n| `allowedDomains` | `vsa test` only | Hosts `fetch` may reach; anything else fails, as on the platform |\n\nThe run\'s time limit is **`timeoutMs` in manifest.json** (30000 when it names none, at most 60000). The push check does not read a `timeoutMs` in the test file, and `vsa test` warns about one.\n\nA run passes when it succeeds and leaves by `expectOutput`. Invalid JSON stops the run.\n\n## Publishing needs a run on success\n\nThe portal refuses to publish code that has not passed a push test **on the `success` port**: once, on that exact code, within the last 30 days. A test that expects `error` and gets it passes the check, but proves nothing, and publishing stays refused. So every code step needs a test file that reaches `success` on the platform:\n\n- **A step that needs the user\'s key:** a `secret` field in `config` would be committed to the repository, so never put a real key there. Use a key the plugin holds in `context.secrets`, a public endpoint the plugin may reach, or ask the user how they want to prove it.\n- **A pure step:** realistic `input` and `config`, `expectOutput` `success`.\n\n`context.media` is not available locally.\n\nA **declared step** is not published from GitHub. It is created on the portal with **New module \u2192 Call an API**, its `manifest.json` pasted into the module\'s editor, and it needs a passing **Test the call** on its saved version, on the success port, before it can be published there.\n\nOutput of a passing run:\n\n```text\n\u2713 passed success, 3ms\noutput success (expected success, the default)\ndata { "id": "t_42" }\n```\n\n## Publishing (tell the user; you cannot do it for them)\n\n1. Push the repository to GitHub.\n2. On https://developers.visualautomate.com create a **plugin**, or an **app** (name, description and a PNG logo up to 2 MB; an app made there connects no account) with a **New module** per step: **Write code** for a code module, **Call an API** for a declared one.\n3. Open **GitHub repository** and import the repository. A repository belongs to one plugin or app.\n4. Every push is tested against the code modules it touched; the portal shows each commit\'s result. Declared modules are pasted into their action page, tested there with **Test the call**, and published there.\n5. Publish a commit whose test came back on `success`. **Private**: only the user, always free. **Public**: reviewed first (editing is locked while in review), then listed in the marketplace; can be priced per run and/or per month in credits.\n6. A published version is fixed. A change is a new commit, a new check and a new version.\n', "SKILL.md": '---\nname: visualautomate-steps\ndescription: Build, fix or extend VisualAutomate steps (plugins, and apps with their modules) end to end - manifest.json, index.js, declared action blocks, visualautomate.test.json, running `vsa test`, and getting the step published on the developer portal. Use whenever someone wants a new VisualAutomate step or integration ("make a VisualAutomate step that...", "add a module to my VisualAutomate app", "connect X to VisualAutomate"), mentions the `vsa`/`va`/`visualautomate` CLI or @visualautomate/plugin-sdk, or is working in a repository with manifest.json + index.js + visualautomate.test.json or a modules/ folder of them.\n---\n\n# Building VisualAutomate steps\n\nVisualAutomate is a workflow automation platform: people drag **steps** onto a canvas and join them with lines. You are building one or more of those steps. Done well, the user describes what the step should do and gets back a repository that passes the platform\'s checks and is ready to push.\n\nRead this file first. Open a file in `references/` when you reach the part it covers; copy from `examples/`, which are held to the platform\'s checks by this repository\'s tests.\n\n| File | Read it when |\n| --- | --- |\n| `references/manifest.md` | writing any `manifest.json` |\n| `references/code-steps.md` | writing `index.js`: `input`, `config`, `context`, return values |\n| `references/declared-steps.md` | the step is one HTTP request, or only reshapes data |\n| `references/sandbox.md` | requiring an npm package, calling `fetch`, or `vsa test` refuses code |\n| `references/test-and-publish.md` | writing the test file, running the CLI, handing over |\n| `examples/` | before writing a step of the same kind: start from the closest one |\n| `examples/apps/` | building an app with several modules: GitHub and Slack (declared only, with a token the user pastes), DeepL (declared and code mixed) |\n\n## The workflow\n\n### 1. Understand the step\n\nGet these straight before writing anything. Ask only for what you cannot sensibly decide yourself, in one short message.\n\n- **What it does**, in one sentence a user would read in the step picker: "Create an invoice in Moneybird".\n- **Which service and how it is reached:**\n - an API key or token the user pastes into the step \u2192 a `secret` property, sent in a header;\n - nothing external \u2192 no property.\n\n There is no third way: **only VisualAutomate\'s own apps act as somebody\'s connected account**, so a step you build never uses a `connection` property, `optionsFrom`, `action.connection`, a `trigger` or `auth.provider`, and `vsa test` refuses them. A service that only offers OAuth, with no key or token a user can paste, cannot be built as a step: say so rather than work around it.\n- **The fields** a user fills in on the canvas, which are required, and sensible defaults.\n- **What it hands on**: the few named values the next steps need (an id, a URL, a list and its count).\n- **How it can fail**, and whether there is a third outcome worth its own port ("not found", "duplicate", "empty").\n\nIf the user names an API, read its documentation for the exact endpoint, method, body and response shape. Never guess field names of a real API.\n\n### 2. Choose the kind of step\n\nPick the first one that can do the job:\n\n1. **Declared step, no request**: only reshapes what reached it (pick, rename, count, join). An `action` block with `outputs` only.\n2. **Declared step, one request**: one HTTP call built from the fields, with parts of the answer handed on. An `action` block with `request`. No code, no sandbox, one credit per run for the user, and a reviewer can read exactly what it does. Most app modules are this.\n3. **Code step**: anything a single request cannot express: two calls combined, arithmetic, parsing, files, ffmpeg, npm packages, state. An `execute` function in `index.js`, run in a sandbox.\n\nA step that *starts* workflows when something new appears (a trigger) watches an account, so it is VisualAutomate\'s own to build, not yours.\n\nSay which you chose and why in one line when you hand over.\n\n### 3. Choose the repository shape\n\n| Building | Shape |\n| --- | --- |\n| One step that stands alone (a **plugin**) | `manifest.json`, `index.js`, `visualautomate.test.json` at the repository root |\n| Several steps for one service (an **app** with **modules**) | `modules/<module-slug>/` with those three files in each folder. The app itself has no code |\n| A **declared** module (an `action` block, no code) | Not published from GitHub. It is created on the developer portal (**New module \u2192 Call an API**), with its `manifest.json` pasted into the module\'s manifest editor. Write it as `modules/<slug>/manifest.json` all the same, check it with `vsa test`, and tell the user to paste it |\n\nA push publishes only modules with an `index.js` that passes the push check; a declared module in the repository is skipped by the push and created on the portal. Module folder names are short kebab-case slugs: `send-message`, `list-invoices`.\n\nIn an app, decide per module: the reads and simple writes are usually declared, and only the modules that need more are code (see `examples/apps/deepl`, where a long text needs code and the rest does not). Every module asks for the key the same way, a `secret` property with the same name (`apiKey`), and names the API\'s host in `action.baseUrl` or in its `fetch`; the app\'s name and logo are set on the portal, not in the repository.\n\nIf the user is already in a repository, follow its shape. If they ran `vsa init`, they have a TypeScript playground (`plugin.ts`); the repository shape above is what gets published.\n\n### 4. Write the files\n\nStart from the closest example. Then:\n\n- `manifest.json`: see `references/manifest.md`. Every field has a `label`; required fields first; `outputFields` describes every key of `data`.\n- `index.js`: see `references/code-steps.md`. CommonJS, `module.exports`, `/** @type {PluginModule} */` above it.\n- `visualautomate.test.json`: see `references/test-and-publish.md`. A realistic `config` and `input` that reach `success` on the platform: publishing needs one push whose test comes back on `success`. A real key never goes in the test file, which is committed; see `examples/apps/deepl` for a step whose test needs one.\n\n### 5. Check it\n\nFor a **code step**, run the platform\'s check and fix until it passes:\n\n```bash\nvsa test # in the plugin\'s folder, or at an app\'s root (every module)\nvsa test send-message # one module of an app\n```\n\n`vsa test` judges as the push check does: the manifest is checked, the code is loaded the way the platform\'s runner loads it (its globals and no others, a four-method console), the run gets manifest.json\'s `timeoutMs`, and it must leave by `expectOutput`, which is `success` when the test file names none.\n\nIf `vsa` is not installed: `npm install -g @visualautomate/cli` (Node 20+ for the CLI). With Docker available, `vsa test --docker` runs inside the real sandbox image on the platform\'s Node 24.\n\nWhat a red result can mean, and what to do:\n\n- **refused**: the code breaks a rule below. Fix the code, never the check.\n- **`"x" is available in the sandbox but not installed here`**: an allowed package is missing locally. Run the `npm install --save-dev` line it prints (it is only for local runs), or skip the local run and say so.\n- **`\u2717 manifest`**: the manifest breaks a rule the portal enforces. Fix the manifest.\n- **`\u2717 manifest \u2026 Only VisualAutomate\'s own apps act as a connected account`**: the step names an account (a `connection` property, `optionsFrom`, `action.connection`, a `trigger` or `auth.provider`). Rewrite it to call the API at its own address with a key in a `secret` property.\n- **`\u2026 is not defined` / `undefined`** for `Buffer`, `process`, `structuredClone` and the like: the platform does not have them either. See `references/sandbox.md`.\n- **`context.media is not available locally`**: expected for a step that uses ffmpeg; it runs on the platform.\n- **a network failure**: the API needs the user\'s key or a host this machine cannot reach. Make sure the step fails cleanly by its `error` port, and say what you could not verify.\n\nA **declared step** is checked by `vsa test` (`\u2713 checked \u2026 the manifest is sound`) but not run, here or by the push check. Fix every `\u2717 manifest` line, read the warnings, and check the request against the API\'s documentation. Then it goes to the developer portal: **New module \u2192 Call an API**, paste the `manifest.json` into the module\'s manifest editor (the form above it edits the same file), press **Test the call** with a real key filled in, and publish there. Publishing needs a test that came back on `success`, on the saved version.\n\n### 6. Hand over\n\nTell the user, briefly:\n\n- what you built and which kind of step it is;\n- the files, and what each field and output is;\n- what `vsa test` said (or what you could not test, and why);\n- the next steps, for code modules: push to GitHub; on https://developers.visualautomate.com create the plugin or app (and a **Write code** module), import the repository under **GitHub repository**, wait for the commit\'s check to pass **on success**, then publish. Private steps are usable at once; public ones are reviewed first.\n- for declared modules: on the portal, **New module \u2192 Call an API**, paste the `manifest.json`, **Test the call**, publish. They are never published from GitHub.\n\n## Hard rules\n\nThese are enforced by the platform. Breaking one means the step is refused or fails for every user.\n\n1. **No `import`, anywhere in `index.js`**, not even in a comment or a type import. CommonJS only: `module.exports`, `require`.\n2. **`require` only the allowed npm packages**, by bare name (`lodash`, not `lodash/get`), with a literal string. No `fs`, `http`, `child_process`, `os`, `path`. The list is in `references/sandbox.md`.\n3. **None of the refused words**, even in comments or strings: `eval(`, `Function(`, `new Function`, `process.env`, `process.exit`, `globalThis`, `Reflect`, `Proxy`, `constructor`, `__proto__`, `require.cache`, `WebAssembly` and the others in `references/sandbox.md`. Name variables so they do not contain these as whole words (`ctorName`, not `constructor`).\n4. **No connected accounts.** Only VisualAutomate\'s own apps act as somebody\'s account: no `connection` property, `optionsFrom`, `action.connection`, `trigger` or `auth.provider`, and `auth` is `{ "type": "none" }` or left out. A step reaches an API at its own address, with the user\'s key from a `secret` property in a header.\n5. **A declared `action` names its host in `baseUrl`**, `https://` only; it may read `config` but never `input`, and the request path is relative to it. A manifest may not contain `url`, `baseUrl`, `base_url` or `host` in `auth`.\n6. **No secrets in code or in the repository.** A user\'s own key is a `secret` property; a key the plugin itself uses on everybody\'s behalf comes from `context.secrets`.\n7. **Return `{ output, data }`** where `output` is one of the manifest\'s `outputs`. Expected failures go out by `"error"` with a readable `data.error`; do not throw for them.\n8. **Nothing waits.** A step runs when the workflow reaches it and returns; there is no polling loop and no trigger of your own.\n9. **Stay inside the limits**: `timeoutMs` at most 60000 (default 10000 on a canvas, 30000 in the push check), `memoryLimitMb` at most 128 (default 32), state values at most 128 KB each with `ttlSeconds` at least 60, `index.js` at most 100,000 characters.\n10. **Only the platform\'s globals.** No `Buffer`, `process`, `globalThis`, `structuredClone`, `setImmediate`; `console` has `log`, `error`, `warn` and `info` only. End `index.js` with a newline.\n11. **Export what the runner accepts**: `module.exports = { execute }`, `module.exports.execute = fn`, or a function. A `default` that is an object is not found.\n\n## What makes a good step\n\n- **The description is a sentence a user picks from**, starting with a verb: "Create an invoice", "List open tickets".\n- **Labels are words, not keys**: "Customer email", not `customerEmail`. Add a `description` where a field needs explaining, and a `group` when there are more than five.\n- **Defaults make the first run work**: a sensible `limit`, a default `select` option.\n- **Outputs are flat and few**: `id`, `url`, `items` and `count` beat the whole raw response. Describe each one in `outputFields`; that is what the next step\'s user sees.\n- **Error messages say what to do**: "Choose a project on this step." beats "Bad request".\n- **Read the body as text before parsing it.** An API having a bad day answers with HTML.\n- **Lists stay small**: a declared step with `items` has a `limit`; a code step that pages through results stops at a sensible maximum.\n- **Logs explain, they do not dump**: `console.log("found 12 invoices")`, never a customer\'s record or a key.\n' };
2887
2542
 
2888
2543
  // src/ai-files.ts
2889
2544
  var SKILL_NAME = "visualautomate-steps";
@@ -2912,8 +2567,8 @@ Before writing or changing a step, read the full guide: \`.claude/skills/${SKILL
2912
2567
  2. The word \`import\` may not appear anywhere in \`index.js\`, comments included. Neither may \`eval(\`, \`Function(\`, \`process.env\`, \`globalThis\`, \`Reflect\`, \`Proxy\`, \`constructor\`, \`__proto__\` or \`WebAssembly\`.
2913
2568
  3. \`require\` only allowed npm packages, by bare name and with a string literal. No \`fs\`, \`http\`, \`os\`, \`child_process\` or \`Buffer\`; use \`context.storage\`, \`fetch\` and \`Uint8Array\`.
2914
2569
  Steps run on Node ${PLATFORM_NODE_MAJOR} in a context with only the platform's globals: no \`process\`, \`globalThis\`, \`structuredClone\` or \`setImmediate\`, and \`console\` has \`log\`, \`error\`, \`warn\` and \`info\` only. End \`index.js\` with a newline.
2915
- 4. Never read or hold a credential. A connected account is \`context.connections.get("<connection property>")\` and \`request({ path })\` with a path relative to the provider.
2916
- 5. A user's own key is a \`secret\` property; a key the plugin holds is \`context.secrets\`. Nothing secret goes in the repository.
2570
+ 4. No connected accounts: only VisualAutomate's own apps act as somebody's account, so there is no \`connection\` property, \`optionsFrom\`, \`action.connection\`, \`trigger\` or \`auth.provider\`, and \`vsa test\` refuses them. Call the API at its own address (\`action.baseUrl\`, or \`fetch\` in code).
2571
+ 5. A user's own key is a \`secret\` property, sent as e.g. \`Authorization: Bearer {{config.apiKey}}\`; a key the plugin holds is \`context.secrets\`. Nothing secret goes in the repository.
2917
2572
  6. Return \`{ output, data }\`, where \`output\` is one of the manifest's \`outputs\`. Expected failures leave by \`"error"\` with a readable \`data.error\`.
2918
2573
  7. Limits: \`timeoutMs\` at most 60000 (the push check gives 30000 when the manifest names none), \`memoryLimitMb\` at most 128, a state value at most 128 KB, \`index.js\` at most 100,000 characters.
2919
2574
 
@@ -2921,10 +2576,10 @@ Before writing or changing a step, read the full guide: \`.claude/skills/${SKILL
2921
2576
 
2922
2577
  - \`vsa test\` is the push check, here (\`vsa test <module>\` for one module of an app): it checks every manifest, and loads and runs every code step the way the platform does. Fix the code until it passes; never weaken the check.
2923
2578
  - A run must leave by the test file's \`expectOutput\`, which is \`success\` when it names none.
2924
- - Locally there is no connected account: \`context.connections\` is absent, so check for it. \`context.media\` only runs on the platform.
2925
- - Declared steps are checked by \`vsa test\`, then pasted into the portal, run with Try it, and published there.
2579
+ - \`context.media\` only runs on the platform.
2580
+ - Declared steps are checked by \`vsa test\`, then pasted into the portal, run with Test the call, and published there.
2926
2581
 
2927
- Publishing is done by the developer, from GitHub, on https://developers.visualautomate.com, and needs one push whose test came back on \`success\`. For a step that acts as an account, that means the id of a connected account under \`config\` in visualautomate.test.json (the module's page on the portal lists them).
2582
+ Publishing is done by the developer, from GitHub, on https://developers.visualautomate.com, and needs one push whose test came back on \`success\`. A key the test needs goes in the plugin's \`context.secrets\` on the portal, never in a committed test file.
2928
2583
  `;
2929
2584
  }
2930
2585
  var CLAUDE_TEXT = `${AGENTS_MARKER}
@@ -2972,7 +2627,7 @@ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync
2972
2627
  import { basename, dirname as dirname2, join as join2 } from "node:path";
2973
2628
 
2974
2629
  // src/ambient-declarations.ts
2975
- var AMBIENT_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\ndeclare interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\ndeclare interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\ndeclare interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\ndeclare interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\ndeclare interface PluginStorage {\n /**\n * Write a file. Bytes are a string or a Uint8Array: there is no `Buffer` in a\n * step\'s context.\n */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * A link on VisualAutomate\'s own domain that opens the file, for somebody\n * with no account: a mail, a message, a page. The storage bucket\'s own\n * address (signedUrl) is right for an API that fetches the file; this one is\n * right for a person. Fifteen minutes unless `expiresInSeconds` says otherwise.\n */\n shareUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\ndeclare interface PluginState {\n /** The stored value, or `null` when the key has never been set or has expired. */\n get(key: string): Promise<unknown>\n /**\n * Store a JSON value, at most 128 KB once encoded. With `ttlSeconds`, which\n * must be at least 60, it is forgotten after that many seconds.\n */\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n /**\n * The keys under a prefix, each with when it expires (`null` for never).\n * `complete` is false when there were more than one call returns.\n */\n list(prefix?: string): Promise<{ keys: Array<{ key: string; expiresAt: string | null }>; complete: boolean }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\ndeclare interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\ndeclare interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\ndeclare interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\ndeclare interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\ndeclare interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\ndeclare interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /** The accounts this step was configured with. Absent when it has none. */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\ndeclare interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\ndeclare interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\ndeclare type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\ndeclare type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\ndeclare interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\ndeclare type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n /**\n * A key the user types in: an API key, a token. A string to your code and to\n * a declared call (`Bearer {{config.apiKey}}`), and masked wherever it is\n * shown, because it is the one value on a step that somebody looking over a\n * shoulder must not be able to read off.\n */\n | "secret"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\ndeclare interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n /** Longer help, shown on request rather than under the input. */\n help?: string\n /** Shown but not editable. */\n disabled?: boolean\n /**\n * For `select` and `multiselect`: read the options from the user\'s account\n * instead of listing them, e.g. the channels of the chosen Slack workspace.\n */\n optionsFrom?: OptionsSource\n /**\n * For `secret`: the properties that say where this secret is sent, such as\n * a server URL. A stored secret is only kept when those are unchanged, so\n * pointing the URL somewhere else cannot carry the key along.\n */\n sentTo?: string[]\n /**\n * For `secret`: the value is sent exactly as typed and never computed with,\n * which lets the platform keep it out of the step\'s code entirely.\n */\n verbatim?: boolean\n}\n\n/**\n * Where a select\'s options come from: one GET as the account in `connection`,\n * a list in the answer, and what each option shows and stores.\n */\ndeclare interface OptionsSource {\n /** The `connection` property whose account the options are read as. */\n connection: string\n request: { path: string; query?: Record<string, unknown>; headers?: Record<string, string> }\n /** Where the list is in the answer (`""` when the answer is the list), and how many to offer. */\n items: { path: string; limit?: number }\n paginate?: Record<string, unknown>\n /** What each option shows, e.g. `{{item.name}}`. */\n label: string\n /** What choosing it stores, e.g. `{{item.id}}`. */\n value: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\ndeclare interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\ndeclare interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\ndeclare interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\ndeclare interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\ndeclare interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\ndeclare interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\ndeclare type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\ndeclare interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\ndeclare type PluginSchema = PluginManifest\n';
2630
+ var AMBIENT_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\ndeclare interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\ndeclare interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\ndeclare interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\ndeclare interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\ndeclare interface PluginStorage {\n /**\n * Write a file. Bytes are a string or a Uint8Array: there is no `Buffer` in a\n * step\'s context.\n */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * A link on VisualAutomate\'s own domain that opens the file, for somebody\n * with no account: a mail, a message, a page. The storage bucket\'s own\n * address (signedUrl) is right for an API that fetches the file; this one is\n * right for a person. Fifteen minutes unless `expiresInSeconds` says otherwise.\n */\n shareUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\ndeclare interface PluginState {\n /** The stored value, or `null` when the key has never been set or has expired. */\n get(key: string): Promise<unknown>\n /**\n * Store a JSON value, at most 128 KB once encoded. With `ttlSeconds`, which\n * must be at least 60, it is forgotten after that many seconds.\n */\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n /**\n * The keys under a prefix, each with when it expires (`null` for never).\n * `complete` is false when there were more than one call returns.\n */\n list(prefix?: string): Promise<{ keys: Array<{ key: string; expiresAt: string | null }>; complete: boolean }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\ndeclare interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\ndeclare interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\ndeclare interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\ndeclare interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\ndeclare interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\ndeclare interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /**\n * The accounts this step was configured with. Absent when it has none.\n *\n * @deprecated Only VisualAutomate\'s own apps act as a connected account, so\n * a step you build never has this: the platform refuses a step that names\n * an account. Take the user\'s key in a property of type "secret" and send\n * it to the API\'s own host with fetch.\n */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\ndeclare interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\ndeclare interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\ndeclare type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\ndeclare type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\ndeclare interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\ndeclare type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n /**\n * A key the user types in: an API key, a token. A string to your code and to\n * a declared call (`Bearer {{config.apiKey}}`), and masked wherever it is\n * shown, because it is the one value on a step that somebody looking over a\n * shoulder must not be able to read off.\n */\n | "secret"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\ndeclare interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n /** Longer help, shown on request rather than under the input. */\n help?: string\n /** Shown but not editable. */\n disabled?: boolean\n /**\n * For `select` and `multiselect`: read the options from the user\'s account\n * instead of listing them, e.g. the channels of the chosen Slack workspace.\n */\n optionsFrom?: OptionsSource\n /**\n * For `secret`: the properties that say where this secret is sent, such as\n * a server URL. A stored secret is only kept when those are unchanged, so\n * pointing the URL somewhere else cannot carry the key along.\n */\n sentTo?: string[]\n /**\n * For `secret`: the value is sent exactly as typed and never computed with,\n * which lets the platform keep it out of the step\'s code entirely.\n */\n verbatim?: boolean\n}\n\n/**\n * Where a select\'s options come from: one GET as the account in `connection`,\n * a list in the answer, and what each option shows and stores.\n */\ndeclare interface OptionsSource {\n /** The `connection` property whose account the options are read as. */\n connection: string\n request: { path: string; query?: Record<string, unknown>; headers?: Record<string, string> }\n /** Where the list is in the answer (`""` when the answer is the list), and how many to offer. */\n items: { path: string; limit?: number }\n paginate?: Record<string, unknown>\n /** What each option shows, e.g. `{{item.name}}`. */\n label: string\n /** What choosing it stores, e.g. `{{item.id}}`. */\n value: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\ndeclare interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\ndeclare interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\ndeclare interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\ndeclare interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\ndeclare interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\ndeclare interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\ndeclare type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\ndeclare interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\ndeclare type PluginSchema = PluginManifest\n';
2976
2631
 
2977
2632
  // src/repo-types.ts
2978
2633
  var TYPES_FILE = "visualautomate.d.ts";
@@ -3052,7 +2707,7 @@ var EXAMPLES = `
3052
2707
  // receives strict JSON.
3053
2708
  //
3054
2709
  // PROPERTY TYPES: string, number, boolean, select, multiselect, json,
3055
- // color, date, connection, file, path, paths.
2710
+ // color, date, secret, file, path, paths.
3056
2711
  //
3057
2712
  // "properties": {
3058
2713
  // "mode": {
@@ -3066,7 +2721,7 @@ var EXAMPLES = `
3066
2721
  // ]
3067
2722
  // },
3068
2723
  // "retries": { "label": "Retries", "type": "number", "min": 0, "max": 5, "default": 2 },
3069
- // "account": { "label": "Account", "type": "connection", "provider": "outlook", "required": true },
2724
+ // "apiKey": { "label": "API key", "type": "secret", "required": true, "description": "Each user enters their own" },
3070
2725
  // "attachment": { "label": "Attachment", "type": "file", "description": "Read into config before the step runs -- right for a PDF" },
3071
2726
  // "clip": { "label": "Clip", "type": "path", "description": "Named, not read. What an ffmpeg step takes." },
3072
2727
  // "advanced": { "label": "Raw options", "type": "json", "group": "Advanced" }
@@ -3087,19 +2742,9 @@ var EXAMPLES = `
3087
2742
  //
3088
2743
  // "timeoutMs": 60000,
3089
2744
  //
3090
- // A TRIGGER makes this a starting point: the platform polls for you and
3091
- // starts the workflow once per new item, so nothing is billed for waiting.
3092
- // "connection" must name one of your own properties of type "connection".
3093
- //
3094
- // "trigger": {
3095
- // "connection": "account",
3096
- // "poll": 5,
3097
- // "request": { "method": "GET", "path": "/me/messages", "query": { "$top": "25" } },
3098
- // "items": "value",
3099
- // "id": "id",
3100
- // "cursor": { "from": "response", "path": "@odata.deltaLink" },
3101
- // "hydrate": { "path": "/me/messages/{id}" }
3102
- // }
2745
+ // NO ACCOUNTS. Only VisualAutomate's own apps act as somebody's connected
2746
+ // account, so there is no "connection" property and no "trigger" here: call
2747
+ // the API at its own address with the key in a "secret" property.
3103
2748
  `;
3104
2749
  function manifestFile(active) {
3105
2750
  const body = JSON.stringify(active, null, 2);
@@ -3161,9 +2806,8 @@ var httpRequest = {
3161
2806
  * A step that calls an API the user holds their own key for.
3162
2807
  *
3163
2808
  * The key is a \`password\` property, so it is filled in on the canvas and never
3164
- * lives in this code. If you are integrating a provider the platform already
3165
- * connects to, use the "connection" template instead \u2014 then nobody pastes a key
3166
- * at all.
2809
+ * lives in this code. Only VisualAutomate's own apps act as a connected
2810
+ * account, so this is how a step of yours reaches any API.
3167
2811
  */
3168
2812
  const plugin: PluginModule = {
3169
2813
  async execute(input, config) {
@@ -3229,87 +2873,6 @@ export default plugin
3229
2873
  }),
3230
2874
  testInput: { config: { url: "https://api.github.com/repos/nodejs/node" } }
3231
2875
  };
3232
- var connection = {
3233
- description: "Act on an account the user already connected.",
3234
- category: "integration",
3235
- code: `import type { PluginModule } from "./visualautomate"
3236
-
3237
- /**
3238
- * A step that acts as one of the user's connected accounts.
3239
- *
3240
- * You never see a token and you never name a host. You name a path, and the
3241
- * platform attaches the credential on the way past \u2014 which is what stops a
3242
- * plugin pointing somebody's account at an address of its own choosing.
3243
- *
3244
- * \`connections.get\` takes the name of one of YOUR properties ("account"
3245
- * below), not a connection id, so this step can only reach the account it was
3246
- * configured with.
3247
- */
3248
- const plugin: PluginModule = {
3249
- async execute(input, config, context) {
3250
- if (!context.connections) {
3251
- return { output: "error", data: { error: "No account is connected to this step." } }
3252
- }
3253
-
3254
- const account = await context.connections.get("account")
3255
-
3256
- const response = await account.request({
3257
- method: "GET",
3258
- path: (config.path as string) || "/me/messages",
3259
- query: { $top: String(config.limit ?? 10) },
3260
- })
3261
-
3262
- if (!response.ok) {
3263
- return {
3264
- output: "error",
3265
- data: { error: \`The provider answered \${response.status}\`, status: response.status },
3266
- }
3267
- }
3268
-
3269
- const body = response.body as { value?: unknown[] }
3270
- return {
3271
- output: "success",
3272
- data: { items: body.value ?? [], count: (body.value ?? []).length },
3273
- }
3274
- },
3275
- }
3276
-
3277
- export default plugin
3278
- `,
3279
- manifest: manifestFile({
3280
- description: "Read from a connected account",
3281
- icon: "link",
3282
- auth: { type: "oauth2", provider: "outlook" },
3283
- properties: {
3284
- account: {
3285
- label: "Account",
3286
- type: "connection",
3287
- provider: "outlook",
3288
- required: true,
3289
- description: "The account this step acts as"
3290
- },
3291
- path: {
3292
- label: "Path",
3293
- type: "string",
3294
- required: false,
3295
- default: "/me/messages",
3296
- description: "Relative to the provider's base URL. Never a full URL."
3297
- },
3298
- limit: { label: "How many", type: "number", required: false, default: 10, min: 1, max: 100 }
3299
- },
3300
- inputs: ["input"],
3301
- outputs: ["success", "error"],
3302
- outputFields: {
3303
- items: "What the provider returned",
3304
- count: "How many items came back"
3305
- }
3306
- }),
3307
- testInput: {
3308
- config: { path: "/me/messages", limit: 2 }
3309
- // context.connections cannot be simulated honestly, so `test` refuses
3310
- // it. Stub it in a test of your own to exercise the code around it.
3311
- }
3312
- };
3313
2876
  var file = {
3314
2877
  description: "Work on a file in the account's storage.",
3315
2878
  category: "utility",
@@ -3383,7 +2946,6 @@ export default plugin
3383
2946
  var TEMPLATES = {
3384
2947
  blank,
3385
2948
  "http-request": httpRequest,
3386
- connection,
3387
2949
  file
3388
2950
  };
3389
2951
 
@@ -3462,7 +3024,6 @@ var PROPERTY_TYPES2 = [
3462
3024
  "secret",
3463
3025
  "color",
3464
3026
  "date",
3465
- "connection",
3466
3027
  "file",
3467
3028
  "path",
3468
3029
  "paths"
@@ -3524,12 +3085,6 @@ function buildProperty(spec) {
3524
3085
  }
3525
3086
  property.options = options;
3526
3087
  }
3527
- if (spec.type === "connection") {
3528
- if (!spec.provider) {
3529
- throw new Error("A connection needs the provider it is for: --provider outlook.");
3530
- }
3531
- property.provider = spec.provider;
3532
- }
3533
3088
  if (spec.required) property.required = true;
3534
3089
  if (spec.value !== void 0) property.default = defaultFor(spec.type, spec.value);
3535
3090
  if (spec.description?.trim()) property.description = spec.description.trim();
@@ -4090,7 +3645,7 @@ function runInDocker(run) {
4090
3645
  }
4091
3646
 
4092
3647
  // src/index.ts
4093
- var VERSION = true ? "0.7.0" : "0.0.0-dev";
3648
+ var VERSION = true ? "0.8.0" : "0.0.0-dev";
4094
3649
  function fail(message) {
4095
3650
  console.error(red(message));
4096
3651
  throw new SilentExit();
@@ -4249,15 +3804,10 @@ ${bold(`\u25B8 ${target.name}`)}`);
4249
3804
  for (const warning of shape.warnings) say(yellow(`! ${warning}`));
4250
3805
  if (!shape.ok) return false;
4251
3806
  if (target.entry === null) {
4252
- if (shape.kind === "trigger") {
4253
- say(`${green(bold("\u2713 checked"))} ${dim("a trigger: the manifest is sound")}`);
4254
- say(dim(" Not run here. A push publishes only a module with an index.js that passes its test, so this one cannot be published from GitHub."));
4255
- return true;
4256
- }
4257
3807
  const what = shape.kind === "outputs-only" ? "a declared step with no request" : "a declared step";
4258
3808
  say(`${green(bold("\u2713 checked"))} ${dim(`${what}: the manifest is sound`)}`);
4259
3809
  say(dim(" Not run here, and not published from GitHub. On the developer portal: New module \u2192 Call an API,"));
4260
- say(dim(" paste this manifest.json into the module's manifest.json editor, press Try it, then publish it there."));
3810
+ say(dim(" paste this manifest.json into the module's manifest.json editor, press Test the call, then publish it there."));
4261
3811
  return true;
4262
3812
  }
4263
3813
  const code = await codeFor(target);
@@ -4275,11 +3825,6 @@ ${bold(`\u25B8 ${target.name}`)}`);
4275
3825
  }
4276
3826
  const outputs = Array.isArray(manifest.outputs) ? manifest.outputs : ["success", "error"];
4277
3827
  const expected = settings.expectOutput ?? PUSH_CHECK.defaultExpectOutput;
4278
- const properties = manifest.properties ?? {};
4279
- const accountProperties = Object.keys(properties).filter((name) => properties[name]?.type === "connection");
4280
- const config = settings.config ?? {};
4281
- const named = accountProperties.filter((name) => typeof config[name] === "string" && String(config[name]).trim());
4282
- const unnamed = accountProperties.filter((name) => !named.includes(name));
4283
3828
  const persist = persistEnabled(target.dir, flags.persist);
4284
3829
  const kept = persist ? await loadPersisted(target.dir) : { storage: {}, state: {} };
4285
3830
  const policy = policyFor(settings.allowedDomains, settings.config);
@@ -4323,14 +3868,6 @@ ${bold(`\u25B8 ${target.name}`)}`);
4323
3868
  enforced ? `${dim("egress")} ${red(`blocked ${host}`)} \u2014 not in allowedDomains` : yellow(`! egress ${host} is not allowed on the platform unless the plugin declares it. `) + `Add "allowedDomains": ["${host}"] to the test file to check with the same rule.`
4324
3869
  );
4325
3870
  }
4326
- if (named.length > 0) {
4327
- say(dim(`account the test file names an account for ${named.join(", ")}. The push check acts as it; here there is none, so the step ran without it.`));
4328
- }
4329
- if (unnamed.length > 0 && expected === "success") {
4330
- say(yellow(
4331
- `! ${unnamed.join(", ")} ${unnamed.length > 1 ? "are account properties" : "is an account property"} the test file names no account for. The push check then runs without an account, and publishing needs a run that comes back on success. Put the id of one of your connected accounts under config (the module's page on the portal lists them).`
4332
- ));
4333
- }
4334
3871
  if (passed && expected !== "success") {
4335
3872
  say(dim("note a pass that is not on success does not count for publishing: that needs one push whose test comes back on success."));
4336
3873
  }
@@ -4475,6 +4012,9 @@ async function cmdAddProperty(args, flags) {
4475
4012
  Types: ${PROPERTY_TYPES2.join(", ")}`
4476
4013
  );
4477
4014
  }
4015
+ if (type === "connection") {
4016
+ fail(`There is no connection property for your steps. ${ACCOUNTS_ARE_OURS}.`);
4017
+ }
4478
4018
  if (!isPropertyType(type)) {
4479
4019
  fail(`There is no "${type}" property. Types: ${PROPERTY_TYPES2.join(", ")}`);
4480
4020
  }
@@ -4506,8 +4046,7 @@ Types: ${PROPERTY_TYPES2.join(", ")}`
4506
4046
  value,
4507
4047
  label: flags.label,
4508
4048
  required: flags.required === "true",
4509
- options: flags.options,
4510
- provider: flags.provider
4049
+ options: flags.options
4511
4050
  });
4512
4051
  next = withMember(text, "properties", name, property);
4513
4052
  } catch (error) {
@@ -4619,7 +4158,6 @@ Options for \`add:property\`:
4619
4158
  --required the workflow will not run without it
4620
4159
  --label <text> instead of one made from the name
4621
4160
  --options <list> for select and multiselect: "Fast:fast,Thorough:thorough"
4622
- --provider <name> for connection: whose account this is
4623
4161
 
4624
4162
  Property types: ${PROPERTY_TYPES2.join(", ")}
4625
4163