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 by maxSteps.

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_flows row (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_runs

Author 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![hero]({{hero}})', 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.
  • generatetext / image / video / audio via @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

  • chainTo launches a follow-up flow on success (result ⊕ input → the child's brief); requireConfirmation stages it for the user to confirm.
  • cadence schedules runs — weekly (with intervalWeeks + anchorDate) or monthly (weekOfMonth, incl. 'last'). cadenceMatches / nextRuns are 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 or trigger: 'external'.
  • Media retention: not automatic — add a *.cron.tsx deleting old artifacts via storage.delete(id) (a plain retention sweep would orphan the blobs).
  • Media tenant on resume: generateMedia receives run.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.