Examples · 05 of 06
A runbook where each step names the next.
Drain, fail over, verify. Each step's program returns the next sentence, and a six-line loop submits it. Every hop passes the gate on its own, and only the destructive one stops for a person. A pattern for your app. The database bodies shown are stand-ins that return fixed lines.
The request and the result
01Three steps, one sentence typed.
The person types the first step. The program names the second, and the second names the third. Every line is what Node printed.
- 1A step names the next. The program returns
data.next, a sentence. The loop submits it as if a person had typed it. - 2Every hop passes the bar. Drain is a write, verify is a read. Each was decided and gated on its own.
- 3Only the failover asks. It is destructive, so it confirms every time, however sure the classifier was.
The operations
02Three reflexes and a loop.
A manifest and a body per step, as files or as code. The loop is the whole application: while there is a next sentence, handle it.
reflex = 1 description = """ Drain traffic from the primary database. New connections go to the replica; open ones finish.""" effect = "write" confirm = "Drain the primary?" run = "drain.mts" [examples] "drain the primary" = {}
import type { Reflex } from "./reflex.d.ts" export default (async () => ({ text: "primary drained", data: { next: "fail over to the replica" }, })) satisfies Reflex
// The same runbook, in code. Three reflexes, one loop. import { load, reflex } from "@evoke-build/evoke" import { jev } from "@evoke-build/evoke/jev" import { done, handlers } from "../terminal.ts" const drain = reflex({ description: "Drain traffic from the primary database.\n" + "New connections go to the replica; open ones finish.", effect: "write", confirm: "Drain the primary?", examples: { "drain the primary": {} }, }, async () => ({ text: "primary drained", data: { next: "fail over to the replica" }, })) const failover = reflex({ description: "Promote the replica to primary.\n" + "The old primary is demoted; it cannot be undone from here.", effect: "destructive", confirm: "Promote the replica now?", examples: { "fail over to the replica": {} }, }, async () => ({ text: "replica promoted", data: { next: "check that writes land on the new primary" }, })) const verify = reflex({ description: "Check that writes land on the current primary.", effect: "read", confirm: "Check writes?", examples: { "check that writes land on the new primary": {} }, }, async () => "writes landing on the new primary") const reflexes = { drain, failover, verify } const runbook = await load({ reflexes, adapter: jev() }) let next: string | undefined = process.argv[2] while (next) { const step = await runbook.handle(next, handlers) if (step.outcome !== "ran") break console.log(step.result.text) next = (step.result.data as { next?: string } | undefined)?.next } done()
// A runbook as sentences, from files. Each step's body names the next step // in words; only the destructive one stops for a person. import { load } from "@evoke-build/evoke" import { done, handlers } from "../terminal.ts" const runbook = await load({ root: import.meta.dirname }) let next: string | undefined = process.argv[2] while (next) { const step = await runbook.handle(next, handlers) if (step.outcome !== "ran") break console.log(step.result.text) next = (step.result.data as { next?: string } | undefined)?.next } done()
// The two handlers every app here shares. // A confirm is a question; an ask is a numbered menu, or a blank to type into. import { createInterface } from "node:readline/promises" import type { Handlers } from "@evoke-build/evoke" const { stdin: input, stdout: output } = process const terminal = createInterface({ input, output }) // Fits a project of any reflexes export const handlers: Handlers<any> = { confirm: async d => { const typed = await terminal.question(`${d.prompt.template} [y]es [n]o > `) return typed.startsWith("y") }, ask: async d => { const given: Record<string, string> = {} for (const { arg, ask, choices } of d.missing) { const keys = choices.type === "options" ? Object.keys(choices.options) : choices.type === "vocab" ? Object.keys(choices.words) : [] const menu = keys.map((key, i) => `[${i + 1}] ${key}`).join(" ") const typed = await terminal.question(`${ask} ${menu}${menu && " "}> `) given[arg] = keys[Number(typed) - 1] ?? typed } return given }, } export const done = () => terminal.close()
- 1The bodies are stand-ins. Each returns its line and the next sentence. Yours would drain, promote and check a real database.
- 2The loop stops the moment a step does not run. A no, an abstain or a failure ends it. Nothing after runs, and nothing before is undone.
- 3The same loop over files or code.
loadtakes a root withevoke.toml, or the reflexes handed as objects.
One boundary
03The destructive step waits for a yes.
The failover cannot be undone from where it runs, so its manifest says destructive.
Answer no, and the loop ends there. The drain stays done. The verify never runs.
$ node runbook.ts "drain the primary" primary drained Promote the replica now? [y]es [n]o > n
The question is the reflex's own confirm line. A confirm handler that returns
false makes handle() answer declined, and the loop's break does the rest.
handle, in the manual →
Before you run it
04What this pattern needs.
- Node 24.5 or newerAnd the SDK,
@evoke-build/evoke. - A classifier keyOne request per step. The provider bills the use.
- Real bodiesThe three shown return fixed lines. Yours would talk to the database, and honour
the deadline's
signal. - Recovery, decided by youA step that fails stops the rest. What to do about the steps already done is the application's call.
The files above are the whole example. Copy them from this page.