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
.mtsor.mjsand lives inside the reflex directory. It is a module, whatever a nearbypackage.jsonsays. 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 Reflextypes the arguments from the manifest and checks the return.evoke checkwritesreflex.d.tsnext to the body whenever the arguments change. Commit it.
What the body receives
(args: Args, context: { input: string; config: Config; signal: AbortSignal })
| Value | Is |
|---|---|
args | Per 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 |
input | The sentence as typed. The classifier cannot write, so free text like a reminder's message arrives here |
config | Each [config] key as a string, secrets resolved from the environment for this run only |
signal | Aborted 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,LANGandTERM, nothing else. Secrets reach the body throughconfig, 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
stdoutandconsolego to stderr, so a strayconsole.lognever 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, thenSIGKILL. - 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 withENOENT.
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.