Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The body

The body is what run names. It is either a JavaScript file with a function as its default export, or a program with its arguments. evoke calls it once per decision, with the arguments filled in, a context, and a deadline.

A file body

run = "lights.mts"
import type { Reflex } from "./reflex.d.ts"     // Args, Config, Context, Result, Reflex — written by evoke check

export default (async ({ room, state, brightness = 30 }, { config, signal }) => {
  await hue(config, room, { on: state !== "off", bri: state === "dim" ? brightness : 100 }, signal)
  return `${room} lights ${state}`
}) satisfies Reflex
  • The file ends in .mts or .mjs and lives inside the reflex directory. It is a module, whatever a nearby package.json says. It runs on Node 24 or newer, which strips types on its own. There is no build step.
  • It is self-contained. Web APIs and node: builtins are there. Anything else is bundled into the file. A reflex commits what it needs.
  • satisfies Reflex types the arguments from the manifest and checks the return. evoke check writes reflex.d.ts next to the body whenever the arguments change. Commit it.

What the body receives

(args: Args, context: { input: string; config: Config; signal: AbortSignal })
ValueIs
argsPer argument: an option's key; a vocabulary word's value, or the word; a pick's value, meaning the number, the seconds, or the text; true for a flag. An optional argument left unstated is absent
inputThe sentence as typed. The classifier cannot write, so free text like a reminder's message arrives here
configEach [config] key as a string, secrets resolved from the environment for this run only
signalAborted at the deadline, when the user declines, and on SIGTERM. Stop. Never guess

What it returns

A string: the one line a person reads. Or { text, data? }, where data is anything an app might use: a pid, a time, a path. Throwing is failure: the message prints, and the exit code is 1. Anything else returned is a failure too.

Say what happened, in one lowercase line: volume 40%, locked, saved report.pdf to ~/Downloads (1.2 MB).

The run

  • Environment. Scrubbed: PATH, HOME, TMPDIR, LANG and TERM, nothing else. Secrets reach the body through config, never the environment.
  • Deadline. 30 seconds per decision, shared with the classifier's answer. Anything that must outlive the run, like a timer or caffeinate, detaches and returns at once.
  • Output. The result is what the function returns. The body's own stdout and console go to stderr, so a stray console.log never corrupts a result.
  • Process. The CLI starts the runtime as the decision begins, so a body is warm when the answer lands. Its life is bounded by evoke's. On timeout or decline, the process group is ended: SIGTERM, a second's grace, then SIGKILL.
  • Platform. A body that runs on one platform says so on its first line: throw new Error("runs on macOS only"). That beats letting a missing program fail with ENOENT.

An argv body

run = ["networksetup", "-setairportpower", "en0", "{state}"]

A program and its arguments, run directly, never through a shell. The first element is a literal: the program. It is a name found on PATH, or an absolute path. It is never a path relative to wherever evoke runs. A placeholder is a whole element. It names an options, vocab or pick argument, and is replaced by the option key, the word's value or the word, or the pick's text. An element whose optional argument is unstated is dropped. A value that would start with - is refused. Config arrives as EVOKE_CONFIG_<KEY>, and the input as EVOKE_INPUT. Stdout is the result. A non-zero exit is failure. No runtime is needed.

Flags and literal braces cannot be expressed in an argv. Write a file instead.

The generated types

// reflex.d.ts — generated by evoke check; do not edit.
export interface Args {
  /** How long? */
  duration: number
  /** What is the timer for? */
  label?: string
}
export interface Config {}
export interface Context { input: string; config: Config; signal: AbortSignal }
export type Result = string | { text: string; data?: unknown }
export type Reflex = (args: Args, context: Context) => Result | Promise<Result>

Option keys become a union: "on" | "off" | "dim". A word, a quoted text, an address and a URL are string. A number and a duration are number. A flag is true. An optional argument is marked ?. Each member carries its ask or about, so an editor's hover shows the question. The file imports nothing.

Testing a body

A body is a function. Import it and call it. The collection's own tests do exactly that, under node --test, with a context built by hand: { input: "", config: { file }, signal: new AbortController().signal }. Nothing of evoke is needed to unit-test a reflex.

Next: Examples and tests.