@botmesh/flow-schema/internal, with no semver guarantee.
A document is data only. It never carries functions, eval, module imports or arbitrary expressions: the only computation is a safe expression language and {{ path }} templates.
Document
Steps
Media URLs must use http(s). Local paths, buffers and streams are deliberately not representable: a stored document cannot read the server’s filesystem. The same holds at run time for
{ stateKey } sources: the value in state can come from an end user’s answer or an http response, so anything that is not an absolute http(s) URL (/etc/passwd, C:\…, file:, data:) fails the step instead of reaching the provider, which would read a plain string as a local file. The provider, or a self-hosted gateway such as Evolution or WPPConnect, downloads that URL from inside your network, so by default it must also be https: and its host must not be, or resolve to, a private, loopback or link-local address (127.0.0.1, 169.254.169.254, 10.x, localhost…). The media compile option relaxes this on purpose: compileFlowDocument(doc, { media: { allowHttp: true, allowPrivateHosts: true, resolver } }). URLs written in the document itself are the author’s and are not checked.
A form field pattern runs against untrusted answers, so the validator rejects backreferences and repeated groups whose body repeats or alternates again ((a+)+, (\w+\s?)*, (a|aa)+: catastrophic backtracking). Write them with a character class instead ([a-z0-9-]+). That check is only a first filter: the compiled field never runs the pattern on JavaScript’s backtracking engine. It matches the whole answer with a linear-time automaton (O(answer length × pattern size)), so adjacent quantifiers (.*.*.*x) or long alternation chains cannot freeze the process either. The supported syntax is the regular subset: literals and escapes, character classes, ., groups (capturing, (?:…) and named), |, * + ? {n} {n,} {n,m} (lazy forms accepted) and the anchors ^ $ \b \B. Lookarounds ((?=, (?!, (?<=, (?<!), backreferences, octal escapes and patterns whose bounded repeats unroll to more than 2000 states are rejected. A text field with a pattern and no maxLength is capped at 500 characters, and its maxLength may be at most 1000 (FLOW_DOCUMENT_LIMITS.maxPatternAnswerLength): the automaton is linear, but its cost still grows with the answer length times the pattern size.
The validator also refuses form fields no answer can satisfy: minLength above maxLength, min above max, a date bound that is not a real day (2025-02-30) or a min after its max, and a minSelected above the number of options or above maxSelected.
Safe expressions
condition.if, guard.if and ask.validate.expr use a tiny recursive-descent language:
- literals (
1,'text',true,false,null), state paths (user.city),== != < <= > >= && || ! + -and parentheses; - functions:
len,lower,upper,trim,number,string,contains,startsWith,endsWith,empty,oneOf.
eval, no Function, no regexes, no indexing, no assignment, no prototype access (__proto__, prototype, constructor are rejected). Limits: 1000 characters, 256 tokens, 32 levels.
Templates ({{ path }}) only read own properties of c.state plus $body, $from, $sender and $isGroup; a missing value renders as an empty string. A rendered value is never read again as a template.
A state key may contain dots: after ask with stateKey: 'user.name', both {{ user.name }} and len(user.name) read c.state['user.name'] (a nested c.state.user.name object wins when both exist). Keys with - or : are valid ids but cannot be referenced (first-name is a subtraction), so use letters, digits, _ and . for keys you read back. An unknown $ variable, or $answer outside ask.validate.expr, is a validation issue.
Validation errors
validateFlowDocument() returns JSON-pointer paths:
compileFlowDocument() throws FlowDocumentError with the same issues. The JSON Schema (FLOW_DOCUMENT_SCHEMA, draft 2020-12) is served by the manager at GET /v1/flows/schema for editors and code generation.
Subflows
c.state, no second trigger). A missing document or a cycle (a → b → a) is a compile error. Because a subflow is inlined again at every reference, a flow may reach at most FLOW_DOCUMENT_LIMITS.maxInlinedSteps (5000) steps once all its subflows are expanded; a chain of flows that each call the next several times grows exponentially and is refused at compile time (in @botmesh/manager, createBot/updateBot/addFlow answer INVALID).
Migrations
version, the pre-versioned shape) becomes version 1. Version 1 becomes version 2: retries on ask/form steps meant the answers in total in v1, so retries: N is rewritten to maxAttempts: N (a retries: 0 to maxAttempts: 1) and the flow keeps its behaviour; in v2 retries counts the answers after the first, like the core. validateFlowDocument, compileFlowDocument and flowFromArtifact apply that migration to v1 documents on load; isFlowDocument only accepts version 2. A version newer than the package is rejected; migrations can be extended and chained (migrateFlowDocument(input, migrations, target)).
Reverse export
say, ask, choose, goto, delay, media, form, use); an opaque do or a function-based guard raises FlowExportError with the step paths instead of being dropped. So does a result that validateFlowDocument would refuse (a DSL .delay() longer than 5 minutes, an id with spaces): an export always loads again. The returned document is a copy; changing it does not touch the compiled flow.
Drafts and deployments
publishFlow() uses the document JSON as the artifact and a flowDocumentGate() that parses, validates and compiles the candidate; options.gate can chain another gate (e.g. golden transcripts). @botmesh/deployments owns the storage; this package only provides the pure helpers.
Manager REST
The manager’sPOST/PUT flow endpoints accept exactly these documents. GET /v1/flows/schema serves the JSON Schema and GET/PUT/DELETE /v1/bots/{botId}/flows/{flowId} store and hot-swap them without changing the REST contract. subflow steps resolve against the bot’s other stored flows.