Skip to content
Talk to our solutions team

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.

What it is. A multi-language script engine. It runs a script file directly, with the language auto-detected by extension.

ExtensionLanguage
.jsJavaScript (Goja)
.luaLua
.goGo (Yaegi interpreter)
.starStarlark
.celCEL
.exprexpr
.wasmWebAssembly (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.

By default the engine calls main. Data arrives through two channels:

  • Positional args (after the file) become function arguments.
  • --vars key=value / --env file.yaml become bare globals, the same ambient scope a flow’s vars: block provides.
Terminal window
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.wasm

A 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 flagMeaning
-a, --argsFunction argument (repeatable; JSON-parsed best-effort)
-f, --funcFunction to call (default main)
-v, --varsVariables, bare globals (key=value)
-e, --env / -n, --nameEnvironment file + name
-t, --timeoutTimeout in milliseconds (default 5000)
-r, --runtimeForce a runtime (e.g. v8, goja for JS)
--namespacesHost namespaces to enable (all, none, or a list)
--rootProduct root dir (overrides .kisai discovery)
-d, --debugDebug output
Terminal window
kis script validate transform.js # syntax + does it compile?
kis script compile transform.js # test compilation for runtimes
kis script bench hello.js --iterations 1000 # measure performance
kis script rules transform.js --input ./data --update # batch over a JSON dir
kis script version # script-engine version info

kis 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.

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.

Terminal window
kis flow -f bot_flow.yaml
kis 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 --dryrun

A minimal flow:

bot_flow.yaml
id: bot-flow-001
name: onboarding_bot_flow
vars:
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"
FlagMeaning
-f, --flowFlow YAML file
-s, --startStart task
-t, --tasksExplicit list of tasks to run
-v, --varsVariables (key=value)
-e, --env / -n, --nameEnvironment file + name
-d, --dryrunValidate/plan without executing
-w, --workersWorker count (default 1)
--affinity / --affinity-keyAgent affinity scope and keys
--pin-agentPin all tasks to one agent
--restart-on-failureRestart the whole flow on agent failure
--logfileLog file path

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.

Terminal window
kis statemachine validate document-review.yaml
kis 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.

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.

Terminal window
kis flow validate agent-loop.yaml
kis statemachine validate document-review.yaml
kis datapipe validate pipeline.yaml

Validation 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.

Engines differ in what moves them, and that is the whole basis for choosing:

EngineMoves onReach for it when
dagdependency — a task runs when its inputs are readyTasks run once each, in an order the shape decides
graphroutes — a DAG whose next may point backwardsA task has to run again: retry, refine, plan/act/observe
statemachineevents — it rests until something happens to itWaiting on a person, a webhook or a timer
datapiperecords — a persistent pipe, not a runContinuous 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.

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-giveup
start: 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: true

next: 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.

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-retry
initial: 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: failed

A 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.

RefusedBecause
A graph that can loop with no maxstepsNothing would stop it
A graph that cannot loop with maxstepsThe limit can never apply
A route with no when that is not lastNothing after it can ever be taken
A task with end: true that also has nextThe run stops there
A state with no on, tasks or always and no type: finalThe machine rests there forever, reporting itself live and accepting nothing
A type: final state with events, exit actions, tasks or an alwaysNone of them could ever run
A transition shadowed by an earlier unguarded oneIt 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 handlesCallers are refused by every state, but the definition said it was supported
A stray comma in a tasks: listThe list is silently one step short
An unconditional alwaysIt 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.

FlagMeaning
--kindgraph or statemachine (required)

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.

Terminal window
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.sh
kis cron add sync "weekly on mon,wed,fri at 6am" -- ./sync.sh
kis cron list
kis cron show backup
kis cron run backup # run it now, output to terminal
kis cron logs backup
kis cron history --last 20
kis cron remove backup

Run something once and have it remove itself:

Terminal window
kis cron once "in 30 minutes" --name my-deploy -- ./deploy.sh
kis cron once "tomorrow at 9am" -- ./morning.sh

Schedule 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.

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.

Terminal window
kis test -t tests/data-api.yaml
kis test -t tests/ # every *.yaml in the dir
kis 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 OR
kis test -t tests/ --db .atc/results.db --junit .atc/junit.xml
kis test -t tests/ --dry-run # validate without HTTP calls
  • When -e is omitted, an env file next to --tests is auto-discovered (env.local.yaml → env.yaml → env.<name>.yaml).