@crossdelta/flowcore
Executable, type-safe flows for CLI, MCP, tests and agents.
Define what should happen — not how it is rendered.
flowcore is a small functional toolkit for building declarative, executable flows
that can run in different hosts:
- interactive CLI
- non-interactive (tests, automation)
- MCP / AI tools
- future UIs or agents
It is not a prompt library.
It is a core execution primitive for flows.
Why flowcore exists
Most CLI tools tightly couple interaction with execution:
- user prompts
- control flow
- side effects
- rendering / TTY concerns
This coupling makes it hard to:
- test flows without a terminal
- reuse logic outside the CLI
- expose the same behavior to MCP or AI tools
- plan, simulate, or review side effects before execution
flowcore separates what happens from how it is driven.
One flow. Many hosts. Zero rewrites.
Core idea
Flow = declarative steps + typed context
Runner = decides how the flow is executed
You describe what happens.
The runner decides how interaction works.
Installation
bun add @crossdelta/flowcore
# or
npm install @crossdelta/flowcore
Quick example
import { createFlow, runFlow, type FlowContext } from '@crossdelta/flowcore'
interface UserContext extends FlowContext {
name: string
role: 'admin' | 'user'
}
const flow = createFlow<UserContext>()
const steps = [
flow.input('name').build(),
flow.select('role')
.choices(['admin', 'user'])
.build(),
flow.task('Save user')
.steps([
(ctx) => {
console.log(`Hello ${ctx.name} (${ctx.role})`)
},
])
.produces('name', 'role')
.build(),
]
await runFlow(steps, {
initialContext: {},
})
What makes flowcore different
Host-agnostic flows
The same flow can run:
- interactively in a CLI
- non-interactively in tests
- inside MCP tools
- later in agents or UIs
No branching.
No duplication.
Fully type-safe context
Each step enriches a shared, typed context:
interface MyContext extends FlowContext {
eventType: string
service?: string
}
TypeScript guarantees correctness across steps.
Non-interactive execution
await runFlow(steps, {
initialContext: {
name: 'Max',
role: 'admin',
},
})
Perfect for:
- tests
- automation
- AI execution
- dry-runs
Step primitives
Input
flow.input('eventType')
.prompt({
title: 'Event Type',
message: 'Event type to publish (e.g., order.created)',
})
.skipWhen((ctx) => ctx.eventType !== undefined)
.build()
Select
flow.select('role')
.choices(['admin', 'user'])
.optional('Skip')
.build()
Task
flow.task('Publish event')
.steps([
loadDataFromInput,
publishToNats,
])
.produces('data', 'published', 'usedMock')
.build()
Testing flows
Flows do not depend on TTY or rendering.
Note on .produces(...)
.produces(...)documents which context fields are guaranteed to exist or be modified after a task completes.It does not change runtime behavior, execute code, or perform validation. It is a declarative promise for readers, tooling, and future hosts (CLI, MCP, agents).
test('publish event flow', async () => {
const result = await runFlow(steps, {
initialContext: {
eventType: 'order.created',
},
})
expect(result.context.published).toBe(true)
})
No prompt mocks.
No renderer stubs.
Flow Style Guide
These rules are not enforced by the compiler, but strongly recommended.
1. Flows read top-to-bottom
A reader should understand the flow without jumping between files.
Good:
input → abort → input → task
Bad:
task → input → hidden condition → side effect
2. Prefer explicit steps
Always use steps([...]) for tasks with multiple operations.
Good:
flow.task('Publish event')
.steps([loadData, publish])
Avoid:
.run(loadData).run(publish)
3. Use skipWhen for flow decisions
skipWhen expresses control flow, not validation.
Good:
.skipWhen(hasExplicitData)
4. Abort early and explicitly
Use abortIf as its own step.
Good:
flow.abortIf(isInvalid, getErrorMessage)
5. Declare context mutations
Always document what a task mutates.
Good:
.produces('data', 'published')
6. Keep domain logic out of flows
Flows orchestrate.
Domain logic lives in functions called by steps.
7. Prefer small, named functions
Anonymous lambdas are fine for trivial logic only.
8. No side effects outside tasks
Inputs and conditions must be pure.
9. One flow, one story
If a flow feels too long, split it.
10. Flows are values
Treat flows as data that can be executed by different hosts.
Where flowcore fits
flowcore is ideal if you:
- build CLIs with complex wizards
- want to expose the same logic to MCP or AI tools
- care about testability and determinism
- want to separate interaction from execution
It is not intended for simple one-off prompt scripts.
Change tracking utilities
flowcore provides utilities for tracking operation outcomes (created/updated/skipped).
These are generic and host-agnostic — not tied to files or any specific domain.
When to use
Use ChangeResult when you need to:
- Track whether a resource was created, updated, or skipped
- Record outcomes to an artifacts sink (console, file system, database)
- React differently based on operation outcome
- Keep track of what changed during a flow
Non-fluent approach (recommended default)
import { change, recordChange, isCreated, type ArtifactsSink } from '@crossdelta/flowcore'
// Perform some operation
const result = await generateContract()
// Map to ChangeResult
const outcome = result.created
? change.created(result.path)
: change.skipped('Already exists')
// Record to artifacts sink
recordChange(ctx.artifacts, 'Contract', outcome)
// React based on outcome
if (isCreated(outcome)) {
ctx.effects.emit(contractCreated(outcome.ref, ctx.eventType))
}
Why this is the default:
- Explicit and clear
- Easy to follow in complex flows
- No hidden behavior
- Familiar imperative style
Fluent approach (optional sugar)
import { change, trackChange } from '@crossdelta/flowcore'
trackChange(change.created('/path/to/contract.ts'))
.record(ctx.artifacts, 'Contract')
.onCreated((ref) => {
ctx.effects.emit(contractCreated(ref, ctx.eventType))
})
Use sparingly:
- Nice for simple linear cases
- Avoid in complex conditional logic
- Remember:
.record()and.onX()hooks are independent (no implicit side effects)
API overview
Types:
type ChangeResult<Ref = string> =
| { status: 'created'; ref: Ref }
| { status: 'updated'; ref: Ref }
| { status: 'skipped'; reason?: string }
interface ArtifactsSink {
created(label: string, ref?: string): void
updated(label: string, ref?: string): void
skipped(label: string, reason?: string): void
}
Constructors:
change.created(ref) // => ChangeResult with status: 'created'
change.updated(ref) // => ChangeResult with status: 'updated'
change.skipped(reason?) // => ChangeResult with status: 'skipped'
Type guards:
isCreated(result) // narrows to { status: 'created'; ref: Ref }
isUpdated(result) // narrows to { status: 'updated'; ref: Ref }
isSkipped(result) // narrows to { status: 'skipped'; reason?: string }
Non-fluent helper:
recordChange(artifacts, label, result) // records to sink, returns result
Fluent helper:
trackChange(result)
.status() // get status
.ref() // get ref (or undefined if skipped)
.record(artifacts, label) // record to sink (returns this)
.onCreated(fn) // execute fn if created (returns this)
.onUpdated(fn) // execute fn if updated (returns this)
.onSkipped(fn) // execute fn if skipped (returns this)
Important: Separation of concerns
.record() and .onX() hooks are independent:
// ✅ This ONLY records to artifacts (hook NOT called)
trackChange(result).record(artifacts, 'File')
// ✅ This ONLY calls the hook (artifacts NOT recorded)
trackChange(result).onCreated((ref) => console.log(ref))
// ✅ Both together work independently
trackChange(result)
.record(artifacts, 'File')
.onCreated((ref) => console.log(ref))
Why this matters:
- No hidden behavior
- Explicit control over side effects
- Easy to reason about in complex flows
Philosophy
- Functional, not object-oriented
- Declarative, not imperative
- Testable by default
- Minimal surface area
- No magic
Flows are values. Execution is a detail.
License
MIT © crossdelta