Version: 4.0.0 — One model for flows, automations and signals. agent.turn() takes any input (a message, a timer, a host event, a manual start) and returns the messages to send and the timers to set.
Summary
v4 is a clean break. There are no aliases and no shims: every old name is gone and the new one has a different shape. A 3.x program does not compile against 4.0.
The mental model got smaller. A Flow is a trigger plus an ordered list of steps. The trigger says when a run starts: the customer asks for it (message), mentions it (mention), goes quiet (silence), something happens in your system (event), or you start it by hand. Each step is one of five things: the model talks (prompt / collect), a fixed text goes out (say), your code does something (do), the run waits (wait), or the code forks (if). Fields live on the agent with their own ask, can come in any order, and a step ends when its fields are known.
Everything that used to live beside the framework in your app (automation rules, signal rules, follow-up schedulers, a second prompt composer) is now a flow with a different trigger. The framework never sends, never sleeps and never saves. It returns what to send and when to wake up; you save the session, then send the messages and set the timers.
Budget: a text turn costs at most two model calls (understand, then speak), plus one per tool round and one more when compaction summarizes the history. Every result carries llmCalls.
respond({ history, session }) took one user message and returned one string. turn() takes whatever just happened and returns everything the host must do.
// ─── v4 ───
import type { Agent, History, Store } from '@falai/agent';
declare const agent: Agent; // your f.agent(...)
declare const store: Store; // one of the seven stores
declare const send: (text: string, opts: { after: number; key: string }) => Promise<void>;
declare const queue: { add(job: { jobId: string; at: Date }): Promise<void> };
async function onMessage(sessionId: string, context: unknown, history: History, text: string, messageId: string, at: string) {
const session = (await store.load(sessionId)) ?? undefined;
const r = await agent.turn({ sessionId, session, context, history, message: text, id: messageId, at });
if (r.changed) {
await store.save(r.session, session?.version ?? 0); // throws SessionConflictError when another turn saved first: drop this result and run the turn again
for (const m of r.messages) await send(m.text, { after: m.afterMs, key: m.key });
for (const s of r.schedule) await queue.add({ jobId: encodeURIComponent(s.key), at: s.at }); // BullMQ refuses a ':' in a custom id
}
}
Input kinds, one of: { message, id?, at? }, { wake } (a key from schedule[]), { event, payload?, key }, { start: { flow, input?, key } }. Pass context and history on every call, wakes included. Pass silenced: 'reason' whenever the assistant must not speak (a human owns the conversation, the channel window is closed, no credits): do steps still run, zero model calls, and nothing is said. A run that was already asking stays asking and speaks on the first turn that is not silenced; a run that reaches a new talk or say step ends, and the step's outcome is code: 'silenced' with your reason in detail. Pass silenced: { reason, understand: true } to keep the understand call (extraction, mentions) while muting speech. Pass silenced: { reason, skip: true } when the gate only stops messages (a closed channel window): the talk or say step is skipped and the run goes on to its then.
respondStream() is turnStream(): yields { delta } chunks and one { done: true, result }.
Why: three products built a follow-up scheduler, an automation engine and a second prompt composer around respond() because the framework had no notion of time or events. One entry point for every input removes all three.
2. falai() replaces createAgent() and the schema
You write one generic: the agent's context type. Fields are declared once, each with its own ask, and set the collected-data type everywhere fields are used.
// ─── v4 ───
import { falai, type AiProvider, type DataOf } from '@falai/agent';
type Ctx = { lead: { owner: 'ai' | 'human' } };
declare const provider: AiProvider;
declare function nextWorkingTime(at: Date, context: Ctx): Date;
const f = falai<Ctx>().fields({
nome: { type: 'string', ask: 'Pergunte o nome de um jeito leve, sem tom de formulário.' },
});
type Data = DataOf<typeof f>;
const agent = f.agent({
name: 'Ana', provider, flows: [/* f.flow(...) */], actions: {}, events: {}, conditions: {},
// tools, instructions, knowledgeBase, persona, goal: unchanged
idle: { prompt: 'Responda pela empresa; não invente preços.' }, // speaks when no flow is asking a question (no flow holds the floor); 'silent' mutes it
clock: () => new Date(), // tests pass fakeClock()
businessHours: (at, { context }) => nextWorkingTime(at, context), // snaps timers forward; optional
});
f.flow(), f.action(), f.event() and f.condition() give you typed values; collect, ask, clearOnStart and ctx.set are checked against the field names at compile time. Action, event and condition names inside a flow are strings and are checked when the agent is built, the same way a JSON flow is.
One Agent serves every session. context, session and history no longer live on the instance; they arrive on each turn(). contextProvider, hooks, initialData, sessionId, flowSwitchMargin, maxAutoStepsPerTurn, maxDirectiveChain, routerMode, signals and signalBatchSize are gone.
Step ids are required and unique; end is reserved.
4. Collection: ask and maxAsks replace requires, requiredFields, skip
Out-of-order data was already the behaviour in 3.x (extraction read the whole schema); the step logic just never used it. In v4 a talk step's fields are pending = collect − known − at maxAsks, computed by code every turn. A step whose fields are all known is skipped with zero calls; a step stays asking until they are known, a branch fires, or a field hits maxAsks (default 3; the step's outcome is then code: 'max-asks', with the field's slug in detail).
ts
// ─── v3 ─── the step held position until `requires` was met; nothing collected it → deadlock
{ id: 'confirma', prompt: 'Confirme os dados.', requires: ['nome', 'empresa'] }
ts
// ─── v4 ─── confirmation is a collected boolean behind an `if`; a "no" clears it and re-asks
import { falai } from '@falai/agent';
const f = falai().fields({
nome: { type: 'string' }, empresa: { type: 'string' },
confirmado: { type: 'boolean', ask: 'Confirme nome e empresa com a pessoa.' },
});
const triagem = f.flow({
id: 'triagem', name: 'Triagem',
steps: [
{ id: 'quem', collect: ['nome', 'empresa'] },
{ id: 'confirma', collect: ['confirmado'] },
{ id: 'ok', if: { equals: { confirmado: true } }, else: { step: 'quem', clear: ['confirmado'] } },
],
});
Per-field wording lives on the field (ask); a step may override it (ask: { nome: '...' }). extract: 'anywhere' | 'asked' says whether a field may be taken from any message (default for strings and numbers) or only from the reply to the step that lists it (default for booleans, so a stray "sim" is never read as a confirmation). { collect: [...] } alone asks using the field's ask.
5. Movement: then / else replace directives and hooks
goTo, goToStep, complete, abort, reset, dispatch(), pendingDirective, flow.merge(), flow.validate(), BranchMap, and the Directive type are gone. Every position change is a then or else on a step:
ts
type Next = string /* step id or 'end' */ | { step: string; clear?: string[] } | { flow: string; input?: unknown };
Branches stay on talk steps, judged while the step is asking: { when: '...', then } for the model, { if: pred, then } for code. A wait step takes if branches only, judged when the customer replies, and only when the step also has an else; validateFlow rejects a when branch on a wait, since nothing would ever judge it. There is no standalone AI-judged step: the model forks only where fresh customer text exists.
// ─── v4 ───
import { falai } from '@falai/agent';
const f = falai().fields({ nome: { type: 'string' } });
const triagem = f.flow({
id: 'triagem', name: 'Triagem', on: [{ message: ['quer saber como funciona'] }],
steps: [{
id: 'quem', collect: ['nome'],
branches: [{ when: 'quer falar com humano', then: { flow: 'handoff' } }],
}],
});
// a tool cannot move the run; give the flow an `if` step or a branch, or let the host `start` a flow
Why: five separate code paths each moved the run in their own way. Now one function, advance(), applies every then and else.
6. Signals become mention flows
A signal was a detector plus a handler. In v4 it is a flow whose trigger is mention: the model judges it inside the same understand call that routes the message, and the run reacts beside the conversation without taking it over.
// ─── v4 ───
import { falai } from '@falai/agent';
const f = falai().fields({ nome: { type: 'string' } });
const concorrente = f.flow({
id: 'concorrente', name: 'Lead falou de concorrente',
on: [{
mention: ['o lead cita ou compara com um concorrente', '!o lead fala do nosso próprio produto'],
extract: { trecho: { type: 'string' } }, repeat: 'once',
}],
steps: [
{ id: 'tag', do: 'add_tags', with: { tags: ['concorrente'] } },
{ id: 'avisa', do: 'notify', with: { recipient: 'owner', message: '{{data.nome}} falou de concorrente: "{{input.trecho}}"' } },
],
});
Signal facet
v4
when[] with ! exclusions
mention: [...], exclusions and all — a ! phrase still rules the trigger out. Copy the list across unchanged
if
trigger if; sees input after extract
extract
trigger extract → run.input → {{input.x}}; never written to data
phase: 'pre' + halt + reply
a say first step; another run's say silences the floor's reply that turn
phase: 'post'
the default: do-only mention flows run beside the reply, same turn
behavior: once / always / cooldown
repeat: 'once' / 'always' / { cooldown }
priority, stopOtherSignals
flow order; one speaker per turn
mention: [] + if
a code-only detector, no model call
session.signals.triggers becomes session.claims (see §10).
7. Timers and events: wait, silence, event
New in v4; nothing in 3.x maps to these.
ts
import { falai } from '@falai/agent';
type Ctx = { lead: { owner: 'ai' | 'human' } };
const f = falai<Ctx>().fields({ nome: { type: 'string' } });
const retomar = f.flow({
id: 'retomar', name: 'Retomar quem sumiu',
on: [{ silence: '24h', businessHours: true, if: ({ context }) => context.lead.owner === 'ai' }],
anchor: 'lead', // one active run per lead, across that lead's conversations
steps: [
{ id: 'p1', prompt: 'Retome a conversa de forma leve.' },
{ id: 'w1', wait: '2d', else: 'end' }, // then = timed out, else = the customer replied
{ id: 'p2', prompt: 'Última tentativa, curta e sem pressão.' },
{ id: 'w2', wait: '3d', else: 'end' },
{ id: 'n1', do: 'notify', with: { recipient: 'owner', message: '{{data.nome}} não respondeu.' } },
],
});
wait: '3s' (10 s or less, and the next step is a say or a talk step) becomes afterMs on that message in the same turn; every other wait parks the run (stops it until a wake) and puts { key, at } in schedule[]. Enqueue the wake with encodeURIComponent(key) as the job id (BullMQ refuses a : in a custom id) and call turn({ wake: key }) when it fires. The framework never cancels a wake itself: a stale one is ignored (changed: false). A re-armed silence wake names the one it supersedes in replaces; removing that job is optional.
on: [{ event: 'stage_entered', after: '1h' }] starts a run when your code calls turn({ event, payload, key }). Declare events with f.event<Payload>({ direction? }): inbound counts as the customer speaking, outbound as the assistant.
wait: { event: 'meeting_booked', upTo: '7d' } parks until the event arrives.
Runs inside a session are concurrent; at most one is asking a question. A timer-started talk step suspends the current asker and hands the floor back when it is done.
What your host must do:
run one turn per session at a time
pass fresh context, history, anchors and claims on every input
after the turn, save the session, queue the messages and the schedules in one transaction
make do handlers idempotent on ctx.key; they run at least once
value is what the model reads back; data is written to the collected data. Argument order flips to (args, ctx). ToolContext is ToolCtx { context, data, history, run?, now }: no updateContext, updateData, setField, dispatch. The gates (validateInput, checkPermissions, isConcurrencySafe, isReadOnly, isDestructive, maxResultSizeChars) stay. ToolManager, ToolScope, DataEnrichmentConfig, ValidationConfig, ApiCallConfig, ComputationConfig are gone.
The seven adapters survive as Store implementations and take the same client you passed before: MemoryStore, PostgresStore, PrismaStore, RedisStore, MongoStore, SQLiteStore, OpenSearchStore. Each takes one options object with the client in it, so OpenSearch's becomes new OpenSearchStore({ client, ...options }). They persist the v4 blob and a version, nothing else; message repositories, SessionRepository, status, currentFlow / currentStep columns, PersistenceManager, autoSave, schemaVersion and restoreSession are gone. The framework never calls a store: you load, turn, save.
Use a fresh table. The default names are the 3.x ones (agent_sessions, agent: prefix), so pass a new one (tables.sessions on Postgres, SQLite and Prisma, collections.sessions on Mongo, indices.sessions on OpenSearch, keyPrefix on Redis) or drop the old table first; initialize() (Postgres, SQLite, OpenSearch) only creates the table or index when it is missing, and does nothing while one of that name exists. A v4 store read against a live 3.x row fails loudly: Redis, Mongo, Prisma and OpenSearch throw InvalidSessionError (no blob), Postgres and SQLite fail on the missing blob column. Create the new table, then migrate rows on first load as §10 shows.
Store
Where a session lives
PostgresStore, SQLiteStore
one row: id, version, blob (JSONB / TEXT), created_at, updated_at
PrismaStore
model AgentSession { id String @id; version Int; blob Json; createdAt DateTime; updatedAt DateTime }, names remappable with fieldMappings.sessions
MongoStore
one document: _id, version, blob (JSON text, so claim keys with dots survive), createdAt, updatedAt
RedisStore
one hash at ${keyPrefix}session:${id} with version, blob, createdAt, updatedAt; the compare-and-swap is one Lua script, so the client needs hgetall, eval and quit
OpenSearchStore
one document with id, version, blob (enabled: false, never indexed), createdAt, updatedAt
Every store rejects a row whose blob is not a v4 session for that id, so a corrupt row is a loud error, never a fresh conversation.
10. The session blob: migrateSession
The 3.x SessionState (currentFlow, currentStep, flowHistory, signals, pendingDirective) becomes Session { id, v: 4, version, data, runs, claims, inputs, lastUserAt?, lastAssistantAt?, history?, metadata }. Migrate once, lazily, where you deserialize:
ts
import { migrateSession } from '@falai/agent';
declare const rowBlob: unknown; // the row as your store returns it
declare const sessionId: string;
const session = migrateSession(rowBlob, {
sessionId,
flowIdOf: (key) => key, // signal key / old flow id → v4 flow id; identity when you kept the ids
});
data is kept verbatim.
currentFlow + currentStep become one run at the same step id, status: 'asking', so a mid-flow conversation keeps its position. Keep your talk-step ids when you convert flows. A flow entered before its first step becomes a running run with no step.
signals.triggers[key], completed flowHistory entries and the mid-flow run itself become claims (${flowIdOf(key)}:${sessionId}:), so once flows do not fire again.
version is 0: the session has no row in the v4 table yet, so your usual store.save(session, session.version) is the insert.
pendingDirective is dropped.
A blob that is neither v4 nor a recognisable 3.x state throws InvalidSessionError; a corrupt row can no longer become a fresh conversation silently.
3.x did not record when the assistant last spoke, so a lifted session has no lastAssistantAt and arms no silence follow-up until the assistant speaks again. At cutover, set session.lastAssistantAt and session.lastUserAt from your messages table, save, and enqueue agent.pendingWakes({ session, context }): a conversation that was quiet at cutover then gets its follow-up on time.
Add a test that loads one real (anonymised) row per product and asserts the run's stepId and the carried claims.
11. Stored flows as JSON: FlowSpec
The object you store in a database is the framework's own JSON form: a Flow whose steps are flat, { id, kind: 'prompt' | 'collect' | 'say' | 'do' | 'wait' | 'waitEvent' | 'if', ...props, then?, else? }, with predicates in JSON ({ equals: {...} }, { known: [...] }, { silenced: true }, { myCondition: arg }).
fromSpec(spec) and toSpec(flow) convert both ways. validateFlow(spec, registries) throws FlowConfigurationError naming the unknown field, action, event, condition or step, and returns { warnings } for what runs but probably not as intended. flowSpecSchema(registries) returns the closed JSON schema to use as the response schema when a model writes a flow.
Host actions, events and conditions are registered once on the agent and referenced by name, so a flow typed in a chat, a flow drawn in an editor and a flow written in TypeScript are the same object.
id + name, on[], repeat, clearOnStart, onEnd, while
skip, auto, reply, step hooks, prepare, finalize
known-field skipping, maxAsks, do, wait, say
requires
an if step: { if: { known: [...] }, then: '<step>', else: 'end' }. Not known-field skipping — that answers "do I still need to ask?", while requires answers "may this step run at all?". They differ exactly when the customer never answers: maxAsks retires the field and the step proceeds with it unknown.
Template as a function, TemplateContext, ConditionEvaluator, ConditionWhen, ConditionIf
Template = string with {{data.x}}{{context.x}}{{input.x}}; Pred (function or JSON). ! exclusions are not gone — a phrase opening with ! still rules a trigger out. Copy the list across unchanged.
Term, terms
put the glossary in knowledgeBase or an instruction
Convert stored flows and signal rules to FlowSpec rows. Keep talk-step ids; give each migrated signal flow id = signal key.
Register your actions, events and conditions on the agent.
Replace the respond call site with load → turn → save + messages + schedules in one transaction.
Wire wakes (job id encodeURIComponent(key), turn({ wake }) at fire time) and host events. At cutover, enqueue agent.pendingWakes() for every lifted session that was quiet.
Put migrateSession in your deserializer and let it throw on garbage.
Delete the automation engine, the follow-up sweep and the second composer.
Check:
bash
bun run typecheck
bun test
rg -n "respond\(|dispatch\(|requiredFields|Signal<|goTo" src # nothing left
Then play four scenarios in your playground before deploying: a triage that collects out of order, a silence follow-up firing from a fake clock, a mention flow that speaks first and hands the floor back, and a say / wait: '3s' / say chain arriving as two messages with a delay.