7 July, 2026
This release brings composable flows, so a flow can run or hand off to another flow, plus shareable and public flows, live step progress, and parallel test runs.
Composable flows
Flows can now call other flows. A flow can run a child flow as a step and use its result, or hand the whole journey off to another flow once its own work is done.
Use ctx.flows.run to run a child flow inline and get its typed result back:
const result = await ctx.flows.run(DispatchOrderFlow, {
name: "dispatch-1",
inputs: { orderId: "1" },
});
return ctx.complete({ data: { dispatched: result.orderId } });The child flow is declared in your schema like any other flow and imported into the parent's function. Its inputs and result are fully typed, so branching on the result afterwards is plain TypeScript.
Use ctx.flows.handoff to complete the current flow and pass the journey to a successor. The successor keeps the same URL and trace, so it reads as one continuous run:
return ctx.flows.handoff(NextStageFlow, { inputs: { stage: "next" } });A composed flow behaves as a single flow to whoever runs it: one merged list of steps, ordered oldest first.
Some limits apply in this first version. Composition depth is capped at 8, and a journey can run at most 1000 child flows. A child flow runs with the parent's permissions and does not re-check its own run permission. Handing off from a child flow is not supported yet, so return a result to the parent instead. See Composing flows for the full guide.
Shareable and public flows
Flows can now be run by people outside your team. Mark a flow @externalAccess to let people run it through a signed link, or use @externalAccess(public: true) to make it runnable by anyone without a link.
flow ShareableFlow {
inputs {
name Text?
}
@externalAccess
@permission(actions: [share], roles: [Admin])
}
flow PublicRunFlow {
@externalAccess(public: true)
}Minting, listing, and revoking signed links is gated by a new share permission action, so only the roles you allow can hand out access. A single-use link counts as used only once a run completes, so a visitor who abandons or fails a run can retry the same link until one run succeeds.
Public flows can also mint shareable links, which is useful for baking per-record default inputs into a link, for example a per-order feedback link. A flow can carry external-facing copy in an externalAccess config block, so internal wording is not exposed to anonymous visitors.
This feature is available in preview. See External access for the full guide.
Live step progress
Step functions can report progress while they run. Each step receives a progress handle you can use to set a message, track a count, and log lines as work happens:
await ctx.step("import data", async ({ progress }) => {
progress.set({ message: "Importing rows", total: rows.length, unit: "rows" });
for (const row of rows) {
await importRow(row);
progress.increment();
}
progress.log("Skipped 3 rows with a missing SKU");
});For a step that does not need live updates, set a static message through the step options instead:
await ctx.step("sync", { loadingMessage: "Syncing" }, doSync);Progress writes are best effort and throttled to about one per second, so they are safe to call in a tight loop. See Function steps for the details.
Parallel test runs
keel test can run test files in parallel across isolated stacks, each with its own database and functions server:
keel test --parallel 4Files are shared across up to 8 runners and balanced by how long they took on the previous run. The default is --parallel 1, which runs serially as before. A new --no-logs flag hides application console output, and pressing Ctrl-C now cancels a run cleanly. See keel test.
Fixes and Improvements
- List actions: When a client sends
orderByto a list action, it now takes precedence over a schema@orderByon that action. The schema ordering still applies as a tiebreaker on any remaining fields, followed byid. This changes the result order for actions that set both. - Functions: Database queries now run under a Postgres statement timeout, so a slow query fails with a catchable error instead of running until the whole invocation is killed. The default is 780 seconds, overridable with the
KEEL_DB_STATEMENT_TIMEOUTenvironment variable. In a flow, a step whose query is cancelled fails and retries under its normal retry policy. - Flows: Flow UI callbacks such as
onLeaveandonSearchnow receive the run's inputs, which fixes a crash in flows that read inputs before their first UI page. - CLI: Running against an existing local database after upgrading no longer fails with a missing-column error. Keel reconciles its internal tables on boot.
For any issues or feedback, please contact us at help@keel.so.
Thank you for using Keel!