Version: 2.0.0 — Clean break (no shims, no aliases)
Summary
v2 is a clean break — no shims, no aliases. The surface is smaller and the primitives are fewer: Route becomes Flow, three behavioral types collapse into Instruction, EnhancedTool folds into Tool, and all flow control converges on a single Directive shape. This guide covers every breaking change with rename tables and before/after code samples.
Other notable changes covered below: the pendingTransition session field becomes pendingDirective, Tool.name collapses into Tool.id, the ConditionTemplate union is replaced by separate when (AI) and if (code) fields, FlowOptions.onComplete is string-only at the top level, createAgent becomes the headline API, agent identity collapses from three fields (description, identity, personality) into one (persona), multi-step batching is replaced by auto: true steps, and step.branches is added as an explicit (non-breaking) alternative to implicit fork.
The SessionState.pendingTransition field is removed. Its replacement is SessionState.pendingDirective, a full Directive object (not just a flow id + reason).
v2 also adds a signals column for forward-compat with the v2.x Signals feature. It's unused at runtime but must be present in your schema.
There is no auto-migration. If a v1 record carries pendingTransition but no pendingDirective, v2 ignores it — the pending transition is lost. Run the backfill before deploying v2.
PostgreSQL / SQLite
sql
-- Step 1: Backfill pendingDirective from pendingTransition
UPDATE sessions
SET pending_directive = jsonb_build_object(
'goTo', jsonb_build_object('flow', pending_transition->'targetFlowId')
)
WHERE pending_transition IS NOT NULL
AND pending_directive IS NULL;
-- Step 2: Schema migration
ALTER TABLE sessions DROP COLUMN pending_transition;
ALTER TABLE sessions ADD COLUMN pending_directive JSONB;
ALTER TABLE sessions ADD COLUMN signals JSONB;
For SQLite (no JSONB), use TEXT columns and json_object():
sql
UPDATE sessions
SET pending_directive = json_object(
'goTo', json_object('flow', json_extract(pending_transition, '$.targetFlowId'))
)
WHERE pending_transition IS NOT NULL
AND pending_directive IS NULL;
ALTER TABLE sessions DROP COLUMN pending_transition;
ALTER TABLE sessions ADD COLUMN pending_directive TEXT;
ALTER TABLE sessions ADD COLUMN signals TEXT;
Extend your migration Lua script (see the Route → Flow rename Redis section for the pattern):
text
local cursor = "0"
repeat
local result = redis.call("SCAN", cursor, "MATCH", "session:*", "COUNT", 100)
cursor = result[1]
for _, key in ipairs(result[2]) do
local val = redis.call("GET", key)
if val then
-- Replace pendingTransition with pendingDirective in JSON
val = val:gsub('"pendingTransition"', '"pendingDirective"')
redis.call("SET", key, val)
end
end
until cursor == "0"
Note: Redis stores serialized JSON. The field-name swap is sufficient if your pendingTransition shape was { targetFlowId: string }. The v2 runtime expects { goTo: { flow: string } } — adjust the transform if your v1 shape was different.
The Route domain noun has been renamed to Flow across the entire @falai/agent package. This is a clean break with no compatibility shims, no dual-naming layer, and no runtime fallback for legacy field names. Every public symbol, configuration option, persisted column/field, adapter method, constant, error class, and utility function that referenced "Route" as a noun now uses "Flow". The verb form route() and the gerund "routing" (as used in prose and the routing.ts module) are preserved — routing-as-an-action remains the correct verb for selecting a flow.
Symbol Rename Table
Old
New
Layer
Action
Route (class)
Flow
Core
Update imports and instantiation
RouteOptions
FlowOptions
Type
Update type annotations
RouteRef
FlowRef
Type
Update type annotations
RouteTransitionConfig
FlowTransitionConfig
Type
Update type annotations
RouteCompletionHandler
FlowCompletionHandler
Type
Update type annotations
RouteLifecycleHooks
FlowLifecycleHooks
Type
Update type annotations
RoutingEngine
FlowRouter
Core
Update imports and references
RoutingEngineOptions
FlowRouterOptions
Type
Update type annotations
RoutingDecisionOutput
FlowRoutingDecisionOutput
Type
Update type annotations
RouteConfigurationError
FlowConfigurationError
Error
Update catch blocks
END_ROUTE
Removed
Constant
Implicit terminus — remove all references
END_ROUTE_ID
Removed
Constant
Implicit terminus — remove all references
generateRouteId
generateFlowId
Utility
Update calls
enterRoute
enterFlow
Utility
Update calls
StepRef.routeId
StepRef.flowId
Type
Update field access
Preserved (verb-form carve-outs)
These are not renamed:
route() method on FlowRouter (verb form)
RoutingDecision type (describes the act of routing)
'last_step' (no successor) or 'completed' (explicit directive)
Adapter Method Rename Table
Adapter
Old Method
New Method
MemoryAdapter
updateRouteStep()
updateFlowStep()
PrismaAdapter
updateRouteStep()
updateFlowStep()
RedisAdapter
updateRouteStep()
updateFlowStep()
MongoAdapter
updateRouteStep()
updateFlowStep()
PostgreSQLAdapter
updateRouteStep()
updateFlowStep()
SQLiteAdapter
updateRouteStep()
updateFlowStep()
OpenSearchAdapter
updateRouteStep()
updateFlowStep()
PersistenceManager
updateRouteStep()
updateFlowStep()
SessionRepository (interface)
updateRouteStep()
updateFlowStep()
If you implement a custom adapter, rename your updateRouteStep method to updateFlowStep.
Per-Adapter Data Migration
The framework no longer reads or writes the legacy field/column names. You must migrate your persisted data before deploying the new version.
PostgreSQL
sql
-- Sessions table
ALTER TABLE sessions RENAME COLUMN current_route TO current_flow;
-- Messages table
ALTER TABLE messages RENAME COLUMN route TO flow;
SQLite
SQLite 3.25+ supports ALTER TABLE ... RENAME COLUMN:
sql
-- Sessions table
ALTER TABLE sessions RENAME COLUMN current_route TO current_flow;
-- Messages table
ALTER TABLE messages RENAME COLUMN route TO flow;
For SQLite versions older than 3.25, use the copy-and-rename pattern:
sql
-- 1. Create new table with correct column names
CREATE TABLE sessions_new (
id TEXT PRIMARY KEY,
user_id TEXT,
agent_name TEXT,
status TEXT DEFAULT 'active',
current_flow TEXT,
current_step TEXT,
collected_data TEXT,
message_count INTEGER DEFAULT 0,
last_message_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 2. Copy data
INSERT INTO sessions_new SELECT
id, user_id, agent_name, status,
current_route AS current_flow,
current_step, collected_data, message_count,
last_message_at, completed_at, created_at, updated_at
FROM sessions;
-- 3. Drop old table and rename
DROP TABLE sessions;
ALTER TABLE sessions_new RENAME TO sessions;
-- Repeat for messages table (route → flow column)
Redis stores sessions as serialized JSON. Use a Lua script to rewrite the payload in-place:
text
-- redis-migrate-route-to-flow.lua
-- Run with: redis-cli --eval redis-migrate-route-to-flow.lua
local cursor = "0"
repeat
local result = redis.call("SCAN", cursor, "MATCH", "session:*", "COUNT", 100)
cursor = result[1]
local keys = result[2]
for _, key in ipairs(keys) do
local val = redis.call("GET", key)
if val then
-- Replace field names in JSON payload
val = val:gsub('"currentRoute"', '"currentFlow"')
val = val:gsub('"routeHistory"', '"flowHistory"')
val = val:gsub('"currentRouteTitle"', '"currentFlowTitle"')
redis.call("SET", key, val)
end
end
until cursor == "0"
-- If using hash layout instead of JSON:
-- Rename hash fields per session key
local cursor2 = "0"
repeat
local result = redis.call("SCAN", cursor2, "MATCH", "session:*", "COUNT", 100)
cursor2 = result[1]
local keys = result[2]
for _, key in ipairs(keys) do
local typ = redis.call("TYPE", key)["ok"]
if typ == "hash" then
local oldVal = redis.call("HGET", key, "currentRoute")
if oldVal then
redis.call("HSET", key, "currentFlow", oldVal)
redis.call("HDEL", key, "currentRoute")
end
end
end
until cursor2 == "0"
For message keys, apply the same pattern replacing "route" with "flow" in the JSON payload or hash field.
OpenSearch
Use the _reindex API with a painless script to rename fields:
The generateFlowId() function now produces IDs with the prefix flow_ instead of route_. Existing sessions stored under the legacy route_* prefix will not be recognized by the framework's flow-matching logic unless migrated.
Run this migration during a maintenance window. In-flight sessions will lose their step pointer if the rename is not atomic with the adapter restart.
PostgreSQL / SQLite
sql
UPDATE sessions
SET current_flow = REPLACE(current_flow, 'route_', 'flow_')
WHERE current_flow LIKE 'route\_%' ESCAPE '\';
If your collected_data JSON contains flowHistory entries with old IDs (stored as routeId before the field rename), update those as well:
sql
-- PostgreSQL (JSONB)
UPDATE sessions
SET collected_data = REPLACE(collected_data::text, '"route_', '"flow_')::jsonb
WHERE collected_data::text LIKE '%"route_%';
For downstream TypeScript consumers, here's a sed/codemod summary covering the most common public-API touchpoints:
bash
# Symbol renames (imports and references)
sed -i '' 's/\bRoute\b/Flow/g; s/\bRouteOptions\b/FlowOptions/g; s/\bRouteRef\b/FlowRef/g' src/**/*.ts
sed -i '' 's/\bRouteTransitionConfig\b/FlowTransitionConfig/g' src/**/*.ts
sed -i '' 's/\bRouteCompletionHandler\b/FlowCompletionHandler/g' src/**/*.ts
sed -i '' 's/\bRouteLifecycleHooks\b/FlowLifecycleHooks/g' src/**/*.ts
sed -i '' 's/\bRouteConfigurationError\b/FlowConfigurationError/g' src/**/*.ts
sed -i '' 's/\bRoutingEngine\b/FlowRouter/g' src/**/*.ts
# Constants (END_ROUTE removed — delete all references)
sed -i '' '/END_ROUTE/d; /END_FLOW/d' src/**/*.ts
# Methods and fields
sed -i '' 's/\.createRoute(/\.createFlow(/g' src/**/*.ts
sed -i '' 's/\.getRoutes(/\.getFlows(/g' src/**/*.ts
sed -i '' 's/\.nextStepRoute(/\.nextStepFlow(/g' src/**/*.ts
sed -i '' 's/\.getRoutingEngine(/\.getFlowRouter(/g' src/**/*.ts
sed -i '' 's/\brouteSwitchMargin\b/flowSwitchMargin/g' src/**/*.ts
sed -i '' 's/\bgenerateRouteId\b/generateFlowId/g' src/**/*.ts
sed -i '' 's/\benterRoute\b/enterFlow/g' src/**/*.ts
# Session state fields
sed -i '' 's/\.currentRoute/\.currentFlow/g' src/**/*.ts
sed -i '' 's/\.routeHistory/\.flowHistory/g' src/**/*.ts
sed -i '' 's/\btargetRouteId\b/targetFlowId/g' src/**/*.ts
# String literals
sed -i '' "s/'end_route'/'last_step'/g" src/**/*.ts
sed -i '' "s/'route_complete'/'completed'/g" src/**/*.ts
# Configuration
sed -i '' 's/routes:/flows:/g' src/**/*.ts # Be careful — review matches manually
# Import paths (if importing from @falai/agent internals)
sed -i '' 's/core\/Route/core\/Flow/g' src/**/*.ts
sed -i '' 's/core\/RoutingEngine/core\/FlowRouter/g' src/**/*.ts
sed -i '' 's/types\/route/types\/flow/g' src/**/*.ts
Important: These sed commands are aggressive. Run them, then use tsc --noEmit to catch any false positives (e.g., routes in an HTTP router context). Review the diff before committing.
Route → Flow Verification
After migrating, confirm no legacy route references remain:
Q: Is there a compatibility shim or deprecation period?
No. This is a clean break. The old names are removed entirely.
Q: Do I need to migrate my database before deploying?
Yes. The framework no longer reads or writes the legacy column/field names. Deploy the data migration first, then deploy the new code.
Q: What about the route() method I see on FlowRouter?
That's the verb form — it means "to route a message to a flow." It is intentionally preserved.
Q: My tests assert on 'end_route' or 'route_complete' — what do I do?'end_route' has been removed entirely (implicit terminus replaces it). Update to 'last_step'. 'route_complete' becomes 'last_step' (no successor) or 'completed' (explicit directive). TypeScript will flag these as type errors if you miss any.
Q: I have custom IDs that don't use the route_ prefix — do I need to migrate them?
Only IDs generated by generateRouteId() (now generateFlowId()) use the prefix. If you set custom IDs on your flows, they are unaffected by the prefix change.
4. Guideline / Rule / Prohibition → Instruction
The three behavioral primitives collapse into a single Instruction type with a kind discriminator:
Rename Table
v1
v2 Instruction.kind
Notes
Rule (always do)
kind: 'must'
Rendered as [must] prefix
Prohibition (never do)
kind: 'never'
Rendered as [never] prefix
Guideline (should do)
kind: 'should'
Default when kind omitted
Before / After
typescript
// ─── v1 ───
agent.addRule("Always greet the user by name");
agent.addProhibition("Never discuss competitors");
agent.addGuideline({
when: "User is a returning customer",
action: "Skip the introduction and get to the point",
});
// ─── v2 ───
agent.addInstruction({ kind: 'must', prompt: "Always greet the user by name" });
agent.addInstruction({ kind: 'never', prompt: "Never discuss competitors" });
agent.addInstruction({
kind: 'should',
when: "User is a returning customer",
prompt: "Skip the introduction and get to the point",
});
The same applies at flow and step scope. FlowOptions.rules, FlowOptions.prohibitions, FlowOptions.guidelines, and StepOptions.guidelines are all removed — use instructions at each level.
Prompt Rendering Change
The heading ## Behavioral Guidelines becomes ## Instructions. Line format:
typescript
[must] [Always] Always greet the user by name
[never] [Always] Never discuss competitors
[should] [When: User is a returning customer] Skip the introduction and get to the point
// ─── v1 ───
agent.addGuideline({
condition: "User is frustrated",
action: "Be extra empathetic and offer to escalate",
});
// ─── v2 ───
agent.addInstruction({
when: "User is frustrated",
prompt: "Be extra empathetic and offer to escalate",
});
This also applies to flow-scoped and step-scoped guidelines:
v1 injected a synthetic __COMPLETED__ step with a hardcoded prompt ("Send a brief, natural farewell message…") when a flow completed. This is gone. No framework-generated farewell message is emitted. Every word the user sees comes from your step prompts.
Idle-state release
When a flow completes and onComplete does not produce a transition:
session.currentFlow → undefined
session.currentStep → undefined
The flow is marked completed: true in flowHistory
The router excludes completed flows from future scoring
Next turn: routing runs fresh (or the no-flow fallback triggers)
Migration: Add an explicit closing step
typescript
// ─── v1: relied on framework-generated farewell ───
agent.createFlow({
title: "Onboarding",
steps: [
{ id: "name", collect: ["name"] },
{ id: "email", collect: ["email"] },
],
});
// Framework would auto-generate "Thank you! I've recorded all..."
// ─── v2: author your own closing turn ───
agent.createFlow({
title: "Onboarding",
steps: [
{ id: "name", collect: ["name"] },
{ id: "email", collect: ["email"] },
{ id: "thanks", prompt: "Thank the user warmly. Wish them a great day." },
],
});
flow.reentrant opt-in
If your v1 code relied on the router re-entering a completed flow, that loop is gone. To restore it deliberately:
When reentrant: true, the router can re-select this flow after it completes. On re-entry, fields declared in requiredFields / optionalFields are cleared so the flow starts fresh.
onComplete always wins over reentrant — if onComplete returns a target, the session goes there instead.
8. createAgent — The New Headline API
createAgent is the recommended entry point in v2. It's equivalent to new Agent(options) but reads better in examples and enables stronger generic inference.
new Agent(options) still works — createAgent is sugar, not a replacement.
9. Tool / EnhancedTool Merge
EnhancedTool is removed. Its optional metadata fields are now part of the base Tool interface. Tool.name is also removed — Tool.id is the sole identifier and is what the LLM sees as the tool name.
Choose descriptive IDs — the LLM sees them. The optional fields that moved to Tool: isConcurrencySafe, isReadOnly, isDestructive, interruptBehavior, maxResultSizeChars, validateInput, checkPermissions.
New: ToolContext.dispatch and ToolResult.directive
dispatch sets session.pendingDirective — the directive is applied at the start of the next turn (not immediately). For synchronous in-place application without a respond() call, use agent.applyDirective(directive, session).
Three v1 agent fields — description, identity, personality — collapse into a single persona field. persona is a Template<TContext> covering role, tone, and self-concept. Merge your old copy into one coherent prompt.
Before / After
typescript
// ─── v1 ───
const agent = createAgent({
name: 'Support Bot',
description: 'A helpful customer support agent',
identity: 'You are a senior support specialist at Acme Corp.',
personality: 'Friendly, concise, solution-oriented',
// ...
});
// ─── v2 ───
const agent = createAgent({
name: 'Support Bot',
goal: 'Help customers resolve issues quickly',
persona: 'You are a senior support specialist at Acme Corp. Communicate in a friendly, concise, solution-oriented style.',
// ...
});
Flow-level identity / personality removed
FlowOptions.identity and FlowOptions.personality are removed. Use the agent-level persona for global voice, or a flow-level instruction for flow-specific voice:
typescript
// Before
const flow: FlowOptions = {
title: 'Billing',
identity: 'You are a billing specialist',
personality: 'Formal and precise',
};
// After — use a flow-level instruction
const flow: FlowOptions = {
title: 'Billing',
instructions: [
{ kind: 'should', prompt: 'Act as a billing specialist. Use formal, precise language.' },
],
};
Flow-level terms / knowledgeBase removed
Terms and knowledge base are now agent-level only. Move them up:
Multi-step batching (maxStepsPerBatch, BatchExecutor, BatchPromptBuilder) conflated two different concerns: skipping the LLM for non-interactive nodes, and compressing N user-facing steps into one response. v2 splits them: the first becomes auto: true, the second becomes a single step with multi-field collect.
Step events (step_entered, step_skipped, step_completed) — auto: boolean in payload
StoppedReason: 'max_steps_reached'
'max_auto_steps'
Restructure pattern: N tiny ask-steps batched into one call → one step with collect: [field1, field2, field3] and a prompt that asks for whatever is still missing. Pre-extraction handles the "user dumped everything in one message" case.
Auto-step pattern: Computation between asks → mark the compute step auto: true. It runs onEnter / prepare / branches / onExit with no LLM call. The pipeline walks consecutive auto-steps until it hits an interactive step or terminating directive.
Validation: an auto: true step throws FlowConfigurationError if it sets prompt, collect, tools, or finalize. onEnter, prepare, onExit, branches, requires, skip are all allowed. No schema change — auto-steps does not touch SessionState, existing persistence adapters need no migration.
This is optional. The implicit-fork pattern (multiple successor steps each with their own step.when) still works in v2. step.branches is a new declarative form for the same routing behavior, recommended when the fork is the point of the step rather than incidental to it.
When to convert
The source step exists solely to route — it's a decision point, not a conversation node.
Three or more successors with when conditions where the decision tree is hard to follow.
You want code-only routing (if) to skip LLM evaluation entirely.
You need mixed targets: some branches go to local steps, others jump to other flows or emit full Directives.
Stay implicit when
The flow reads as a linear chain A → B → C where B is occasionally skipped.
Only two successors, with self-explanatory when conditions.
Before / After
typescript
// Before — routing scattered across 4 target steps
{
steps: [
{ id: 'triage', prompt: 'How can I help?' },
{ id: 'billing', when: 'asking about billing', prompt: '…' },
{ id: 'tech_support', when: 'asking a technical question', prompt: '…' },
{ id: 'cancellation', when: 'wants to cancel', prompt: '…' },
{ id: 'general', prompt: '…' },
],
}
// After — routing declared once at the source
{
steps: [
{
id: 'triage',
prompt: 'How can I help?',
branches: [
{ when: 'asking about billing', then: 'billing' },
{ when: 'asking a technical question', then: 'tech_support' },
{ when: 'wants to cancel', then: 'cancellation' },
{ then: 'general' }, // unconditional fallback (must be last)
],
},
{ id: 'billing', prompt: '…' },
{ id: 'tech_support', prompt: '…' },
{ id: 'cancellation', prompt: '…' },
{ id: 'general', prompt: '…' },
],
}
Runtime behavior is identical: AI evaluates entries in declaration order; first match wins; an entry without when/if is the fallback. The only difference is where the routing logic lives.
Mixed targets
Branches can route to local step ids, flow ids (sugar for goTo), or full Directives — implicit forks can only target steps in the same flow: