evoke

Six sentences, at work.

Each one becomes a call of a small program, checked before it runs. The same recipe as a file anyone can install, or as code your app keeps. Every scene is a real session.

An ops channel

01

Scale, silence, roll back.

Three recipes for one channel. The one that wraps kubectl is a manifest and nothing else. A write runs when the bar is met. A rollback always asks.

~/ops
$ evoke "silence db-3 for 2 hours" silence host="db-3" duration="2 hours" 0.99 db-3 silenced until 01:12 AM
scale/reflex.tomlthe author's
reflex = 1

description = """
Scale a service to a number of replicas.
One deployment in one environment; the number is set, never added to."""
not_for = [
  "restarting or rolling back a service",
  "resizing a database or a disk",
  "autoscaling rules",
]
tags    = ["ops"]
effect  = "write"
confirm = "Scale {service} in {env} to {replicas}?"
run     = [
  "kubectl", "-n", "{env}", "scale", "deployment", "{service}",
  "--replicas", "{replicas}",
]

[args.service]
ask   = "Which service?"
vocab = "services"

[args.env]
ask   = "Which environment?"
vocab = "envs"

[args.replicas]
ask   = "How many replicas?"
pick  = "number"
range = [1, 20]

[examples]
"scale checkout to 6 in staging"   = { replicas = "6" }
"run 3 replicas of search in prod" = { replicas = "3" }
"take payments down to 1 replica"  = { replicas = "1" }

[tests]
"bring checkout up to 12 in prod" = { replicas = "12" }
"scale checkout"                  = { replicas = false }
"restart checkout"                = false
vocab/services.tomlyours
checkout = { what = "The checkout API.", value = "checkout-api" }
search   = { what = "The search API.", value = "search-api" }
payments = { what = "Payments; also 'the gateway'.", value = "payments-svc" }
  1. 1kubectl, no code. An argv recipe is a manifest and nothing else.
  2. 2Your names, from a vocabulary. The value is what the program gets. Jev sees the word.
  3. 3In code, the same text. with hands the names over, from a database in a real app.

An inbox

02

Lines in, decisions out.

The same three recipes, fed a file. What is sure runs. What needs a person is queued with its ranking. What is nobody's stays untouched.

~/ops
$ node queue.ts < inbox.txt deployment.apps/checkout-api scaled queued · ask · "silence db-3" queued · abstain · "what is the weather like"
inbox.txtyours
scale checkout to 6 in staging
silence db-3
what is the weather like
  1. 1At the terminal, exit 3 is the queue. evoke < inbox.txt names the command a person should run.
  2. 2The ranking travels with it. Every decision carries its contenders and what is missing.
  3. 3Nothing was invented. A question about the weather is nobody's, and stays so.

A payments desk

03

The maker types. The checker says yes.

A payee is one of your words, never invented. Paying is destructive, so the decision waits as plain data until a second person confirms it.

~/desk
$ node checker.ts pay payee="acme" account="ops" amount="12400" ref="invoice 8812" pay payee="acme" account="ops" amount="12400" ref="invoice 8812" · destructive · weakest: route 1.00 paid 12400 from acc_ops, transfer tr_0f3a
pay/reflex.tomlthe author's
reflex = 1

description = """
Pay an approved payee from one of our accounts.
One transfer, released after a second person confirms it."""
not_for = [
  "adding or changing a payee",
  "moving money between our own accounts",
  "asking a balance",
]
tags    = ["treasury"]
effect  = "destructive"
confirm = "Pay {payee} {amount} from {account}?"
run     = "pay.mts"

[config]
api   = "The payments API address"
token = { about = "The payments API token", secret = true }

[args.payee]
ask   = "Which payee?"
vocab = "payees"

[args.account]
ask   = "From which account?"
vocab = "accounts"

[args.amount]
ask   = "How much?"
pick  = "number"
range = [1, 250000]

[args.ref]
ask      = "What reference?"
pick     = "quoted"
optional = true

[examples]
'pay Acme 12400 from ops, ref "8812"' = { amount = "12400", ref = "8812" }
"send 950 to the cleaners from the office account" = { amount = "950" }
"wire 3200 to Acme from treasury" = { amount = "3200" }

[tests]
'settle 18000 with Acme, ref "Q3"' = { amount = "18000", ref = "Q3" }
"pay Acme from ops"                = { amount = false }
"add Acme as a new payee"          = false
vocab/payees.tomlyours
acme     = { what = "Acme, the packaging supplier.", value = "py_acme" }
cleaners = { what = "Blitz, the office cleaners.", value = "py_blitz" }
  1. 1A payee is a word. Its value is an id in the payments system. Jev never sees it.
  2. 2The decision is plain data. It waits in any queue, and only confirmed: true runs it.
  3. 3Each desk, its own words. with compiles the desk's payees in milliseconds.

A plant floor

04

Stop the filler.

A line is a word whose value is the controller's id. The night shift's phrases live in an overlay the plant owns, and the shipped recipe never changes.

~/plant
$ evoke "the filler is jammed, stop it" stop line="line 3" · destructive · weakest: route 0.98 Stop line 3 at the end of this cycle? [y]es [n]o [t]each > y L3 stops at the end of this cycle
stop/reflex.tomlthe author's
reflex = 1

description = """
Stop a production line.
A controlled stop at the end of the cycle; restart at the panel."""
not_for = ["an emergency stop", "pausing one machine", "holding a work order"]
tags    = ["floor"]
effect  = "destructive"
confirm = "Stop {line}?"
run     = "stop.mts"

[config]
gateway = "The line controller gateway address"

[args.line]
ask   = "Which line?"
vocab = "lines"

[examples]
"stop line 3"                  = {}
"shut the packaging line down" = {}
"bring line 2 to a stop"       = {}

[tests]
"halt line 3 after this cycle" = {}
"emergency stop"               = false
"hold work order 4471"         = false
  1. 1A line is a word. Its value, L3, is what the gateway gets.
  2. 2The overlay is the plant's. The shipped recipe never changes. The night shift's phrases are one file.
  3. 3Reflexes in code, words in files. The floor edits words/, never the code.

A runbook

05

Each step names the next.

Three recipes in a chain. Every hop passes the bar, and only the destructive one stops for a person.

~/runbook
$ node runbook.ts "drain the primary" primary drained Promote the replica now? [y]es [n]o > y replica promoted writes landing on the new primary
drain/reflex.tomlthe author's
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" = {}
drain/drain.mtsthe author's
import type { Reflex } from "./reflex.d.ts"

export default (async () => ({
  text: "primary drained",
  data: { next: "fail over to the replica" },
})) satisfies Reflex
  1. 1A step names the next. The program returns data.next, a sentence.
  2. 2Every hop passes the bar. Drain is a write, verify is a read.
  3. 3Only the failover asks. A destructive recipe always confirms.

A lesson

06

A miss becomes a lesson.

Claude proposes the line, evoke teach checks it at the door, evoke test keeps it only when nothing regresses, and git is the undo.

~/ops
$ evoke try "make db-3 shut up for 90 minutes" silence 1.00 · none 0.00 · scale 0.00 · rollback 0.00 host db-3 0.93 · unstated 0.07 · web-1 0.00 duration "90 minutes" 1.00 · unstated 0.00 fits silence 0.82 · scale 0.08 · rollback 0.08 run · weakest: host 0.93
teach.tsyours
// What evoke could not decide becomes wording. A model proposes the lessons,
// evoke test disposes, git undoes.
import { execFile } from "node:child_process"
import { readFile } from "node:fs/promises"
import { promisify } from "node:util"
import Anthropic from "@anthropic-ai/sdk"
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod"
import { z } from "zod"

const run = promisify(execFile)
// evoke's own lines are on stderr; stdout is a reflex's result
const evoke = async (...args: string[]) => (await run("evoke", args)).stderr

// A miss: a sentence nobody's, or one that stopped under the bar
const logFile = `${process.env.HOME}/.local/state/evoke/log.jsonl`
const log = await readFile(logFile, "utf8")
const decisions = log.trim().split("\n").map(line => JSON.parse(line))
const missed = (d: { outcome: string; because?: { type: string }[] }) =>
  d.outcome === "abstain" || d.because?.some(b => b.type === "under_floor")
const undecided = new Map<string, string>(
  decisions.filter(missed).map(d => [d.input, d.call ?? "nothing"]),
)

const names = (await evoke("show")).trim().split("\n")
  .map(row => row.trim().split(/\s+/)[0]!)
  .filter(name => name !== "inactive")
const manifests = await Promise.all(names.map(name => evoke("show", name)))

const brief = `Below are the reflexes of an evoke project, then sentences it
could not settle. For each sentence that can only mean one reflex, write the
call it means in evoke's grammar, "name arg=value …": only option keys,
vocabulary words and exact spans of the sentence may be values. Leave out a
sentence that fits no reflex, or two.`
const misses = [...undecided]
  .map(([input, call]) => `${input}\n  evoke proposed: ${call}`)
const Lessons = z.object({
  lessons: z.array(z.object({ utterance: z.string(), call: z.string() })),
})
const { parsed_output } = await new Anthropic().messages.parse({
  model: "claude-opus-5",
  max_tokens: 4000,
  system: `${brief}\n\n${manifests.join("\n")}`,
  messages: [{ role: "user", content: misses.join("\n") }],
  output_config: { format: zodOutputFormat(Lessons) },
})

// A value evoke does not know is refused at the door
for (const { utterance, call } of parsed_output?.lessons ?? []) {
  process.stdout.write(await evoke("teach", utterance, call))
}
// Green keeps the lessons; a regression drops them
try {
  process.stdout.write(await evoke("test"))
} catch {
  await run("git", ["checkout", "--", "overlays"])
  console.log("a regression: the lessons are undone")
}
  1. 1A miss is a sentence under the bar. From the log, with the call Jev proposed.
  2. 2Checked at the door. evoke teach refuses a value the recipe does not know.
  3. 3Kept only if green. evoke test judges every record. A regression is undone with git.

Run it

07

Every scene above is a real session.

The two handlers every app shares are twenty lines. Install evoke, add the SDK, and say it yourself.

terminal.tsyours
// 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()

The CLI

Fetches the release for your machine, checks its digest, and puts evoke in ~/.local/bin.

curl -fsSL https://evoke.build/install.sh | sh

The SDK

Node 24 or newer, ES modules, no dependencies. The core ships inside as WebAssembly.

npm install @evoke-build/evoke

Then the first ten minutes, and the SDK's first hour.