@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:
- The entry point's
${cdref:flow:...}names a flow in the set. - Every token resolves. A resource an
expect-lambda,expect-transfer,expect-hours-checkorexpect-lexwaits on, and the production resource a substitution replaces, must be referenced by some document in the set, or the event can never fire. Any other token (a substitute, a queue named byexpect-queue) must be referenced by the set or keyed in the map. Only keys are read, so--address-map(the fileemittakes) serves as well as--resource-map, in the same three key forms; a scenario whose tokens the set itself references needs no map at all. - Every
expect-promptis a text some block in the set plays:Text, the spoken words ofSSML, and the messages of a loop, in any document of the set, a module included.containsis a case-insensitive substring match.similarTois loose, since the service's similarity threshold is not published: half the expectation's words must appear in one text. A prompt that reads$.Attributes.<name>is heard with the value the scenario starts the contact with or asserts withEquals, so "Welcome back, Mrs. Alder" matchesWelcome back, $.Attributes.callerName.when the scenario asserts that name; an attribute the scenario says nothing about splits the prompt, and the expectation has to fit one literal part. - Every
send-dtmfanswers the nearestexpect-promptbefore it, which must be played by aGetParticipantInput(then the key must be one its conditions branch on) or aStoreUserInput(any key). A key sent after a prompt no keypad block plays, or before any prompt, is a problem.
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.