AI Flows
Durable multi-step AI pipelines — deterministic or agentic, with human-in-the-loop, chaining, and cadence. Author flows in code (defineFlow) or as data (visual-editor rows); one engine runs both.
@voltro/plugin-ai-flows turns a multi-step AI pipeline into a durable,
resumable run: an orchestrator that chains sub-agents and generation tools
(text / image / video / audio) into a bundled result, with human-in-the-loop
review, chaining (flow A feeds flow B), and cadence (scheduling). It's the
layer above @voltro/ai agents — an agent is code; a flow is data a
non-technical user can author in a visual editor and the framework interprets.
Two execution modes:
- Deterministic (
mode: 'deterministic') — run the plan steps in order, verbatim, no orchestrator LLM. Predictable, cheaper, resumable per step (a crash/deploy resumes from the first unfinished step via the workflow journal). - Agentic (
mode: 'agentic') — an orchestrator LLM drives the tools and may reorder/insert/skip the suggested plan, bounded bymaxSteps.
And two authoring front doors that lower to one engine:
- Code-first
defineFlow(...)— typed, testable, diffable; validates every{{ref}}at registration (dangling refs fail at boot, not mid-run). - Data-driven — a stored
ai_flowsrow (from a visual editor); hot-editable, no deploy.
Install
// app.config.ts
import { aiFlowsPlugin } from '@voltro/plugin-ai-flows'
export default { plugins: [aiFlowsPlugin()] } // contributes ai_flows + ai_flow_runsAuthor a flow (code-first)
import { defineFlow, flowStep } from '@voltro/plugin-ai-flows'
defineFlow({
name: 'blog-article',
brief: [{ key: 'topic', label: 'Topic', type: 'text', required: true }],
steps: [
flowStep.agent({ agentRef: 'writer', prompt: 'Draft a post about {{topic}}', outputKey: 'draft' }),
flowStep.human({ reviewMode: 'approve', prompt: 'Approve this draft?' }),
flowStep.media('image', { prompt: 'Hero image for: {{draft}}', outputKey: 'hero' }),
flowStep.note({ prompt: '# {{topic}}\n\n{{draft}}\n\n', outputKey: 'final' }),
],
})Each step writes context[outputKey]; later steps reference it as {{outputKey}}
(alongside {{briefKey}}). The result bundle is outputKey → value.
Step kinds
agent— delegate to a sub-agent (its system prompt + model), text-only.generate—text/image/video/audiovia@voltro/ai.structured— a JSON object.note— the interpolated prompt itself is the output (no model call).human— pause for review (approve/choice/text).
Human-in-the-loop
A human step sets the run to waiting and parks the workflow without holding
a worker (the durable suspend). The user answers via respondToFlow; a reject
ends the run cancelled, otherwise the answer lands in context and the run
resumes. Thousands of runs can wait on review for days at no runtime cost.
Chaining & cadence
chainTolaunches a follow-up flow on success (result ⊕ input → the child's brief);requireConfirmationstages it for the user to confirm.cadenceschedules runs —weekly(withintervalWeeks+anchorDate) ormonthly(weekOfMonth, incl.'last').cadenceMatches/nextRunsare exported so the editor's "next 3 runs" preview and the scheduler agree exactly.
Live timeline
ai_flow_runs is .reactive(): the engine patches the row (steps[], status,
costMicroUsd, …) as it runs, and the client's subscription streams the timeline
over CDC — no polling, no sockets.
Wiring (once per app)
The engine runs as one durable workflow; the operations are server helpers (a plugin route's context is subject-only, so the app owns the thin RPC surface):
// api/flows/flow.run.workflow.tsx
export { flowRunWorkflow as default } from '@voltro/plugin-ai-flows/workflow'
// api/flows/flow.run.workflow.server.tsx
import { buildFlowRunExecute, makeMediaGenerator } from '@voltro/plugin-ai-flows'
export default (ctx) => buildFlowRunExecute(ctx, {
resolveAgent: (ref) => /* → { system, model } */,
// No AI Gateway key? Route model strings to a direct provider (or honour a
// `provider/model` prefix). Absent → every call goes to the gateway.
resolveModel: (model) => providerFromEnv(),
// `run.tenantId` is the durable tenant from the run row — pin persistence to
// it so a resume stores into the right tenant even when the subject has none.
generateMedia: makeMediaGenerator({ put: ({ data, mediaType, run }) => storage.put({ bytes: data, contentType: mediaType, tenantId: run.tenantId }), ingestUrl: (u, run) => storage.ingestUrl(u, { tenantId: run.tenantId }) }, { resolveModel: (m) => providerFromEnv() }),
onEvent: (e) => /* notify via @voltro/plugin-notifications */,
memoryPrefix: (ownerId) => /* long-term memory */,
})
// api/flows/aiFlows.launch.action.server.ts (+ retry / cancel / respond)
import { launchFlow } from '@voltro/plugin-ai-flows'
export default (ctx) => (input) => launchFlow(ctx, input)
// The plugin OWNS ai_flows — import its CRUD helpers over the plugin's table
// instead of forking your own entity + a store bridge:
import { getFlow, listFlows, createFlow, updateFlow, deleteFlow } from '@voltro/plugin-ai-flows'
// e.g. api/flows/aiFlows.list.query.server.ts → (ctx) => () => listFlows(ctx)
// api/flows/flows.cadence.cron.tsx
import { defineSchedule } from '@voltro/runtime'
import { runCadenceTick } from '@voltro/plugin-ai-flows'
import { Effect } from 'effect'
export default defineSchedule({ cron: '*/15 * * * *', timezone: 'Europe/Berlin', handler: (s) => Effect.promise(() => runCadenceTick(s.app)) })Adopt @voltro/plugin-versioning on ai_flows for automatic edit history.
Deployment notes
- Durability: each generation is a durable step — deterministic runs resume
from the first unfinished step; agentic runs aren't replay-deterministic (LLM
planner), so a mid-run crash re-plans (
retry= fresh run). - Cadence + scale-to-zero:
dormancy: 'sleep'won't fire an in-process tick — use an external waker ortrigger: 'external'. - Media retention: not automatic — add a
*.cron.tsxdeleting old artifacts viastorage.delete(id)(a plain retention sweep would orphan the blobs). - Media tenant on resume:
generateMediareceivesrun.tenantId, read from the durable run row. Pin persistence to it (as above) rather than the caller subject — a resumed run executes under a tenant-less system subject, so reading the tenant from the subject would fail closed or store into the wrong tenant. - Cost is tracked in
costMicroUsd(micro-USD) on the run + per step; project to your display currency in the view.