v3 → v4 migration

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.


Contents

  1. Entry point: turn() replaces respond()
  2. falai() replaces createAgent() and the schema
  3. Flows: triggers replace when, steps get kinds
  4. Collection: ask and maxAsks replace requires, requiredFields, skip
  5. Movement: then / else replace directives and hooks
  6. Signals become mention flows
  7. Timers and events: wait, silence, event
  8. Tools return { value, data }
  9. Persistence: Store replaces PersistenceAdapter
  10. The session blob: migrateSession
  11. Stored flows as JSON: FlowSpec
  12. Removed → replacement
  13. Codemod and verification

1. Entry point: turn() replaces respond()

respond({ history, session }) took one user message and returned one string. turn() takes whatever just happened and returns everything the host must do.

ts
// ─── v3 ───
const r = await agent.respond({ history, session });
await send(r.message);
await save(r.session);
ts
// ─── 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.

ts
// ─── v3 ───
const agent = createAgent<Ctx, Data>({
  name, provider,
  schema: { type: 'object', properties: { nome: { type: 'string', description: 'nome' } } },
  flows: [...], signals: [...], tools, instructions, knowledgeBase, persona, goal,
});
ts
// ─── 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.


3. Flows: triggers replace when, steps get kinds

ts
// ─── v3 ───
{
  title: 'Triagem', when: ['quer saber como funciona', 'pede um orçamento'],
  requiredFields: ['nome', 'empresa'], reentrant: false,
  steps: [
    { id: 'quem', prompt: 'Descubra quem é.', collect: ['nome', 'empresa'] },
    { id: 'aviso', auto: true, hooks: { prepare: notifySeller } },
    { id: 'tchau', reply: 'Um vendedor continua daqui.' },
  ],
}
ts
// ─── v4 ───
import { falai } from '@falai/agent';
const f = falai().fields({ nome: { type: 'string' }, empresa: { type: 'string' } });

const triagem = f.flow({
  id: 'triagem', name: 'Triagem',
  on: [{ message: ['quer saber como funciona', 'pede um orçamento'] }],   // repeat: 'once' per session by default
  steps: [
    { id: 'quem',  prompt: 'Descubra quem é.', collect: ['nome', 'empresa'] },
    { id: 'aviso', do: 'notify', with: { recipient: 'owner', message: 'Lead: {{data.nome}} ({{data.empresa}})' } },
    { id: 'tchau', say: 'Um vendedor continua daqui.' },
  ],
  onEnd: 'end',   // or 'stay' (the last talk step answers every later message) or 'reset' (first step, data kept)
});
v3v4
titleid (required, stable) + name
when / if on the flowon: [{ message: [...], if }]
reentrant: truerepeat: 'always' on the trigger, plus clearOnStart
requiredFields, optionalFieldsgone; a run ends after its last step, onEnd says what then
endBehavior (app-side)onEnd: 'end' | 'stay' | 'reset'; go to another flow is then: { flow } on the last step
{ reply } step{ say }, with media? and once?
{ auto: true } step{ do }, { if } or { wait }
step descriptionlabel
hooks.prepare / finalize / onEnter / onExit / onCompletea do step at that position

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.

ts
// ─── v3 ───
branches: [{ when: 'quer falar com humano', then: { goTo: 'handoff' } }]
tools: [{ id: 'cancel', handler: (ctx) => ({ directive: { goTo: 'cancelamento' } }) }]
ts
// ─── 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.

ts
// ─── v3 ───
{
  id: 'concorrente', when: ['cita um concorrente', '!fala do nosso produto'], phase: 'post',
  behavior: 'once', extract: { trecho: { type: 'string' } },
  handler: ({ extracted, context }) => notify(context.lead, extracted.trecho),
}
ts
// ─── 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 facetv4
when[] with ! exclusionsmention: [...], exclusions and all — a ! phrase still rules the trigger out. Copy the list across unchanged
iftrigger if; sees input after extract
extracttrigger extract → run.input → {{input.x}}; never written to data
phase: 'pre' + halt + replya 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 / cooldownrepeat: 'once' / 'always' / { cooldown }
priority, stopOtherSignalsflow order; one speaker per turn
mention: [] + ifa 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

8. Tools return { value, data }

ts
// ─── v3 ───
handler: async (ctx, args) => ({ data: slots, dataUpdate: { horario: slots[0] }, directive: { goTo: 'confirmar' } })
ts
// ─── v4 ───
import { falai, type DataOf, type Tool } from '@falai/agent';
const f = falai().fields({ horario: { type: 'string' } });
declare const slots: string[];

const horarios: Tool<undefined, DataOf<typeof f>> = {
  id: 'horarios',
  handler: async (args, ctx) => ({ value: slots, data: { horario: slots[0] } }),
};

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.


9. Persistence: Store replaces PersistenceAdapter

ts
interface Store<D> {
  load(id: string): Promise<Session<D> | null>;
  save(session: Session<D>, expectedVersion: number): Promise<Session<D>>;   // 0 = insert if absent; stale → SessionConflictError
}

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.

StoreWhere a session lives
PostgresStore, SQLiteStoreone row: id, version, blob (JSONB / TEXT), created_at, updated_at
PrismaStoremodel AgentSession { id String @id; version Int; blob Json; createdAt DateTime; updatedAt DateTime }, names remappable with fieldMappings.sessions
MongoStoreone document: _id, version, blob (JSON text, so claim keys with dots survive), createdAt, updatedAt
RedisStoreone 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
OpenSearchStoreone 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.


12. Removed → replacement

RemovedReplacement
createAgent, new Agent(options) with schemafalai<C>().fields(defs).agent(options)
agent.respond / respondStreamagent.turn / turnStream
agent.dispatch, pendingDirective, Directive, flow.merge, flow.validatethen / else on steps
Flow class, Step class, flow namespace, FlowOptions, StepOptionsplain objects: Flow, Step
title, when, if, reentrant, requiredFields, optionalFields, onComplete, flow hooksid + name, on[], repeat, clearOnStart, onEnd, while
skip, auto, reply, step hooks, prepare, finalizeknown-field skipping, maxAsks, do, wait, say
requiresan 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.
Signal, SignalContext, SignalFiring, signals, signalBatchSize, triggeredSignalsmention flows, repeat, claims
ToolContext.updateContext / updateData / setField / dispatch, ToolResult.dataUpdate / contextUpdate / directive, ToolManager, ToolScope, tool config helpersTool.handler(args, ctx) → { value?, data? }
PersistenceAdapter, SessionRepository, MessageRepository, PersistenceManager, SessionManager, restoreSession, createPersistedState, enterFlow, enterStep, completeCurrentFlow, mergeCollectedStore, the seven *Store classes, migrateSession
SessionState, CollectedStateData, SessionDataSession
AgentResponse.executedSteps / stoppedReason / endedFlows / appliedInstructions / isFlowCompleteTurnResult.outcomes / started / ended / skipped / messages / schedule / llmCalls
Template as a function, TemplateContext, ConditionEvaluator, ConditionWhen, ConditionIfTemplate = 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, termsput the glossary in knowledgeBase or an instruction
Instruction.enabled / tags / metadatafilter before passing
promptCache, PromptSectionCache, PromptCacheConfiggone; every prompt is built per call. compaction stays and runs once per turn on the history you pass
generateFlowId, generateStepId, generateToolId, adaptEvent, convertHistoryToEventsids are yours; historyToEvents / eventsToHistory stay
ResponseGenerationError, ToolCreationError, ToolExecutionErrorProviderError (provider failures), FlowConfigurationError (bad config); a failing speak call re-parks the step instead of throwing

Providers (GeminiProvider, OpenAIProvider, AnthropicProvider, OpenRouterProvider, DeepSeekProvider, ZaiProvider, FallbackAiProvider, OpenAICompatibleProvider, ProviderAdapter) and the AiProvider seam are unchanged.


13. Codemod and verification

Find every site to touch:

bash
rg -n "createAgent|\.respond\(|respondStream|dispatch\(|pendingDirective|goTo|requiredFields|optionalFields|requires:|reentrant|auto: true|reply:|signals:|Signal<|phase: '(pre|post)'|PersistenceAdapter|restoreSession|SessionState|updateData|dataUpdate|directive" src

Then, in this order:

  1. Convert stored flows and signal rules to FlowSpec rows. Keep talk-step ids; give each migrated signal flow id = signal key.
  2. Register your actions, events and conditions on the agent.
  3. Replace the respond call site with load → turn → save + messages + schedules in one transaction.
  4. 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.
  5. Put migrateSession in your deserializer and let it throw on garbage.
  6. 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.