Skip to content

JSON flow schema ​

@botmesh/flow-schema (private, monorepo only) defines versioned, code-free flow documents: JSON that the manager stores and hot-swaps, that the deployment gate validates, and that compiles to a core CompiledFlow.

A document is data only. It never carries functions, eval, module imports or arbitrary expressions: the only computation is a safe expression language and templates.

ts
import { compileFlowDocument, validateFlowDocument } from '@botmesh/flow-schema'

const document = {
  version: 2 as const,
  id: 'checkout',
  trigger: { keywords: ['comprar', 'pedido'], match: 'contains' },
  timeout: '5m',
  steps: [
    { kind: 'ask' as const, text: '¿Tu nombre?', stateKey: 'name' },
    { kind: 'condition' as const, if: 'len(name) > 2', then: [{ kind: 'say' as const, text: 'Hola {{name}}' }] },
    { kind: 'handoff' as const, reason: 'checkout' },
  ],
}

if (validateFlowDocument(document).length === 0) bot.registerFlow(compileFlowDocument(document))

Document ​

FieldNotes
version2. A version 1 document is still accepted and migrated on load (its retries: N becomes maxAttempts: N); use migrateFlowDocument() for older shapes.
id^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$; the flow's name on the bot.
triggerA keyword, an array, or { keywords, match: 'equals' | 'contains' | 'startsWith' | 'endsWith', scope: 'dm' | 'group' | 'all' }.
timeoutFlow timeout (ms or '30s', '5m', '1h'; at most 7 days).
persistentSurvive a restart (requires a persistent store).
stepsAt least one step; at most 500 in total (nested steps included), 8 levels deep.

Steps ​

KindWhat it does
saySends text (templates allowed).
askSends text, waits for an answer and stores it in c.state[stateKey]; optional timeout, maxAttempts (answers in total) or retries (after the first) and validate: { expr, message } ($answer holds the raw answer).
chooseNumbered menu (options: [{ key, label, steps? }]), stores the picked key.
conditionif expression plus then/else nested steps.
delayPauses ('500ms'… at most 5 minutes).
gotoJumps to another registered flow.
endEnds the conversation, with an optional farewell.
mediaSends image, video, audio, voice, document, sticker or location. File media needs source: { url } (http/https) or { stateKey } (a URL stored in state); location needs latitude/longitude.
formNative form (or degraded question-by-question) with typed fields; see Forms.
guardif expression; when false it sends message and ends, or message + redirect to another flow.
subflowRuns another document inline (same state), resolved by resolveSubflow.
httpDeclarative HTTP request through @botmesh/http (anti-SSRF, redirects, timeout and size limits). Stores the JSON/text body in stateKey. placeholders are allowed only in the URL's path, query or fragment (never in the scheme, credentials, host or port); their values are percent-encoded and a bare . or .. value is refused.
handoffHands the chat to a human (ctx.handoff), with an optional message.
aiOne AI turn through ctx.tools.ai.replyInChat, optionally restricted to tool ids (tools: ['lookup']); stores the answer in stateKey.

Media URLs must use http(s). Local paths, buffers and streams are deliberately not representable: a stored document cannot read the server's filesystem.

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.

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.

No 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 () only read own properties of c.state plus $body, $from, $sender and $isGroup; a missing value renders as an empty string.

Validation errors ​

validateFlowDocument() returns JSON-pointer paths:

ts
validateFlowDocument({ version: 2, id: 'x', trigger: 'x', steps: [{ kind: 'ask', text: '', stateKey: 'bad key' }] })
// [{ path: '/steps/0/text', message: '...' }, { path: '/steps/0/stateKey', message: '...' }]

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 ​

ts
bot.registerFlow(compileFlowDocument(parent, {
  resolveSubflow: (id) => documents.get(id),
}))

A subflow's steps run inline (same 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 ​

ts
import { migrateFlowDocument } from '@botmesh/flow-schema'

const { document, from, applied } = migrateFlowDocument(storedDraft)

Version 0 (no 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 ​

ts
import { exportFlowDocument } from '@botmesh/flow-schema'

const document = exportFlowDocument(compiledFlow)

Flows compiled from a document round-trip exactly (the original JSON is kept as step metadata). Flows built with the DSL export their declarative steps (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.

Drafts and deployments ​

ts
import { diffFlowDocuments, flowDigest, publishFlow } from '@botmesh/flow-schema'

const changes = diffFlowDocuments(draft, published)   // metadata, added/removed/changed steps with paths
const digest = flowDigest(draft)                      // sha256 of canonical JSON, stable across key order
await publishFlow(registry, 'acme', 'support', draft) // DeploymentRegistry gate + audited gate run

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's POST/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.

Released under the MIT License.