evoke Get started

Give your software a way to be asked.

A function, a script or a command becomes a reflex with one file beside it. Decisions run inside your app, typed, before anything happens. Eight parts, from the operation you already have to where evoke fits beside an LLM.

Your operation

01

Start with an operation you already have.

evoke new writes a reflex that already works. The body is your code. It receives typed arguments, returns one line, and can call anything you already have.

hello/hello.mtsyour code
import type { Reflex } from "./reflex.d.ts"

export default (async ({ who }) => `Hello, ${who}!`) satisfies Reflex
a terminal
$ evoke new hello
+ hello/reflex.toml
+ hello/hello.mts
+ hello/reflex.d.ts
$ cd hello && evoke check
  hello  read  runs hello.mts
$ evoke run hello who=Ada
  hello who="Ada"
Hello, Ada!

What stays your responsibility

  • The body. A JavaScript file with a default export, or a program with its arguments. Everything it needs is bundled into its directory.
  • Its result. One lowercase line a person reads, or { text, data } when an app needs more. Throwing is failure.
  • Its deadline. Thirty seconds per decision, for the classifier's answer and the body together. The body gets a signal and stops when it fires.
  • Its platform. A body that runs on one system only checks that first, and throws a plain error elsewhere.

evoke run calls the body by name and asks no classifier, so you can develop offline. reflex.d.ts is generated from the manifest and types the arguments.

Build your first reflex →

The manifest

02

Describe the boundary.

The manifest is what the classifier reads and what the body receives. Its wording decides accuracy. Every input has exactly one source, so a sentence can never invent a value.

lights/reflex.tomlthe whole shape
reflex = 1

description = """
Turn the lights in one room on, off, or dim them.
Ceiling and lamp lights only."""
not_for = ["colour scenes and schedules", "asking whether a light is on"]
effect  = "write"
confirm = "Set the {room} lights {state}?"
run     = "lights.mts"

[config]
bridge = "Hue bridge address"

[args.room]
ask   = "Which room?"
vocab = "rooms"

[args.state]
ask         = "What should the lights do?"
options.on  = "Switch on."
options.off = "Switch off."
options.dim = "Lower the brightness without switching off."

[args.brightness]
ask      = "How bright, in percent?"
pick     = "number"
range    = [1, 100]
optional = true

[examples]
"turn on the kitchen lights"   = { state = "on" }
"dim the office to 30 percent" = { state = "dim", brightness = "30 percent" }

[tests]
"make it darker in here" = { state = "dim" }
"light a candle"         = false
options · vocab · pick · flag
The four sources of an input. The author's closed set, the user's word list, a piece of the sentence read by code and checked against a range, or a yes/no switch.
ask · the meanings
Questions a person could answer. The same line serves the classifier and the prompt. One meaning per option, distinct and short.
not_for · false tests
The near misses. Name what sounds like this reflex and is not. Then hold the boundary with a test that must never route here.
effect
Your honest claim. A read observes. A write can be undone. Destructive cannot, and always asks. Absent means destructive.
[yields]
Optional outputs. A field of the result, by kind, so a later step can take it.
Read the format →

In your app

03

Put decisions inside your app.

A decision is a typed value your code holds before anything runs. Inspect it, show it, fill what is missing, ask for approval, then run it. Or let handle() do the whole loop.

app.ts
import type { Reflexes } from "./evoke.d.ts"

const project = await load<Reflexes>({ root: import.meta.dirname })
const d = await project.decide("kill the lights in the den")

switch (d.outcome) {
  case "run":     await project.run(d); break
  case "confirm": if (await ui.confirm(d.prompt.template))
                    await project.run(d, { confirmed: true }); break
  case "ask":     project.fill(d, await ui.pick(d.missing)); break
  case "abstain": ui.say(d.contenders); break
}

// typed by evoke.d.ts once the reflex is known
if (d.outcome === "run" && d.reflex === "lights") {
  d.values.room    // string
  d.values.state   // "on" | "off" | "dim"
}
run
Ready. The call, the typed values, the confidence and every judgment.
confirm
Needs a yes. All of the above, the reflex's own question, and every reason it stopped.
ask
Needs a value. What is missing, why, and what it may be. fill() types the answer and gates again.
abstain
Nothing fits. The ranking, so your app can show it or queue the request.
data
Plain JSON. A decision survives a queue and a database. A second process can run it later, with confirmed: true.

Use the SDK → The types, key by key: Decisions.

Chaining

04

Connect compatible operations.

Two ways to run several things, with two owners. A weave is planned by evoke from declared outputs. A continuation is chosen by your program and driven by your loop.

A weave: explicit result bindings

A reflex declares what its result holds under [yields]. A later step that lacks a value of that kind takes it. steps() reads a sentence into its plan and runs nothing. weave() runs it under your handlers.

A continuation: your loop decides

A body returns { text, data: { next } }. Your application submits next as the following request. Each hop is a decision like any other, gated on its own.

steps and weave
const plan = await project.steps("look up dana's address and email them")
plan.steps       // [{ n: 1, text, decision, reflex: "contact", effect: "read", ... }, { n: 2, ... }]
plan.binds       // [{ from: 1, to: 2, arg: "to", field: "email", kind: "email", via: "fill" }]
plan.verdict     // { outcome: "run" | "ask" | "confirm" | "refuse", because?: [...] }

const woven = await project.weave(input, {
  ask: (d, turn) => ui.pick(d.missing, turn.step),     // before anything runs
  confirm: (d, turn) => ui.confirm(d.prompt.template),   // at the step's turn
})
woven.status     // the worst step's: "ran", "failed", "declined", "refused", "unanswered"

View the chaining example → The loop, in the runbook example.

Team names

05

Keep team-specific names separate.

Your data supplies the vocabulary. The operation underneath never changes. A multi-tenant server compiles the same reflexes over each tenant's own words, in milliseconds.

server.ts
const tenant = project.with({ vocab: { rooms: await db.rooms(user) } })
const d = await tenant.decide(input)
if (d.outcome === "run") await tenant.run(d)
vocab/rooms.tomlthe same words, as a file
den    = "The TV room downstairs; also 'the snug'."
office = { what = "The upstairs study.", value = "group-7" }
  • A word has a meaning and a value. The classifier reads the meaning. The program receives the value, or the word itself.
  • A decision is bound to its words. One made on a tenant's project is refused by another's, so a server cannot mix them up.
  • Files or data, the same shape. On a laptop the words are a file. In an app they come from wherever you keep them.
View the vocabulary guide →

Testing

06

Test the interpretation and the program.

Three tests, each for a different thing. The wording over the installed set, the control flow offline, and the body as a plain function.

the wording
$ evoke test
  lights  3 passed · 1 failed
    "make it darker in here"  state: expected "dim", read "off"
  timer   8 passed
  1 of 12 cases failed  →  evoke test
[1]

Every example and every held-out test, decided over everything installed. A neighbour installed beside yours is part of the test.

the control flow, offline
const record = process.env.RECORD ? jev() : undefined
const project = await load({
  reflexes: { timer },
  adapter: replay("answers.toml", { record }),
})
const d = await project.decide("timer for 10 minutes")
assert.equal(d.outcome, "run")

One run records the answers. Every run after is offline and gives the same result. The gate in tests is the gate in production.

the program, as a function
import note from "../note/note.mts"

const context = {
  input: "",
  config: { file: "notes/today.txt" },
  signal: new AbortController().signal,
}
assert.equal(await note({ text: "buy milk" }, context),
  'noted "buy milk" in ~/notes/today.txt')

A body is a function. Import it and call it with a context of your own. Nothing of evoke is involved.

Examples and tests → Testing in the SDK →

Sharing

07

Share what you built.

Publishing is a git tag. No account, no registry, no build step. Others install from the repository, pinned by tag, commit and content. Their wording stays in their own files.

publish · install
$ git tag v1.2.0 && git push --tags
$ evoke add radhi/home/lights
+ lights  radhi/home/lights 1.2.0  write  runs lights.mts
  inactive  lights: vocabulary "rooms" is empty  →  evoke vocab rooms add <word> "<meaning>"
the next version
$ evoke check
  lights  write  runs lights.mts
  1.2.0 → 2.0.0  major
    args.state  renamed power
  1. 1Wording changes at any tag. A contract change, an input or the program, is a version bump. evoke check diffs against your last tag and says which.
  2. 2A user's lessons survive. Rename an input with was, and every overlay follows. An update reports and never rewrites their files.

Publish a reflex →

Beside an LLM

08

Where evoke fits in what you already have.

Tools built on a large language model can also make constrained tool calls. The pattern overlaps: a model picks an operation and fills its arguments. Judge evoke on what it packages around that step. A reflex you install from git and share. A vocabulary of your own names, kept apart from the operation. A decision your code inspects before anything runs. And a plan that runs several steps in order, each gated on its own.

This site claims no advantage in speed or cost. Such a claim needs a named comparison and measurements. The classifier answers closed questions with calibrated probabilities, and evoke's core names no engine, so another one can be plugged in behind the same manifests.

Use evoke in your app.

One file, one reflex as code, one call that decides, asks, confirms and runs. Node 24.5 or newer.