@flow-as-code/cli

Install with npm i -D @flow-as-code/cli. Apache-2.0, Node 22.12 or newer. Installing it brings the other five, so npx flow-cli works straight away. npx flow-cli init flows writes a document to open, and npx flow-cli studio flows opens it. examples/promote-across-environments/ is a worked run of lint and emit against one document and two environments.

Site and docs: https://flow-as-code.dev/. This package on npm: https://www.npmjs.com/package/@flow-as-code/cli.

flow-cli init [dir] [--author ts|tf]                     scaffold a directory to open
        flow-cli lint <dir-or-file...> [--format text|json]     rule set over each document set
        flow-cli render <dir-or-file> --resources map.json      standalone materialization
        flow-cli codegen <doc.flowdoc.json> [--to ts|tf] [--out <file>] [--banner <text>]  FlowDoc -> companion
        flow-cli synth <file.flow.ts|file.flow.tf> [--out <dir>] companion -> FlowDoc (TS in a sandboxed child)
        flow-cli convert <doc.flowdoc.json> --to ts|tf [--address-map <file>] [--keep-old] [--force]
        flow-cli emit <dir-or-file...> --target cdk|flowascode|tf [--address-map refs.tfmap.json] [--instance-id-expression <expr>] [--allow-unbound] [--strict] [--module-alias module:name@alias]...
        flow-cli diff <dir> --instance <arn>                     local FlowDocs vs the live instance
        flow-cli export --instance <arn> [--out <dir>] [--author ts|tf] [--no-codegen] [--on-error abort|collect]
        flow-cli simulate <scenarios> --instance <arn> [--resource-map <file>] [--format junit|json] [--out <file>]
                 simulate --dry-run <scenarios> <flows...> [--resource-map <file> | --address-map <file>]  offline checks, no instance
        flow-cli studio [dir] [--port <port>]                    local visual editor, live sync both ways
        

Install-script warning for esbuild

A fresh npm install on npm 11 prints

npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:
        npm warn install-scripts   esbuild@0.28.2 (postinstall: node install.js)
        

The chain is @flow-as-code/cli -> tsx -> esbuild: flow-cli synth runs a .flow.ts builder file in a child process with tsx registered through --import, and tsx is built on esbuild, whose postinstall fetches its platform binary. The dependency stays a regular one, because every synth, convert --to ts and studio save of a .flow.ts needs it and an optional dependency would turn the first synth into a run-time error about a missing loader. The warning is npm 11's script allowlisting (https://docs.npmjs.com/cli/v11/commands/npm-install-scripts) and it is answered once per project:

npm install-scripts approve esbuild
        

which records the approval in package.json and runs the script. Checked against npm 11.19.1 on 2026-10-05.

Availability

Every command above is wired, so --help is the complete surface; a test in src/cli.test.ts holds --help and the usage block above to the same list.

Command Status Notes
init available One demo FlowDoc plus its .flow.ts; refuses to overwrite
lint available Whole set in one pass, so cross-document rules resolve module references
codegen available .flow.ts or .flow.tf; @keep comments in an existing output file survive
synth available A .flow.ts in a sandboxed child process (see below); a .flow.tf parsed
convert available Switches a document's companion; says what the new one does not carry
render available Strict resource map; every unmapped token is listed at once
emit available tf and flowascode write Terraform, cdk writes a scaffold
export available Live instance to FlowDoc pairs; collects failures by default
simulate available Scenario suite through the TestCase API, JUnit or JSON report
diff available Local documents against the live instance; exit 2 when it cannot tell
studio available Local bridge on 127.0.0.1 serving the studio, with live sync both ways

Exit codes: 0 on success, 1 for a failure (a missing path, malformed JSON, a schema-invalid document, an unmapped token, an unknown flag value, a lint finding with error severity, a flow that could not be exported, a scenario that did not pass, or a document that differs from the instance). diff alone also uses 2, for an error in the comparison itself, so a script can tell "differs" from "could not tell".

Importing it

The command lives in dist/bin.js and nothing else loads it, so importing the package never parses argv or exits the host process. The package root is a library entry, the union of the four subpaths:

import { synthFile, createWatcher, startStudioServer, scenarioProblems } from "@flow-as-code/cli";
        // or, narrower:
        import { synthFile } from "@flow-as-code/cli/synth";
        import { createWatcher } from "@flow-as-code/cli/watch";
        import { startStudioServer } from "@flow-as-code/cli/bridge";
        import { scenarioProblems, dryRunSimulate } from "@flow-as-code/cli/simulate";
        

@flow-as-code/cli/simulate is what a consumer's own tests reach for to check a scenario suite without an instance: scenarioProblems(value) is the schema plus cross-field validation the command runs on every scenario file, as a list of strings, empty when the value is a valid scenario; loadScenarios(path) reads and validates a file or directory the way the command does; dryRunSimulate(scenarios, flows, { resourceMap | addressMap }) is the whole dry run below, throwing a CliError that lists every problem. The set-level check itself, dryRunScenario(scenario, docs, { resourceMap }), is @flow-as-code/core's and takes parsed objects, for a test that already has them in hand.

src/index.test.ts imports the built root in a child process and fails if it writes anything or exits, and holds the root's exports equal to the union of the four subpaths.

Connecting to an instance

export, simulate, and diff take --instance <arn>, the instance ARN as the console's account overview shows it (https://docs.aws.amazon.com/connect/latest/adminguide/find-instance-arn.html). Only an instance ARN is accepted: a bare instance id carries no Region, and a resource ARN names the wrong thing.

The Region is read from the ARN. Nothing consults AWS_REGION or a profile's region, so an instance in one Region is never addressed through a client configured for another (https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html).

Credentials come from the AWS SDK's default provider chain: environment variables, the shared config and credentials files with their profiles and SSO sessions, web identity, and the container and instance metadata endpoints (https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html). The CLI takes no credential flags and stores nothing. Pick a profile with AWS_PROFILE as you would for the AWS CLI.

@aws-sdk/client-connect is an optional peer dependency, loaded only when one of these three commands runs, so the offline commands do not pay for it. When it is not installed the command exits 1 with one line naming the package and the install command, no stack trace. An SDK that is installed but will not load (a dependency of its own missing, a broken install) is a different failure and is reported as @aws-sdk/client-connect could not be loaded: with the loader's reason, since the install command is not the fix. simulate also checks that the installed SDK exposes the TestCase operations, which are recent, and names the version that has them all when it does not.

@aws-sdk/client-qconnect is a second optional peer, read by export and diff when it is installed: it lists the account's AI agents assistants (Amazon Q in Connect ListAssistants), so a CreateWisdomSession exports its assistant as an assistant reference. Without it the command runs as before, names no assistant, and leaves an assistant ARN in flow content literal, which export reports as it does any ARN it cannot name. It needs wisdom:ListAssistants beside the Connect read permissions.

@aws-sdk/client-connectcases is a third, read the same way: it finds the Cases domain the instance's CASES_DOMAIN integration association names and lists its templates and fields (Cases ListTemplates and ListFields), so a CreateCase, GetCase or UpdateCase exports its template and the domain's own fields as casetemplate and casefield references. A flow holds them by bare id, not ARN, and export names an id only where the catalog says a template or field stands; a system field such as summary stays literal, since it is the same on every domain. Without the package, or on an instance with no Cases domain, the ids stay literal, which the service accepts and codegen keeps as a generic block. It needs connect:ListIntegrationAssociations, cases:ListTemplates and cases:ListFields beside the Connect read permissions.

Every other command works with no SDK installed and makes no network calls.

init

flow-cli init [dir] writes the smallest complete thing to open, so the npm path has a first step:

npm i -D @flow-as-code/cli
        npx flow-cli init flows
        npx flow-cli studio flows
        

Three files, into the working directory or [dir]:

File What it is
appointment-line.flowdoc.json The demo FlowDoc, the same one the hosted studio demo opens
appointment-line.flow.ts Its paired builder file, generated by codegen
package.json Only when no package.json exists above the directory

The pair is written through the same path a canvas save takes, so the document carries the meta.sourceHash of the builder file beside it and the two open in sync rather than as a conflict on the first edit. The package.json declares "type": "module"; a directory that already sits inside a project gets none, because synth evaluates a builder file as ESM whatever the project declares and there is nothing to fix.

--author tf writes appointment-line.flow.tf, the document as a flowascode_contact_flow resource, in place of the builder file, and no package.json: HCL is parsed, not run. Its refs values start as null under TODO comments; bind them to your Terraform addresses.

A directory that already holds other files is fine. A directory that already holds one of the files this writes, or the other companion, is not: nothing is written at all and the command exits 1 naming every collision, since half a pair is worse than none.

Input and output conventions

lint, render, and emit take a directory or a single file. A directory contributes every *.flowdoc.json in it, sorted by name, and is not walked recursively. Every document is validated against the schema of the version its flowdoc field names (flowdoc-0.1.schema.json, flowdoc-0.2.schema.json or flowdoc-0.3.schema.json, byte copies of conformance/schema/ this package ships) and then migrated to the current version, before any command looks at it, so a malformed document fails with its own path named rather than deep inside an emitter.

--out defaults to the input directory for every command that writes, and codegen names its file after the document (appointment-line.flowdoc.json generates appointment-line.flow.ts), which is the inverse of what synth writes.

lint

Each argument is a set, and the whole set goes to @flow-as-code/core's lint in one call. Rules receive the other documents alongside their own, and the ones that follow module references or attribute writes cannot answer without them: module-depth-5 and attribute-set-before-read on a single file report nothing, correctly, because neither is computable from one document.

Several arguments are several sets, never one merged set. flow-cli lint flows/ seasonal/ lints exactly as two runs would, in one process: a module in seasonal/ does not resolve a reference in flows/, an attribute set in one does not cover a read in the other, and every finding names the set it came from. With one set the text report is @flow-as-code/core's; with more, each set's report follows its path and a line across sets closes it. Exit status follows severity, not count, and the worst across sets: any error exits 1, warnings alone exit 0; the failure line names the sets with errors.

--format json prints flow-lint-report/0.1 on stdout; the failure line goes to stderr, so the JSON stays clean for a pipe. Its schema is conformance/schema/lint-report-0.1.schema.json, and src/lint.test.ts holds the command's output to it. A change to the shape is a new id and a new schema; this one is:

Field What it holds
format "flow-lint-report/0.1"
summary { total, errors, warnings } across every set
sets[] One per argument, in argument order
sets[].set The argument, resolved to an absolute path
sets[].documents The names of the FlowDocs in that set, in file order
sets[].summary { total, errors, warnings } for that set
findings[] Every finding, set by set in argument order; within a set by document, rule, block, message
findings[].set The set it came from, as sets[].set spells it
findings[].rule The rule id, stable and never renamed
findings[].severity "error" or "warning"
findings[].doc The name of the FlowDoc
findings[].blockId The action Identifier, present when the finding is about one action
findings[].message The finding's sentence

A script that read summary and findings from the report before it had a format still reads them: those fields are where they were, and findings[] gained set.

Every document is schema-checked before any rule runs, so a document the schema rejects never reaches the report. A literal ARN is the one violation that is both a schema failure and a named rule, and the schema states it four ways per field without ever saying "literal ARN", so it is reported once per offending field as no-literal-arn: "<field>" contains a literal ARN. Schema errors the ARN did not cause are still listed.

render

Materializes each document with materializeWithMap and writes <doc name>.json. This is the one path where a literal ARN is the goal rather than a mistake, so map values are not linted. Completeness is enforced: a reference with no entry in the map is fatal, and every missing reference across every document is printed in one run, each with the key forms it would have taken.

Reference map keys

render --resources, simulate --resource-map, and emit --target tf --address-map read the same shape of file and accept the same three keys per reference, so one map's keys serve all three:

Key Where it comes from
${cdref:module:survey@prod} The token, exactly as the document writes it
module:survey@prod The token without its wrapper
module_survey_prod_arn The variable @flow-as-code/tf emits in flow_refs

The middle form is what @flow-as-code/tf writes into its own TODO comments and what examples/promote-across-environments/ uses, and it is the one to prefer: it is the shortest that still says which resource it means. render and simulate took only the token before 0.1.2, so a map written from an emit run was rejected by the next command.

The keys are shared; the values are not. render and simulate want the ARN of a resource that exists, and render is the one path where a literal ARN is the goal rather than a mistake. emit --target tf --address-map wants an HCL address expression and refuses a literal ARN, because an emitted flow that hardcodes one is the thing this tooling exists to stop. So a render map and an emit map are two files with the same keys.

emit

Each argument is a set and is emitted on its own, into its own directory, so module aliasing and reference resolution never reach across sets, exactly as lint keeps them apart: flow-cli emit flows/ seasonal/ --target flowascode writes flows/flows.tf and seasonal/flows.tf from their own documents, as two runs would. --out names one directory, so with more than one set it is refused rather than letting the second set overwrite the first; so are two sets that would resolve to the same directory. The paths printed are every set's, in argument order.

A run over several sets is two-phase: every set goes through its emitter first, and nothing is written unless every set passed, so an unbound reference or a refused --strict in the second set leaves the first set's directory as it was; the message names the set. One address map serves every set, so a key is unused only when no set uses it: a key flows/ binds and seasonal/ never mentions is not warned about, and --strict refuses only a key no set uses, once. --allow-unbound and --instance-id-expression apply to every set. --module-alias module:<name>@<alias> goes to the set(s) whose documents hold that module and is refused, once, naming the sets searched, when none does; with one set it is handed to the emitter as given, whose refusal names the module. outputs.tf is written per set, for that set's flows.

--target tf is a thin wrapper over @flow-as-code/tf, writing exactly the bytes the emitter returns and nothing of its own. --address-map is passed through as options.addressMap and takes the same three key forms ("Reference map keys" above); without one, unresolved references become the emitter's loud TODO_MISSING_ADDRESS_* placeholders, which fail terraform validate rather than deploying a broken flow. That placeholder is the flat target's whole answer to an unbound reference: the command exits 0 and validate is where the run stops, so --allow-unbound changes nothing on this target and is accepted so one flag list serves both.

--target flowascode writes the set for the flow-as-code/flowascode provider, exactly the bytes @flow-as-code/hcl's emitFlowascode returns: flows.tf with one resource per document and its actions as blocks, outputs.tf with an <name>_arn and an <name>_document_sha256 output per document, named by FlowDoc name, for a promotion gate to read (docs/tutorials/02-promote.md), variables.tf, and versions.tf.example. It takes the same address map. A reference it resolves nowhere is an error: the command exits 1, writes nothing, and lists each key with the documents that make it, because the null binding the emitter would write validates and is refused only at plan time, when the provider names the key. --allow-unbound writes the partial map anyway, each unbound key as null under a # TODO comment, and exits 0; that is the shape to review or to finish binding in the file, not one to apply. It never writes a <name>.flow.tf, so an emit into a directory flow-cli studio serves cannot create a companion. docs/06-terraform-provider.md says when to pick flowascode over tf.

On either target, an address map key that no reference in the set reaches by any of its three forms is a warning on stderr (warning: address map key "queue:apointments" matches no reference in the set), one line per key, and the command still exits 0: a typo in a key, or a key left behind when a flow stopped using it, is otherwise silent. --strict makes it an error, exiting 1 and writing nothing. A key the set reaches but resolves itself (a flow or module the set emits) is not unused; the emitter ignores its value and, on the flat target, says so in flow_refs.tf. Both flags are refused on --target cdk, which binds through a TokenBinder and takes no map.

On either target, a module gets a version and an alias resource for each alias a flow in the set invokes it through, and a module nothing in the set references gets none (on tf, a version and no alias): the emitter cannot know what a root elsewhere will bind. --module-alias module:greeting@live, repeatable, declares an alias the module publishes anyway, written exactly as for an invoked module, so a module released in a root of its own can be bound as module:greeting@live from another root's address map; on flowascode, outputs.tf adds greeting_live_arn for that root to read. The value is the key a flow binds, module:<name>@<alias>, slugs on both sides; a module the set does not emit is refused. The flag is refused on --target cdk. The emitted version and alias carry no description, so a root moving from a hand-written pair with descriptions (the cookbook's module-release.tf) to the emitted one sees a plan that drops them, an in-place update; keep the hand-written resources if the descriptions matter, or accept the change.

--instance-id-expression <expr> is the emitter's instanceIdExpression: the HCL expression every resource's instance_id takes. Without it the expression is var.connect_instance_id and variables.tf declares that variable; with it no variables.tf is written, so a root that declares connect_instance_id itself (or reads the instance from a data source or a remote state) takes the whole emitted tree instead of copying flows.tf out of it. The expression is checked the way an address map value is: not empty, not a literal ARN, one line, no comment marker.

flow-cli emit flows/ --target flowascode --instance-id-expression local.connect_instance_id
        flows/flows.tf
        flows/versions.tf.example
        

--target cdk is not a code generator, because @flow-as-code/cdk is a library: FlowSet reads the FlowDoc directory itself at synth time. What the command writes is flow-stack.ts, a compiling scaffold that constructs a FlowSet over the directory and declares a TokenBinder carrying one TODO per reference type the documents actually use, with the referenced names listed. Reference types the set does not use still get a method, because TokenBinder requires them, but theirs throws instead of carrying a TODO. Module references never appear: FlowSet resolves those itself against the modules it manages. Re-running the command overwrites the file, so move or rename it once you have filled it in.

The generator lives in @flow-as-code/cdk/scaffold, not here, because the studio's CDK export button has to produce the same bytes and cannot import this package (@flow-as-code/cli depends on @flow-as-code/studio for its built assets). What stays here is the path arithmetic: turning --out and the documents' directory into the relative FLOW_DOCS expression. The scaffold resolves that expression against its own file (new URL(..., import.meta.url)), so cdk synth works from the project root, where cdk.json normally lives, and not only from the directory the scaffold was written to.

codegen and convert

flow-cli codegen <doc> [--to ts|tf] [--banner <text>] writes the document's companion: <name>.flow.ts (builder TypeScript) or <name>.flow.tf (a flowascode_contact_flow resource, @flow-as-code/hcl's fromFlowDoc). Without --to it writes the kind the document's meta.sourceKind names, and ts when it names none. An existing output file is read first: its @keep comments survive, and a .flow.tf also keeps its refs bindings, instance_id, tags, state, lint, provider, lifecycle and depends_on. Writing the document's own companion (no --out, and the kind its meta.sourceKind names) also restamps the document's meta.sourceHash with the hash of what was written, so the pair stays in sync for the watcher. It refuses to write a companion beside one of the other kind; that is convert's job.

--banner <text> (TypeScript only) writes one extra comment line after the two fixed ones, for a generator that owns the document to say so:

// Generated by @flow-as-code/core codegen from main-line.flowdoc.json.
        // Edits to structure regenerate; comments marked @keep survive.
        // Written by generators/districts.ts from districts.config.json; edit the config, not this file.
        

A later run that omits the flag keeps the line, as it keeps @keep comments, so the generator stamps it once; --banner "" removes it. The same option is banner on @flow-as-code/core's codegen(), and conformance/codegen/ holds the cases. A .flow.tf carries no banner, and the flag is refused with --to tf.

The TypeScript codegen writes is left unchanged by Prettier 3 at this repository's settings (.prettierrc: double quotes, trailing commas, print width 100, arrowParens: always), so a consumer on those settings need not list the companions in .prettierignore. Lines are measured as Prettier measures them (packages/core/src/width.ts, a port of its display width: CJK and fullwidth text two columns, combining marks none), so a Japanese or Korean prompt is a fixed point too. Three emoji classes are known exceptions, measured differently from Prettier: a tag-sequence flag (England, Scotland, Wales), a ZWJ pair that is not a registered sequence, and a skin-tone modifier after a base that takes none; a prompt carrying one of those at the print width is reformatted by Prettier, and .prettierignore is the answer there. packages/core/src/roundtrip.test.ts formats every roundtrip case's output and asserts it comes back byte-identical, and src/codegen.test.ts does the same for every export golden. A consumer on other settings (a print width of 80, single quotes) formats the companions like any other file and the next codegen writes them back; the round trip through synth does not read formatting, so nothing is lost either way, but the diff is noise, and .prettierignore with **/*.flow.ts is the quiet option there.

flow-cli convert <doc> --to ts|tf switches which companion a document has. It writes the new one from the document, carrying @keep comments across (as # @keep in HCL and // @keep in TypeScript), restamps the document's meta.sourceKind and meta.sourceHash, and deletes the old companion unless --keep-old. It refuses when the new companion already exists, and when the old one has edits the document does not hold (its hash is not the document's meta.sourceHash): run synth on it first, or pass --force to discard them. What does not carry is printed as a note: on stderr: converting to tf binds only what --address-map gives (the rest is null under a TODO comment) and writes instance_id = var.connect_instance_id with no tags or lint settings; converting to ts drops the .flow.tf's bindings, instance_id, tags, state and lint settings, which TypeScript has no place for.

synth

flow-cli synth <file.flow.tf> reads a .flow.tf in process, parsing and never executing it, and writes the one document its resource holds with meta.sourceKind tf. A refs key no action uses, and a resource address in a reference field that is read as its key, are warnings on stderr. Everything below is about .flow.ts.

flow-cli synth <file.flow.ts> [--out <dir>] evaluates a TypeScript builder file and writes one <flow.name>.flowdoc.json per flow it exports, into --out or, by default, next to the source file. A flow is any exported Flow instance and the result of any exported zero-argument function that returns one. Output is byte-stable (@flow-as-code/core's canonical serializer) and carries meta.generator (cli@<version>) and meta.sourceHash (sha256:<hex> of the source file bytes).

A builder file does not need @flow-as-code/core installed beside it. The child resolves that import normally first, so a copy in the file's own node_modules wins; only when nothing resolves does it fall back to the copy flow-cli itself is running against. That is what lets a folder holding nothing but FlowDocs and the .flow.ts files codegen wrote next to them synth at all. The fallback covers @flow-as-code/core and nothing else, so a builder file cannot reach the rest of @flow-as-code/cli's dependencies through it.

The builder file itself is evaluated as ES module source whatever the enclosing package.json says, including an explicit "type": "commonjs" (which is what npm init -y writes). That is what codegen emits and the only module system that can import @flow-as-code/core, which ships ES modules only. The override covers that one file and not the project: a module the builder imports keeps its project's module system, so in a CommonJS project a helper .ts that imports @flow-as-code/core still fails, and synth says so by name. Set "type": "module" in the nearest package.json.

The builder file never runs in the CLI process. It runs in a child node with tsx registered via --import, a stripped environment (PATH plus a private temp dir for tsx), and cwd pinned to the source directory. Where the runtime has the stable --permission flag (Node 22.13+), Node's permission model is enabled: file writes are denied everywhere except tsx's private temp dir (the parent process writes the FlowDocs), and child processes are denied. File reads are not jailed, and on Node 22/24 the permission model does not cover network access at all; see the header comment in src/synth.ts for exactly what is and is not enforced, with citations.

diff

flow-cli diff <dir> --instance <arn> answers whether what is checked in matches what is deployed. Every *.flowdoc.json in <dir> is validated and paired with the live flow or module of the same kind and name, where the live name is the slug export derives from the console name. The live side goes through the same exporter, so it carries tokens rather than ARNs, and the two documents are compared as canonical JSON with layout and meta removed: positions and provenance are not differences anyone deploys.

One line per local document, in path order, then a unified diff of the canonical JSON under each changed one:

flows/appointment-line.flowdoc.json: changed
        --- local/flows/appointment-line.flowdoc.json
        +++ live/appointment-line
        @@ -13,7 +13,7 @@
        ...
        flows/recording-consent.flowdoc.json: unchanged
        flows/callback-offer.flowdoc.json: missing-live
        

Live flows with no local document are not reported. The same two states always print the same bytes, so the output can be compared across runs.

A live flow that has never been published has no published content, so the exporter reads its saved content through the $SAVED alias, the same way export does. The line says so, flows/draft-line.flowdoc.json: unchanged ($SAVED), and a diff under it is labelled +++ live/draft-line:$SAVED. Unchanged against a draft means the draft matches, not that anything is deployed.

Exit 0 when nothing differs, 1 when any document is changed or has no live counterpart, and 2 when the comparison itself failed: bad arguments (a missing --instance or an unknown flag included, where every other command exits 1), the SDK not installed, the instance unreadable, or a matched live flow the exporter could not convert (that document prints as error: with the reason, and the run still reports the rest).

export

flow-cli export --instance <arn> [--out <dir>] [--author ts|tf] [--no-codegen] [--on-error abort|collect] reads every flow and module in the instance and writes <name>.flowdoc.json plus its companion for each into --out, which defaults to the working directory and is created if needed. The companion is <name>.flow.ts, or <name>.flow.tf with --author tf, whose refs values are null until bound. A document whose other companion is already in --out, or whose existing companion does not parse, is reported as a failure rather than given a second companion or overwritten. --no-codegen writes the documents only. References come out as tokens, never ARNs, and the name is the slug the exporter derives from the console name (Appointment Line becomes appointment-line). A flow and a module that slug to the same name would share a file name; the second one is reported as a failure rather than overwriting the first.

Each pair is written the way synth and the studio write it: the document carries meta.sourceHash of the generated source, so the watch engine sees an exported pair as in sync, and an existing companion is read first so its @keep comments (and a .flow.tf's carried values) survive, as with codegen. A flow that has never been published is read through the documented $SAVED alias and marked as such in the summary.

stdout carries one line per document written and a closing count; stderr carries the exporter's warnings (warning: ..., for content it had to skip or could not fully represent) and one failed: <name> (<arn>): <reason> line per flow it could not export.

appointment-line (flow): appointment-line.flowdoc.json, appointment-line.flow.ts
        recording-consent (module): recording-consent.flowdoc.json, recording-consent.flow.ts
        Exported 2 of 3 to /work/flows
        

--on-error defaults to collect, which writes everything that exported and reports every failure at once. abort stops at the first failure and writes nothing. The default is collect because a fresh instance always holds the stock "Sample Lambda integration" flow, which calls a Lambda in an AWS-owned account that the instance's Lambda list cannot return; that is an unknown-ARN hard error by design (see the Export section of packages/core/SPEC.md), and aborting on it would make a first export of any new instance write nothing. Either mode exits 1 when any flow failed.

--on-error governs flows only. The inventory listing that precedes them (flows, modules, queues, hours, prompts, Lambda functions, bots) fails in either mode with the SDK's own message, an AccessDeniedException naming the missing action for instance, and nothing is written.

simulate

flow-cli simulate <scenarios> --instance <arn> [--resource-map <file>] [--format junit|json] [--out <file>] runs a scenario suite through the instance's TestCase operations (https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html) within the documented limits, 5 concurrent, 100 in flight, 5 minutes per scenario, and writes a report.

<scenarios> is one scenario file, or a directory. A directory contributes every scenario.json and *.scenario.json directly in it and one level down, which is the layout of conformance/simulate/ (one scenario.json per case directory); other files are ignored.

Everything that can fail offline fails before the instance is touched, and all at once. Every scenario is validated against scenario-0.1.schema.json, the byte copy of conformance/schema/scenario-0.1.schema.json this package ships, then against the cross-field rules the schema cannot express, and every ${cdref:...} token in the suite is resolved through --resource-map, a JSON object from reference to ARN in the same shape render takes, with the same three key forms ("Reference map keys" above). A suite with one broken scenario or one unmapped reference creates no test case.

The report goes to stdout, or to --out, in which case the path is printed instead. --format junit (the default) is the <testsuites> document CI systems ingest; --format json is the stable flow-simulate-report/0.1 document. Scenarios that did not pass are also described on stderr, one block per scenario with the failed observations, so a CI log says why without opening the report.

Exit 0 only when every scenario PASSED. FAILED, TIMED_OUT (the harness stops the execution when the 5 minutes are up), STOPPED, and ERRORED (an API error for that scenario) each exit 1, and the summary line says how many of the suite did not pass.

Dry run

flow-cli simulate --dry-run <scenarios> <flows...> [--resource-map <file> | --address-map <file>] checks the same suite against a set of FlowDocs (directories or files, read together as one set: a scenario runs across a flow and the modules it calls, wherever those live, so flows/ seasonal/ is one set here where lint and emit would keep them apart) with no instance, no credentials and no SDK: it is what a unit test or a CI job without an AWS account runs, and what to run before paying for a live run. Exit 0 means nothing offline says a scenario cannot pass; any problem exits 1 with every problem listed, one line per problem as <scenario file>: <path in the scenario>: <what is wrong>.

What it checks, after the validation the live run also does:

What it does not do, so a clean dry run is not a passed scenario: it does not execute Lambdas, evaluate conditions on attribute values, follow the contact through branches (every text in the set counts, whichever branch plays it), read recorded prompts (a PromptId plays audio whose words are not in the document, and a miss says how many there are), or model the speech-to-text transcript a voice MessageReceived is matched against, which drops punctuation and may spell numbers out. --format, --out and --instance belong to the live run and are refused with --dry-run, --format junit included: the dry run has no report to format. Two paths holding a document of the same kind and name are refused rather than silently merged.

conformance/simulate/dry-run/ is the contract: a two-document set, a map, and one case per thing the check can say, each with the exact problems it must report. docs/08-simulate.md puts the two runs side by side.

A scenario's expect-queue takes the queue's token (queue) as well as its console name (name). The token form compiles to an Assert on $.Queue.ARN, resolved through the resource map like every other token, so the map must hold the queue's ARN rather than a bare id for it. It has not yet been executed against an instance: the name form was, on 2026-09-01, and tasks/C14-simulate-offline-checks.md records the live run the token form waits on.

watch (library)

@flow-as-code/cli/watch exports createWatcher(dir), the engine behind studio sync (A11). It watches one directory (non-recursive) and pairs each <name>.flowdoc.json with its companion, <name>.flow.ts or <name>.flow.tf, by base name. On a companion change it re-reads the companion (a .flow.ts in the same sandboxed child synth uses, a .flow.tf parsed in process) and rewrites the doc, stamping meta.sourceKind, guarded by meta.sourceHash (docs/01-flowdoc-spec.md, invariant 3): if the doc on disk was edited externally since the watcher last wrote or observed it, and that edit does not carry the previous companion content's hash, the pair is dirty on both sides. The watcher then emits conflict and writes nothing. A pair that is already diverged when the watcher starts also gets a conflict rather than an overwrite. A document another tool has already restamped for the companion's current content (codegen, convert) is in sync, not a conflict. A name with both a .flow.ts and a .flow.tf is ambiguous: every change to either is an error naming both, nothing is synced, and removing one syncs from the other.

The re-synth uses the same resolution rules synth documents above, so a watched directory needs no node_modules of its own for the builder files in it to resolve @flow-as-code/core.

Events: synced {sourcePath, sourceKind, docPath, name, warnings?}, conflict {sourcePath, sourceKind, docPath, name, reason}, error {path, message}, plus a ready convenience event after the initial scan settles. warnings are what reading a .flow.tf noticed without refusing it: a refs key no action uses, a resource address in a reference field read as its key. A .flow.tf the reader refuses is an error carrying the contract's code and the file, line and column. A ts file whose flow names do not include the file's base name syncs only when it exports exactly one flow, and a .flow.tf only when its resource's name is the file's base name; otherwise the watcher emits error for that pair.

A change that leaves the companion byte-identical to the last synced content is normally silent (it is how the watcher recognizes its own writes), with one exception: when the preceding change ended in error, the same no-op emits synced. Undoing a broken edit is the ordinary way to fix one, and consumers latch the error, so without that event the studio's out-of-sync badge outlived the state it reported and cleared only on some later, unrelated edit.

noteWrite(name, {sourceContent, docContent}) records a pair another part of the same process just wrote as the clean baseline, so the studio bridge's own writes are not read back as external edits. Pass the exact bytes written, and call it after both files are on disk.

studio

flow-cli studio [dir] [--port <port>] serves the visual editor over a directory of FlowDocs and their companions (.flow.ts builder files or .flow.tf resources), with live sync in both directions. It prints the URL to open; dir defaults to the working directory and the port defaults to a free one.

A document with no companion beside it gets one written before the server starts (the kind its meta.sourceKind names, else <name>.flow.ts), and the command prints a line per file:

wrote    appointment-line.flow.ts from appointment-line.flowdoc.json
        

That is the same codegen the canvas runs on save, so the bytes are the ones the first save would have written, and the document is stamped with the meta.sourceHash of the source just generated. Without it a directory holding only documents (exported from an instance, copied out of conformance/, sent by a colleague) had no builder file to edit, so the loop this command exists for could not start there. An existing builder file is never overwritten. A document codegen refuses is named and skipped, and the rest of the directory still opens:

skipped  broken.flowdoc.json: broken.flowdoc.json is not a valid FlowDoc: ...
        

A builder file the watcher cannot synth is reported on the same stream:

error    appointment-line.flow.ts: Evaluating /w/appointment-line.flow.ts threw: Transform failed with 1 error:
        /w/appointment-line.flow.ts:101:16: ERROR: Unexpected ";"
        

Stack frames inside node_modules and node's own internals are dropped from that message. A transform failure carries ten frames of esbuild and stream internals under a line that already names the file, line and column, and the studio's badge shows only the first line for the same reason. Frames in files you wrote survive, because a runtime throw inside a flow needs them. There is no switch that restores the dropped frames. node_modules has to be a whole path segment of the frame's own location for the frame to count as vendor code, so a project under a directory called node_modules-sandbox, or a function named loadNodeModules, keeps its frames.

A pair assembled by hand is a different case and still reports a conflict: the document has to carry the meta.sourceHash of the source next to it, or the watcher has no evidence the two ever agreed. flow-cli synth <name>.flow.ts writes that stamp; removing the stale side works too.

The server binds 127.0.0.1 and only 127.0.0.1. This process writes files you own, so a bridge reachable from the network would be a remote file writer; the bind address is not configurable for that reason. Binding loopback is not by itself protection against DNS rebinding (a page on the internet can resolve its own hostname to 127.0.0.1), so the Host header is checked as well and a request addressed to anything else is refused. No CORS headers are sent, and the server makes no outbound connections.

What it serves: the built studio assets from the installed @flow-as-code/studio package, and a small JSON API under /bridge. If the studio package is present but has not been built, the command says so and names the build command.

The API is these eight routes and nothing else; anything else under /bridge answers 404 with the method and path it was given (src/bridge/server.ts):

Route Method What it does
/bridge/info GET Served directory, version, and the session token
/bridge/docs GET The documents in the directory and each companion
/bridge/docs POST Create a document and its companion; 409 if taken
/bridge/docs/<name> GET One pair: the FlowDoc and which companion it has
/bridge/docs/<name> PUT Save that pair; 409 when the pair diverged
/bridge/docs/<name>/resolve POST Settle a refused save by choosing a side
/bridge/export POST Write an emitted file map under the served directory
/bridge/events?cursor=<n> GET Long-polled watch events from <n> on

Every request to /bridge and to the document itself carries the session token, either as the token query parameter (which is how the printed URL opens the page) or as the x-flow-studio-token header (which is how the page's own calls send it). Subresources are served without one so the token never appears in an asset URL. src/bridge/forgery.test.ts holds this contract, along with the Sec-Fetch-Site and Origin checks described above.

POST /bridge/export is how the studio's export buttons reach disk: the studio emits the file map (the same emitters this CLI's emit and render use) and this process writes it under the served directory. Paths are checked against the protocol's path rule and then again by resolving them and requiring the result to be inside that directory. Nothing is paired and no event is published: emitted terraform and CDK files are output, not documents.

Saving from the canvas writes both halves of the pair: the FlowDoc, and its companion regenerated (<name>.flow.ts with codegen, <name>.flow.tf with @flow-as-code/hcl), passing the existing file so @keep comments survive, and for a .flow.tf its refs bindings, instance_id, tags and lint settings too. The document is stamped with meta.sourceHash of the source just written and meta.sourceKind, which is what keeps the watcher from reading the save as a divergence. A save never writes a second companion: the kind is the one on disk, else the document's meta.sourceKind, else ts.

POST /bridge/docs takes {doc, sourceKind} and writes the document and the companion it names, answering 201 with the write result, or 409 without writing anything when the document or either companion already exists. The bridge protocol is version 2: payloads carry sourceKind, a .flow.tf's lint.disable list as lintDisable, and a write result names its companion as sourcePath and sourceText.

Conflicts are never merged. A save whose builder file has changed since the document was generated from it is refused with 409 and both sides are offered in the studio's dialog; so is a pair the watcher finds diverged on disk. Every write stays refused until the choice is made.