| title | Flow spec |
|---|---|
| description | A Flow as JSON: flat steps with a kind, predicates in JSON form, and the functions that convert, validate and describe it to a model. |
| type | reference |
| order | 12 |
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.
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.
Every field means what it means on Flow. The differences:
| Field | Spec type | Difference from Flow |
|---|---|---|
steps |
StepSpec[] |
Each step carries kind. |
while |
ConditionSpec |
JSON form only. |
on[].if |
ConditionSpec |
JSON form only. |
instructions[].if |
ConditionSpec |
JSON form only. |
steps[].branches[].if |
ConditionSpec |
JSON form only. |
steps[].if |
ConditionSpec |
JSON form only. |
| any optional field | may be null |
null means "not set" on the way in; fromSpec drops it. |
kind |
Flow step | Rule |
|---|---|---|
prompt |
talk step with prompt alone |
A guideline, nothing to collect. |
collect |
talk step with collect |
Has a collect list, with or without a prompt. |
say |
say step |
A fixed text. |
do |
do step |
A host action. |
wait |
wait: '5m' |
A duration. |
waitEvent |
wait: { event } |
An event. |
if |
if step |
A 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.
- Strips
nullfrom every optional value, at any depth. - Removes
kindfrom each step. Nothing else changes. - Throws
FlowConfigurationErrorwhen the JSON has the wrong shape, with the same shape checksvalidateFlowruns 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 itskinddisagrees 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
Flowbut nothing is verified yet;validateFlowdoes that, and the agent runs it on every flow it is built with.
- Never writes
nullorundefined; a field that was not set is absent. - Throws
FlowConfigurationErroron a function predicate anywhere (while, a triggerif, an instructionif, a branchif, anifstep):[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))isspecwith itsnulls dropped, andspecitself when it had none.
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".
| Family | Example of <what> |
Fix in the message |
|---|---|---|
| No flow id | has no id |
Give the flow a short unique id. |
| Steps missing | has no steps list |
Write steps as a list, even an empty one. |
| Step without id | has no id (<where> is flow "id", step #2) |
Give every step a unique id. |
| Reserved step id | uses the reserved id "end" |
"end" ends the run; pick another id. |
| Duplicate step id | duplicates an earlier step id |
Give each step its own id. |
| Triggers, no steps | has triggers but no steps |
Add at least one step or remove on. |
| Trigger with no kind | names 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 exclusions | every 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 nothing | does nothing |
A step talks (prompt / collect), says (say), acts (do), waits (wait) or forks (if). |
| Unknown field | unknown 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 tool | unknown tool "x" (flow or step tools) |
Register it in the agent's tools or fix the name. |
| Unknown action | unknown action "x" |
Register it in actions or fix the name. |
| Unknown event | unknown event "x" (trigger) or unknown event "x" in wait |
Register it in events or fix the name. |
| Unknown condition | unknown condition "x" in if |
Register it in conditions or use equals, known, silenced. |
equals shape |
if.equals is not an object |
Write equals as { field: value }. |
equals type |
if.equals gives "orcamento" a string, but the field is a number |
Write a number; values are not coerced. |
equals off the list |
if.equals gives "etapa" "frio", which is not one of "novo", "quente" |
Use one of the listed values. |
known shape |
if.known is not a list |
Write known as [field, ...]. |
silenced shape |
if.silenced is not a boolean |
Write true or false. |
| Bad duration | wait 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 jump | then points at step "x", which does not exist (also else, onFail, branches[n].then) |
Use an existing step id or "end". |
| Missing parameter | action "notify" needs parameter "message" |
Add it to with. |
| Wrong parameter type | parameter "tags" of action "add_tags" must be a list of strings, got string |
Values are not coerced; write the right type. |
| Extra parameter | action "notify" has no parameter "to" |
Remove it or fix the name. |
| Branch without a test | branches[0] has neither when nor if |
Give the branch an AI condition (when) or a code one (if). |
Backward if with no else |
"if" jumps back to "quem" with no else |
Add else so the false branch has somewhere to go. |
| Fixed question, nothing to ask | has a question but collects nothing |
A fixed question asks for fields: add collect, or send the text with a say step. |
| Not an object | is null, not an object (the flow), steps[0] is null, not an object, with is "x", not an object |
Pass the flow itself: { id, name, steps }. / Write each entry of steps as an object. |
| Not a list | collect is "nome", not a list (also on, steps, clearOnStart, tools, instructions, branches, message, mention, then.clear) |
Write collect: ["nome"]. |
| Not text | say is 42, not text (also prompt, question, description, anchor) |
Write say as a string. |
| Not one of the values | onEnd is "restart", which is not one of "end", "stay", "reset" (also instructions[n].kind) |
Use one of them. |
Bad repeat |
repeat is "never" |
Use "once", "always" or { cooldown: "24h" }. |
Bad maxAsks |
maxAsks is "3", not a whole number of 1 or more |
Write a number like 3. |
| Event wait without an event | wait has no event |
Write wait: { event: "name" } to wait for an event, or a duration like "1h". |
| Bad target | then 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 things | mixes "say" and "do" |
A step does one thing. Split it into one step per kind. |
kind disagrees with the body |
has kind "do", but its body is a "say" step |
Set 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 twiceidle: unknown tool "x"tool "x": parameters must be a JSON Schema objectcondition "known" shadows a built-in:equals,knownandsilencedare reserved- an unknown condition in an agent or idle instruction's
if:agent: unknown condition "vip" in instructions[0].if
| Warning | Why |
|---|---|
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(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;nullstands for "not set". Gemini accepts it as a response schema. - Steps are an
anyOfwith one variant perkind, and onedovariant per registered action.anyOfis the union keyword both Gemini and OpenAI strict schemas accept;oneOfis not. - A variant whose registry is empty is left out: no actions, no
dostep; no events, noeventtrigger and nowaitEventstep; no fields, nocollectstep. - Condition arguments are typed loosely (string, number, boolean or list of strings) because a
Conditioncarries no argument schema. - Left out on purpose, so a model does not write them:
ui,tools, step-levelinstructions,ask, a mention trigger'sextract, and theinputof{ flow }.
The schema shapes the answer; validateFlow checks it. Run it on every generated spec before it reaches an agent.
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));- Flows from JSON: storing, editing and generating flows.
- Flow, Step, Trigger, Branches: the typed forms.
- Actions, events, conditions: what the names resolve against, and
ConditionSpec. - Errors:
FlowConfigurationErrorand the message contract.