Narratives, outcomes & queries

Make runs human-readable and machine-queryable — step narratives, outcome/inbox templates, the result reducer, and query handlers.

By default a run is just variables and a status. These four features make a run legible — to humans watching it and to programs reading its output.

Step narratives

A per-step narrative function returns human-readable progress text for the timeline:

step("charge", {
  next: "confirm",
  narrative: function chargeNarrative(ctx, status) {
    if (status === "running") return "Charging card for order " + ctx.vars.order_id + "…";
    if (status === "success") return "Charged order " + ctx.vars.order_id + ".";
    return "Charge failed.";
  },
}, charge);

The signature is fn(ctx, status, branch)status is running / success / failure, and branch is the chosen next step on a branching decision. A narrative that throws never affects execution.

Outcome & inbox templates

On workflow({}), two Mustache-style template maps render the run’s state as strings:

workflow({
  outcomeTemplates: {
    completed: "Order #{{order_id}} delivered to {{customer_id}}.",
    failed: "{{failed_step_label}} failed: {{error_summary}}",
    running: "{{current_step_label}} in progress…",
  },
  inboxTemplates: {
    approval_form: { title: "Approve order #{{order_id}}", subtitle: "{{total}} · {{customer_id}}" },
  },
  steps: [ /* … */ ],
});
  • outcomeTemplates feed the run-history outcome line. Built-ins include {{workflow_title}}, {{run_id}}, {{current_step_label}}, {{failed_step_label}}, {{error_summary}}, plus any variable.
  • inboxTemplates ({title, subtitle}) feed the human-in-the-loop inbox card when a run parks.

The result reducer — make runs queryable

result: (vars) => ({...}) is a terminal reducer: when a run reaches COMPLETED/FAILED the engine calls it with the final variables and persists the returned typed object as the run’s result.

workflow({
  name: "DailyEmailReply",
  outcomeTemplates: { completed: "Replied to {{sender}} — ticket {{ticket_id}}." },
  result: (vars) => ({
    reply: vars.reply_body,
    ticket_id: vars.ticket_id,
    resolved: vars.status === "done",
  }),
  steps: [ /* … */ ],
});

flowctl run status <exec> -o json then returns both outcome (natural language) and result (typed), and flowctl run list --status COMPLETED carries them on each summary.

outcome vs result

outcomeTemplates is the human string; result is the machine-readable, queryable contract. Give result strong, stable field names so agents and monitoring read result.<field> instead of scraping raw variables.

Queries — read-only, any time

query(name, opts, fn) declares a read-only view over variables. Queries are not part of the flow graph — run them at any time, mid-run, without advancing the run.

query("status", {}, function status(ctx) {
  return { state: ctx.vars.status, approved: ctx.vars.approved };
});
flowctl query list <def>           # available query names
flowctl query run <exec> status    # run it

Use result for the final output and a query for live reads while a run is still going.