Skip to content

Latest commit

 

History

History
275 lines (227 loc) · 17.9 KB

flow-spec.md

File metadata and controls

275 lines (227 loc) · 17.9 KB
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

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

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:

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.

StepKind

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.

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

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 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

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

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

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