Flow spec

A FlowSpec is a Flow written as plain JSON: the same object, with each step carrying a kind and every predicate in its JSON form (ConditionSpec). No functions anywhere, so you can store it in a database row, edit it in a form, and let a model write one. Four functions work on it: fromSpec turns it into a Flow, toSpec goes the other way, validateFlow checks that it can run, and flowSpecSchema describes it to a model that writes one.

Source: src/core/FlowSpec.ts, src/core/Agent.ts.

Signature

ts
type StepKind = "prompt" | "collect" | "say" | "do" | "wait" | "waitEvent" | "if";

interface FlowSpec {
  id: string;
  name: string;
  description?: string;
  on?: TriggerSpec[];
  anchor?: string;
  while?: ConditionSpec;
  collect?: string[];
  clearOnStart?: string[];
  steps: StepSpec[];
  onEnd?: "end" | "stay" | "reset";
  instructions?: InstructionSpec[];
  tools?: string[];
}

type StepSpec = StepBase &
  (
    | { kind: "prompt"; prompt: Template; ask?; maxAsks?; branches?: BranchSpec[]; tools?; instructions?: InstructionSpec[] }
    | { kind: "collect"; collect: string[]; prompt?: Template; question?: Template; ask?; maxAsks?; branches?: BranchSpec[]; tools?; instructions?: InstructionSpec[] }
    | { kind: "say"; say: Template; media?: { slug: string }; once?: boolean }
    | { kind: "do"; do: string; with?: Record<string, unknown>; onFail?: Next }
    | { kind: "wait"; wait: Duration; businessHours?: boolean; else?: Next; branches?: BranchSpec[] }
    | { kind: "waitEvent"; wait: { event: string; upTo?: Duration }; else?: Next }
    | { kind: "if"; if: ConditionSpec; else?: Next }
  );

type TriggerSpec = { repeat?: Repeat } & (
  | { message: string[]; if?: ConditionSpec }
  | { mention: string[]; extract?: ParamDefs; if?: ConditionSpec }
  | { silence: Duration; if?: ConditionSpec; businessHours?: boolean }
  | { event: string; if?: ConditionSpec; after?: Duration; businessHours?: boolean }
);

type BranchSpec = { then: Next } & ({ when: string } | { if: ConditionSpec });

type InstructionSpec = Omit<Instruction, "if"> & { if?: ConditionSpec };

/** What a flow's names resolve against. With `flows`, a literal `{ flow }` target must name one of them. */
type Registries = Pick<AgentOptions, "fields" | "actions" | "events" | "conditions" | "tools"> & {
  flows?: ReadonlyArray<{ id: string }>;
};

function fromSpec<C = unknown, D = InferData<FieldDefs>>(spec: FlowSpec): Flow<C, D>;
function toSpec<C, D>(flow: Flow<C, D>): FlowSpec;
function validateFlow<C, D>(input: Flow<C, D> | FlowSpec, registries: Registries): { warnings: string[] };
function flowSpecSchema(registries: Registries): StructuredSchema;

f.fromSpec(spec) is fromSpec with the toolkit's C and D filled in.

FlowSpec fields

Every field means what it means on Flow. The differences:

FieldSpec typeDifference from Flow
stepsStepSpec[]Each step carries kind.
whileConditionSpecJSON form only.
on[].ifConditionSpecJSON form only.
instructions[].ifConditionSpecJSON form only.
steps[].branches[].ifConditionSpecJSON form only.
steps[].ifConditionSpecJSON form only.
any optional fieldmay be nullnull means "not set" on the way in; fromSpec drops it.

StepKind

kindFlow stepRule
prompttalk step with prompt aloneA guideline, nothing to collect.
collecttalk step with collectHas a collect list, with or without a prompt.
saysay stepA fixed text.
dodo stepA host action.
waitwait: '5m'A duration.
waitEventwait: { event }An event.
ifif stepA code fork.

fromSpec drops kind; toSpec derives it from the step's shape by this table. A talk step with neither prompt nor collect makes toSpec throw: [FlowConfigurationError] flow "x", step "y": has neither prompt nor collect. A talk step needs a guideline, fields to collect, or both.

fromSpec

  • Strips null from every optional value, at any depth.
  • Removes kind from each step. Nothing else changes.
  • Throws FlowConfigurationError when the JSON has the wrong shape, with the same shape checks validateFlow runs first: the flow or a step is not an object, a list is not a list (steps, on, collect, …), a step does two things or none, or its kind disagrees with its body. For example [FlowConfigurationError] flow "x": has no steps list. Write steps as a list, even an empty one.
  • Does not check names. The result is typed as a Flow but nothing is verified yet; validateFlow does that, and the agent runs it on every flow it is built with.

toSpec

  • Never writes null or undefined; a field that was not set is absent.
  • Throws FlowConfigurationError on a function predicate anywhere (while, a trigger if, an instruction if, a branch if, an if step): [FlowConfigurationError] flow "x", step "y" if: is a function, which cannot be stored as JSON. Write it as a condition ({ equals }, { known }, { silenced } or a named condition) to store this flow.
  • toSpec(fromSpec(spec)) is spec with its nulls dropped, and spec itself when it had none.

validateFlow

validateFlow(input, registries) accepts a typed Flow or a FlowSpec. It throws FlowConfigurationError on the first problem that would break at run time and returns { warnings } for what runs but probably not as you meant. The agent constructor calls it on every flow and logs each warning through the logger with an [Agent] prefix.

Every message has the form [FlowConfigurationError] <where>: <what>. <fix>, where <where> is flow (no id yet), flow "id", flow "id", trigger #n, flow "id", step #n (step n has no id) or flow "id", step "sid".

Errors

FamilyExample of <what>Fix in the message
No flow idhas no idGive the flow a short unique id.
Steps missinghas no steps listWrite steps as a list, even an empty one.
Step without idhas no id (<where> is flow "id", step #2)Give every step a unique id.
Reserved step iduses the reserved id "end""end" ends the run; pick another id.
Duplicate step idduplicates an earlier step idGive each step its own id.
Triggers, no stepshas triggers but no stepsAdd at least one step or remove on.
Trigger with no kindnames no trigger kind (<where> is flow "id", trigger #n; the v3 { kind: 'message', when } shape lands here)A trigger is one of message, mention, silence, event. A flow the host starts itself has no on at all.
Only exclusionsevery message phrase starts with "!", so nothing can ever match it (also mention)A "!" phrase rules the trigger out. Add at least one plain phrase saying when it should fire, or use an empty list for a catch-all.
Step does nothingdoes nothingA step talks (prompt / collect), says (say), acts (do), waits (wait) or forks (if).
Unknown fieldunknown field "x" in collect (the flow's or a step's; also ask, clearOnStart, then.clear, while.equals, if.known, …)Add it to the agent's fields or fix the slug.
Unknown toolunknown tool "x" (flow or step tools)Register it in the agent's tools or fix the name.
Unknown actionunknown action "x"Register it in actions or fix the name.
Unknown eventunknown event "x" (trigger) or unknown event "x" in waitRegister it in events or fix the name.
Unknown conditionunknown condition "x" in ifRegister it in conditions or use equals, known, silenced.
equals shapeif.equals is not an objectWrite equals as { field: value }.
equals typeif.equals gives "orcamento" a string, but the field is a numberWrite a number; values are not coerced.
equals off the listif.equals gives "etapa" "frio", which is not one of "novo", "quente"Use one of the listed values.
known shapeif.known is not a listWrite known as [field, ...].
silenced shapeif.silenced is not a booleanWrite true or false.
Bad durationwait has duration "5 min", which does not parse (also silence, after, repeat.cooldown, wait.upTo)Write a number and a unit: "30s", "5m", "24h" or "3d".
Dangling jumpthen points at step "x", which does not exist (also else, onFail, branches[n].then)Use an existing step id or "end".
Missing parameteraction "notify" needs parameter "message"Add it to with.
Wrong parameter typeparameter "tags" of action "add_tags" must be a list of strings, got stringValues are not coerced; write the right type.
Extra parameteraction "notify" has no parameter "to"Remove it or fix the name.
Branch without a testbranches[0] has neither when nor ifGive the branch an AI condition (when) or a code one (if).
Backward if with no else"if" jumps back to "quem" with no elseAdd else so the false branch has somewhere to go.
Fixed question, nothing to askhas a question but collects nothingA fixed question asks for fields: add collect, or send the text with a say step.
Not an objectis null, not an object (the flow), steps[0] is null, not an object, with is "x", not an objectPass the flow itself: { id, name, steps }. / Write each entry of steps as an object.
Not a listcollect is "nome", not a list (also on, steps, clearOnStart, tools, instructions, branches, message, mention, then.clear)Write collect: ["nome"].
Not textsay is 42, not text (also prompt, question, description, anchor)Write say as a string.
Not one of the valuesonEnd is "restart", which is not one of "end", "stay", "reset" (also instructions[n].kind)Use one of them.
Bad repeatrepeat is "never"Use "once", "always" or { cooldown: "24h" }.
Bad maxAsksmaxAsks is "3", not a whole number of 1 or moreWrite a number like 3.
Event wait without an eventwait has no eventWrite wait: { event: "name" } to wait for an event, or a duration like "1h".
Bad targetthen is 5, not a step id or a target (also else, onFail, branches[n].then)Write a step id, "end", { step: "id" } or { flow: "id" }.
Step does two thingsmixes "say" and "do"A step does one thing. Split it into one step per kind.
kind disagrees with the bodyhas kind "do", but its body is a "say" stepSet kind to "say", or change the body to match.

Parameter values are checked strictly: "3" is not a number, 3.5 is not an integer, and an enum must contain the value unless the string holds {{, because a template's value is only known at run time.

The agent constructor passes its own flows as registries.flows, so a literal chain to a flow it does not have is a warning in the log (see below). Five more checks live in the constructor rather than in validateFlow:

  • flow "x" is declared twice
  • idle: unknown tool "x"
  • tool "x": parameters must be a JSON Schema object
  • condition "known" shadows a built-in: equals, known and silenced are reserved
  • an unknown condition in an agent or idle instruction's if: agent: unknown condition "vip" in instructions[0].if

Warnings

WarningWhy
flow "f", step "s": then jumps back to "quem" without clear; the fields collected since stay known and those steps skip. Add clear: [...] to re-ask them.A then, else, onFail or branch target points at the same or an earlier step and clears nothing, so a collect step it lands on is skipped with code: 'already-known'.
flow "f", step "s": collects "nome", "empresa" with no prompt and no ask; the model has nothing to go on. Add a prompt or an ask per field.A collect step with no prompt and no question, where no listed field has an ask on the step or on the agent.
flow "f": collect lists "confirmado", which is only taken from the answer to a step that asks it, and no step does. Add it to a step's collect, or set extract: 'anywhere' on the field.A field in the flow's collect with extract: 'asked' (every boolean, by default) that no step's collect lists, so nothing can ever fill it.
flow "f", step "s": then names flow "humnao", which this agent does not have; a run skips this move with flow-gone. Use one of "vendas", "suporte", or add the flow.A literal { flow } target missing from registries.flows; only checked when that list is set, and never for an id with {{. A warning rather than an error, so a host that drops one bad row keeps the rest of its agent.
flow "f", step "s": branches[0] is a "when" branch on a wait step, which no call judges, so a reply goes to else. Use "if", or move the branch to a talk step.No call judges a wait step, so an AI condition there never fires. flowSpecSchema no longer offers one.

flowSpecSchema

flowSpecSchema(registries) returns a StructuredSchema for FlowSpec with this agent's field slugs, actions (each with its parameter schema), events and conditions as enums. Pass it as parameters.jsonSchema of a provider call and the model can only write a flow that names things you have.

  • Every object is closed (additionalProperties: false) and every property is required; null stands for "not set". Gemini accepts it as a response schema.
  • Steps are an anyOf with one variant per kind, and one do variant per registered action. anyOf is the union keyword both Gemini and OpenAI strict schemas accept; oneOf is not.
  • A variant whose registry is empty is left out: no actions, no do step; no events, no event trigger and no waitEvent step; no fields, no collect step.
  • Condition arguments are typed loosely (string, number, boolean or list of strings) because a Condition carries no argument schema.
  • Left out on purpose, so a model does not write them: ui, tools, step-level instructions, ask, a mention trigger's extract, and the input of { flow }.

The schema shapes the answer; validateFlow checks it. Run it on every generated spec before it reaches an agent.

Example

ts
import { falai, FlowConfigurationError, flowSpecSchema, GeminiProvider, toSpec, validateFlow, type FlowSpec } from "@falai/agent";

const f = falai().fields({
  nome: { type: "string", ask: "Pergunte o nome." },
  empresa: { type: "string", ask: "Pergunte a empresa." },
});

// Everything a stored flow may name, registered once.
const registries = {
  fields: f.fields,
  actions: {
    notify: f.action({
      parameters: { recipient: { type: "string" }, message: { type: "string" } },
      run: (params) => {
        console.log(`[notify] ${params.recipient}: ${params.message}`);
        return { ok: true };
      },
    }),
    add_tags: f.action({
      parameters: { tags: { type: "array", items: { type: "string" } } },
      run: (params) => {
        console.log("[add_tags]", params.tags);
        return { ok: true };
      },
    }),
  },
};

// A flow typed in a chat and stored as a row: flat steps with a `kind`, no functions.
const concorrente: FlowSpec = {
  id: "concorrente",
  name: "Lead falou de concorrente",
  on: [{ mention: ["o lead cita ou compara com um concorrente"], extract: { trecho: { type: "string" } }, repeat: "once" }],
  steps: [
    { id: "tag", kind: "do", do: "add_tags", with: { tags: ["concorrente"] } },
    { id: "avisa", kind: "do", do: "notify", with: { recipient: "owner", message: '{{data.nome}} falou de concorrente: "{{input.trecho}}"' } },
  ],
};

// Validate on save. The error names the unknown field, action, event, condition or step.
console.log(validateFlow(concorrente, registries).warnings); // []
try {
  validateFlow({ ...concorrente, steps: [{ id: "x", kind: "do", do: "send_email", with: {} }] }, registries);
} catch (error) {
  if (error instanceof FlowConfigurationError) console.log(error.message);
  // [FlowConfigurationError] flow "concorrente", step "x": unknown action "send_email". Register it in actions or fix the name.
}

// The TypeScript form is the same object.
const triagem = f.flow({
  id: "triagem",
  name: "Triagem",
  on: [{ message: ["quer um orçamento"] }],
  steps: [
    { id: "quem", prompt: "Descubra quem é.", collect: ["nome", "empresa"] },
    { id: "avisa", do: "notify", with: { recipient: "owner", message: "Lead: {{data.nome}} ({{data.empresa}})" } },
  ],
});
console.log(toSpec(triagem).steps[0]); // { id: "quem", kind: "collect", prompt: "Descubra quem é.", collect: ["nome", "empresa"] }

// Let a model write one: the schema is the response schema of an ordinary generation call.
const provider = new GeminiProvider({ apiKey: process.env.GEMINI_API_KEY ?? "", model: "gemini-2.5-flash" });
const generated = await provider.generateMessage<undefined, FlowSpec>({
  prompt: "Write a flow, as JSON, for: 'quando o lead pedir para falar com uma pessoa, avise o dono e marque a tag humano'. Texts in Brazilian Portuguese.",
  history: [],
  context: undefined,
  parameters: { jsonSchema: flowSpecSchema(registries), schemaName: "flow" },
});
const spec = generated.structured;
if (!spec) throw new Error("The model returned no flow JSON. It usually ignored the schema or hit its token limit; check the raw response and ask again.");
validateFlow(spec, registries);

// Rows load like any other flow.
const agent = f.agent({ name: "Ana", provider, ...registries, flows: [triagem, f.fromSpec(concorrente), f.fromSpec(spec)] });
console.log(agent.options.flows?.map((flow) => flow.id));

See also