Automation
These commands make kis act: run a unit of logic (script),
orchestrate multiple steps (flow), schedule work
(cron), and verify services (test).
engine is how you pick the right execution model for a job and check
a definition before it runs. See
Flows, pipelines & scripts in Core Concepts for how the
first two relate.
kis script
Section titled “kis script”What it is. A multi-language script engine. It runs a script file directly, with the language auto-detected by extension.
| Extension | Language |
|---|---|
.js | JavaScript (Goja) |
.lua | Lua |
.go | Go (Yaegi interpreter) |
.star | Starlark |
.cel | CEL |
.expr | expr |
.wasm | WebAssembly (wazero) |
Why it exists. Sometimes a rule engine is overkill and you just want to run a
function. Crucially, kis script uses the same engine and calling convention as a
flow’s script: task, so a script you develop and test standalone runs unchanged
inside a flow.
When to reach for it. Glue logic, transforms, one-off automation, or prototyping a step before embedding it in a flow.
run, execute a script
Section titled “run, execute a script”By default the engine calls main. Data arrives through two channels:
- Positional args (after the file) become function arguments.
--vars key=value/--env file.yamlbecome bare globals, the same ambient scope a flow’svars:block provides.
kis script run greet.js anand 7 # → main("anand", 7)kis script run process.js '[1,2,3]' '{"k":1}' # JSON-parsed → main([1,2,3], {k:1})kis script run hello.js # → main() (no args)kis script run x.js --vars name=anand # main() with bare global 'name'kis script run x.js --func handler 3 4 # call exported handler(3, 4)kis script run plugin.wasmA real script, showing the ambient host helpers (fs, log, now()) available
inside the runtime:
function main(question, answer) { var path = "log.json"; var data = fs.exists(path) ? fs.readJSON(path) : {}; data[question] = (data[question] || 0) + 1; fs.writeJSON(path, data); log.info("logged: " + question);}run flag | Meaning |
|---|---|
-a, --args | Function argument (repeatable; JSON-parsed best-effort) |
-f, --func | Function to call (default main) |
-v, --vars | Variables, bare globals (key=value) |
-e, --env / -n, --name | Environment file + name |
-t, --timeout | Timeout in milliseconds (default 5000) |
-r, --runtime | Force a runtime (e.g. v8, goja for JS) |
--namespaces | Host namespaces to enable (all, none, or a list) |
--root | Product root dir (overrides .kisai discovery) |
-d, --debug | Debug output |
Other script subcommands
Section titled “Other script subcommands”kis script validate transform.js # syntax + does it compile?kis script compile transform.js # test compilation for runtimeskis script bench hello.js --iterations 1000 # measure performancekis script rules transform.js --input ./data --update # batch over a JSON dirkis script version # script-engine version infokis script rules is the scripting counterpart to kis rules: it runs
a script for every JSON file in a directory, optionally injecting facts/bbox and
writing results back. Its flags mirror kis rules (-i, -o, -p, --facts,
-b/--bbox, --with-bbox, -k, -v, --update, -w) plus -f/--func and
-r/--runtime.
kis flow
Section titled “kis flow”What it is. Runs a multi-step workflow defined in YAML. A flow is a list of
tasks; each task does one thing (call a model, run a script, print…) and points to
what runs next. Aliased as kis automate.
Why it exists. Real work is rarely one call, it’s “ask the model, then transform, then store, branching on the result.” A flow expresses that as reviewable, re-runnable YAML instead of a brittle shell script, and adds workers, agent affinity, and restart-on-failure.
When to reach for it. Orchestrating several steps with control flow. For pure
data movement, use datapipe; for a single step, just
script run.
kis flow -f bot_flow.yamlkis flow -f bot_flow.yaml -s ask-llm -v question="Who picks my buddy?"kis flow -f bot_flow.yaml -e env.yaml -n staging --dryrunA minimal flow:
id: bot-flow-001name: onboarding_bot_flowvars: question: "Who chooses my onboarding buddy?"tasks: - name: ask-llm llm-chat: prompt: "Answer briefly: {{question}}" next: go: show - name: show print: "Reached the show step"| Flag | Meaning |
|---|---|
-f, --flow | Flow YAML file |
-s, --start | Start task |
-t, --tasks | Explicit list of tasks to run |
-v, --vars | Variables (key=value) |
-e, --env / -n, --name | Environment file + name |
-d, --dryrun | Validate/plan without executing |
-w, --workers | Worker count (default 1) |
--affinity / --affinity-key | Agent affinity scope and keys |
--pin-agent | Pin all tasks to one agent |
--restart-on-failure | Restart the whole flow on agent failure |
--logfile | Log file path |
kis statemachine
Section titled “kis statemachine”What it is. Runs and checks state machine definitions — the shape that rests until an event arrives.
Why it exists. A flow you start and watch finish. A machine you start and then feed: it spends essentially all of its life waiting for you. That difference is large enough to deserve its own command rather than a flag on a shared one.
When to reach for it. Anything waiting on a person, a webhook or a timer.
kis statemachine validate document-review.yamlkis statemachine run review.yaml --event submit --event "decide verdict=approve"A scripted run ends by saying whether the machine parked or finished, and for a park what would move it — because a machine resting on somebody’s approval and one that has completed otherwise look identical at the shell.
Drive it by hand with no --event and it reads one event per line from stdin;
send approve verdict=yes and a bare approve verdict=yes are the same thing.
Validating a definition
Section titled “Validating a definition”Each shape validates through its own command. There is no kis engine: it took the
kind as a flag, so every invocation restated what the file already said.
kis flow validate agent-loop.yamlkis statemachine validate document-review.yamlkis datapipe validate pipeline.yamlValidation is structural — the checks that depend only on the definition. Handler
and guard names resolve against a registry that exists only inside a running
service, so they are listed under requires rather than resolved.
Choosing an engine
Section titled “Choosing an engine”Engines differ in what moves them, and that is the whole basis for choosing:
| Engine | Moves on | Reach for it when |
|---|---|---|
dag | dependency — a task runs when its inputs are ready | Tasks run once each, in an order the shape decides |
graph | routes — a DAG whose next may point backwards | A task has to run again: retry, refine, plan/act/observe |
statemachine | events — it rests until something happens to it | Waiting on a person, a webhook or a timer |
datapipe | records — a persistent pipe, not a run | Continuous data, opened once and written to for as long as the service lives |
The dividing question is usually “does anything have to run twice?” — if yes it is a graph, not a flow — and “is it waiting on something outside?” — if yes it is a state machine, not a graph.
graph definitions
Section titled “graph definitions”A graph is authored exactly like a flow — a list of tasks, each with a run
and a next. The only difference is that next may name a task that has
already run.
id: retry-with-giveupstart: try
# REQUIRED when a route points backwards, refused when none does.maxsteps: 5
tasks: - name: try run: attempt next: # Routes are tried in order; the first whose `when` passes is taken. - when: failed go: try # backward — the loop # No `when`, so it always wins and must come last. - go: succeeded
- name: succeeded run: record-success end: truenext: succeeded and next: {go: succeeded} are shorthand for a single
unconditional route, so a task that just goes somewhere reads the same as it
does in a flow.
A graph that can loop has no natural end, so maxsteps is the only thing that
stops it and is therefore required. One that cannot loop and declares one is
refused: the number could never apply, and a limit that never applies reads to
the next person as though it does.
Running out of budget is reported as budget-exhausted, never as a failure.
Nothing errored — the loop’s exit route never matched, which is answered by
raising the budget or fixing the route, and those are different fixes.
statemachine definitions
Section titled “statemachine definitions”States are a map keyed by name, each carrying its own events, entry and exit actions — the XState shape. A definition holds the logic, not just the shape: conditions, context updates and calls to external systems are all written here rather than named and implemented elsewhere.
id: payment-retryinitial: charging
context: # extended state; conditions read it attempts: 0 max_attempts: 3
events: # what the outside world may send - name: retry - name: abandon payload: required: [reason] # checked on delivery, not by the conditions
states: charging: entry: - assign: attempts: "context.attempts + 1"
# The state's work, run in order. Call out, and move on the result. tasks: - name: charge type: http config: url: "https://payments.internal/charge" method: POST onDone: - target: settled cond: "event.status == 'ok'" - target: waiting onError: - target: waiting actions: - assign: last_error: "event.error"
on: abandon: target: written-off actions: - assign: last_error: "event.reason"
waiting: # Re-evaluated after EVERY change to context, with no event involved. always: - target: written-off cond: "context.attempts >= context.max_attempts" on: retry: charging
settled: { type: final } written-off: { type: final }Actions are one of three things: ref: for registered code, type: +
config: for a task, or assign: to set context from an expression. A
type: task is the same task a flow runs — http, shell, script, db,
docker, k8s and the rest — so a machine and a flow make the same call the
same way.
Actions run in a fixed order on every transition: the source state’s exit,
then the transition’s own actions, then the target’s entry. That is what
makes “tear down what this state set up, record what happened, then set up the
next state” three separate lists.
events: is the vocabulary. An event a state handles that is not declared
is an error, so a typo is caught instead of becoming an event nobody sends;
and a declared event no state handles is an error too. payload.required is
checked when the event arrives — a condition reading a missing field sees an
empty value and quietly takes the wrong branch.
always: is what must be true, as opposed to on: which is what happens
when something arrives. It re-evaluates after every change, so a rule like
“three failures and we are locked out” lives next to the state it constrains
instead of being repeated in every transition that could be the third failure.
A machine settles before anyone observes it, including at start.
tasks: is the state’s work, run in order, with onDone/onError saying
where to go next. Each task’s output merges into context before the next starts,
and what they return becomes the payload, so a condition can branch on what the
external system actually said. Write them as a list, as one mapping, or as
plain names separated by commas — tasks: fetch-config, download, explode. Name
them: a failure reports the name and the position, which is what you grep for. A
result that arrives after the machine has already moved on is discarded rather
than applied.
Those names come from the definition’s library: block — the same shape a
flow’s top-level tasks: uses, named differently because nothing in it runs on
its own:
library: # where a task is DEFINED - name: download-file type: shell config: { execute: "emerald download --path {{filepath}} ..." }
states: preparing: tasks: fetch-config, download-file, explode # where it is USED onDone: prepared onError: failedA name in neither the library nor the registered set is refused, and the refusal lists both so a typo is told what exists.
A machine accepts only the events its current state handles — decide
means something in awaiting-review and nothing in draft — so a UI can ask
what to offer rather than guess. Suspending a machine makes it refuse
events rather than hold them: a held event has no defined delivery time, and
the caller could not tell an applied one from a buffered one.
What validation refuses
Section titled “What validation refuses”| Refused | Because |
|---|---|
A graph that can loop with no maxsteps | Nothing would stop it |
A graph that cannot loop with maxsteps | The limit can never apply |
A route with no when that is not last | Nothing after it can ever be taken |
A task with end: true that also has next | The run stops there |
A state with no on, tasks or always and no type: final | The machine rests there forever, reporting itself live and accepting nothing |
A type: final state with events, exit actions, tasks or an always | None of them could ever run |
| A transition shadowed by an earlier unguarded one | It can never fire |
An event a state handles that is not in events: | A typo becomes an event nobody sends |
| A declared event no state handles | Callers are refused by every state, but the definition said it was supported |
A stray comma in a tasks: list | The list is silently one step short |
An unconditional always | It fires on entry, so the machine can never rest there |
An unknown key (maxstep for maxsteps) | Dropped silently, a looping graph is left unbounded |
Handler, action, task-type and guard names are not checked, and neither is whether an expression evaluator is wired. They resolve inside the running service, so the command would have to guess — and a wrong “handler not found” is worse than no answer. The engine checks them when the definition starts, before the first task runs.
| Flag | Meaning |
|---|---|
--kind | graph or statemachine (required) |
kis cron
Section titled “kis cron”What it is. Schedules jobs using human-friendly phrases. Jobs are stored in
~/.kisai/cron/jobs/ and installed into your OS scheduler (crontab on Linux, launchd
on macOS, Task Scheduler on Windows).
Why it exists. It gives you readable schedules (“daily at 2am”) instead of raw cron syntax, plus a managed job store with history and logs, portable across operating systems.
When to reach for it. Recurring local jobs, backups, syncs, scheduled kis
commands.
kis cron add backup "daily at 2am" -- kis encrypt -i data.db -o data.enc -k "$KEY"kis cron add cleanup "every 15 minutes" -- ./cleanup.shkis cron add sync "weekly on mon,wed,fri at 6am" -- ./sync.shkis cron listkis cron show backupkis cron run backup # run it now, output to terminalkis cron logs backupkis cron history --last 20kis cron remove backupRun something once and have it remove itself:
kis cron once "in 30 minutes" --name my-deploy -- ./deploy.shkis cron once "tomorrow at 9am" -- ./morning.shSchedule phrases: every N minutes/hours, hourly [at :30], daily [at 2am],
weekly on monday [at 9am], monthly on 15th [at 3am]. Times accept 2am,
2:30pm, 14:00, noon, midnight. Useful add flags: --cron (raw cron
expression), --dir, --env KEY=VALUE, --capture-env common|all|VARS,
--overlap skip|queue|allow, --timeout 30m. Run kis cron sync to reconcile the
YAML job store with the OS scheduler, and kis cron init to create the cron
directories and validate that your OS scheduler is wired up correctly (optional, directories are created automatically on first add, but init is the way to
troubleshoot a setup).
Full subcommand list: add, list (alias ls), show, run, logs, history,
remove (aliases rm, delete), once, sync, init.
kis test
Section titled “kis test”What it is. The test client, turns API checks, load profiles, and request
collections into declarative YAML you can tag, run in CI, and persist. kis test is
the flag-driven functional runner. Aliased as kis run.
Why it exists. It covers smoke tests through performance testing without a bespoke harness: point it at a folder of test suites and an environment file, and it runs them, records results, and can emit JUnit for CI.
kis test -t tests/data-api.yamlkis test -t tests/ # every *.yaml in the dirkis test -t tests/ -e env.yaml -n staging # multi-env file, pick "staging"kis test -t tests/ --tags smoke # AND within a flag; repeat for ORkis test -t tests/ --db .atc/results.db --junit .atc/junit.xmlkis test -t tests/ --dry-run # validate without HTTP calls- When
-eis omitted, an env file next to--testsis auto-discovered (env.local.yaml→env.yaml→env.<name>.yaml).