Skip to content
Talk to our solutions team

Your first rule set

One .grl file, one JSON input, one command. Every rule on this page compiles and runs against the engine, and the console output is what the commands print for it.

You needNote
The kis CLIThe entry point for every local command below.
The rules pluginkis rules is a proxy command, not a compiled-in subcommand: see below.
Nothing elseNo database, no service, no network. The local pass is entirely offline.

kis rules and kis bbox are dispatched to a separately installed plugin executable. kis forwards your arguments to it untouched and contains none of the rules logic itself. Check it is present before anything else:

Terminal window
kis rules --help

If the plugin is missing, the command fails immediately and nothing else on this page works:

Error: 'rules' is not installed.

A second line names the install command for the plugin package. The plugin is looked for in the directory holding the kis binary, then ~/.kisai/bin, then every entry of $KIS_PLUGIN_PATH, then $PATH; the first executable match wins. Full flag reference: the CLI.

quickstart/
rules/
triage.grl # the rule set
input/
claim-1041.json # one input document; its top-level keys become the facts

-r rules/ compiles every .grl file in the directory into one rule set. It also takes a single .grl file, and -R walks subdirectories. With no input named on the command line, -i is the directory searched for inputs: every *.json file in it, one execution per file. Files not matching -p (default *.json) are skipped silently, and matching nothing at all is an error.

rule TriageHighValue "Claims above the review threshold go to a manager" salience 100 {
when
amount > 10000.0
then
out.Set("review_tier", "manager");
audit.Log("TriageHighValue", "amount above the 10000 threshold");
}
rule TriageStandard "Everything else is auto-adjudicated" salience 90 {
when
amount <= 10000.0
then
out.Set("review_tier", "auto");
audit.Log("TriageStandard", "amount within the 10000 threshold");
}
rule StampClaim "Copy the identifier onto the result" salience 80 {
when
strings.ToUpper(status) == "SUBMITTED"
then
out.Set("claim_id", claim_id);
}

Four things in that file are load-bearing:

ElementWhy it matters
out.Set(key, value)The result. What the rules Set is exactly what the caller gets back.
Distinct salienceThe only ordering control. Higher fires first.
10000.0 vs 10000Comparison operators coerce, so either works here. Function arguments do not: passing 10000 where a float parameter is declared fails with reflect: Call using int64 as type float64.
Trailing ; on every then statementIncluding the last one. A missing semicolon is a parse error.

A rule that fires leaves the pool for the rest of the execution, so each rule fires at most once per run. A rule does not need to end with Retract to stop itself firing again.

The full grammar is on the rule language; the callable surface is on functions.

Every top-level key of the input file is bound as its own fact, under its own name. There is no wrapper object and no schema. input/claim-1041.json:

facts
{
"claim_id": "CLM-1041",
"status": "submitted",
"amount": 12500.0,
"memo": "Closing Balance: $7,325.00"
}

That gives four facts: claim_id, status, amount and memo. A rule reads them as bare identifiers. Over those facts the rule set writes:

out
{
"claim_id": "CLM-1041",
"review_tier": "manager"
}
Terminal window
kis rules -r rules/ -i input/
Result for /path/to/quickstart/input/claim-1041.json:
{
"claim_id": "CLM-1041",
"review_tier": "manager"
}
Processed 1/1 input(s) successfully

Two of the three rules fired. TriageHighValue matched because amount is 12500, StampClaim matched on status, and TriageStandard never matched because its condition is the negation of the first rule’s.

Salience decided which of the two matching rules went first; it did not stop the other one running. A rule that fires leaves the pool, and every remaining rule is evaluated again on the next cycle, so a lower-salience rule still fires later unless its own when is false. Make competing rules mutually exclusive in their conditions; do not use salience to suppress one.

The final line is the exit contract: any failed input prints ERROR: <path>: <err>, processing continues, and the command exits 1 with a count of what succeeded.

Terminal window
kis rules -r rules/ -i input/ --history --audit --history-audit ./audit/
--- History for /path/to/quickstart/input/claim-1041.json ---
1. [TriageHighValue] review_tier = "manager"
2. [StampClaim] claim_id = "CLM-1041"
--- Audit for /path/to/quickstart/input/claim-1041.json ---
1. [TriageHighValue] executed
2. [TriageHighValue] amount above the 10000 threshold
3. [StampClaim] executed
Result for /path/to/quickstart/input/claim-1041.json:
{
"claim_id": "CLM-1041",
"review_tier": "manager"
}
Processed 1/1 input(s) successfully

Every out.Set is attributed to the rule that wrote it, the engine stamps the firing rule name onto each history entry, the executed audit lines are added automatically for every rule that fires; the other lines are your own audit.Log calls.

--history and --audit also record their section in a <basename>-hist-audit.json sidecar:

{
"history": [
{ "rule_id": "TriageHighValue", "key": "review_tier", "value": "manager" },
{ "rule_id": "StampClaim", "key": "claim_id", "value": "CLM-1041" }
],
"audit": [
{ "rule_id": "TriageHighValue", "message": "executed" },
{ "rule_id": "TriageHighValue", "message": "amount above the 10000 threshold" },
{ "rule_id": "StampClaim", "message": "executed" }
]
}

Printing is the default. -o writes the results to a directory; --update merges them back into each source file instead.

Terminal window
kis rules -r rules/ -i input/ -o results/ -k triage

results/claim-1041.json:

{
"triage": {
"claim_id": "CLM-1041",
"review_tier": "manager"
}
}
CombinationEffect
neither -o nor --updatePrint only.
-o <dir>Write only the rule results, mirrored under -o. Source untouched.
--update, no -oMerge the results back into each source file.
--update with -o-o wins; a warning says --update was ignored.
-k <key>Nest the results under that key instead of merging at the root.

In the order a new rule set tends to produce them.

A missing fact aborts the whole execution, not the rule. Feed a rule set a JSON file that does not carry the facts it reads and the run fails like this:

ERROR: /path/to/input/summary.json: executing rules: executing ruleset rules: evaluating expression in rule 'TriageHighValue' the when raised an error. got left hand expression error. got non existent key amount
Processed 1/2 input(s) successfully
1 input(s) failed

The file carries none of the four facts, so the first rule the engine reaches cannot be evaluated, and a failed evaluation aborts the execution. Every rule is evaluated every cycle, so one rule naming an unsupplied fact fails that input. Read a fact that may be absent through in, which takes a default and cannot fail: strings.ToUpper(in.Str("status", "")) == "SUBMITTED". A dotted path reads into nested objects, in.Double("claim.amount", 0.0). See Reading facts.

A misspelt method fails when the rule runs. Change strings.ToUpper to strings.ToUper in StampClaim and run again:

ERROR: /path/to/quickstart/input/claim-1041.json: executing rules: executing ruleset rules: evaluating expression in rule 'StampClaim' the when raised an error. got left hand expression error. got this node identified as "strings" have no function named ToUper
Processed 0/1 input(s) successfully
1 input(s) failed

--validate finds it without an input, names the rule and line, and suggests the nearest name:

Terminal window
kis rules -r rules/ --validate
StampClaim:19: error: strings has no method ToUper (did you mean ToUpper?)
strings.ToUper(status) == "SUBMITTED"
1 diagnostic(s): 1 error(s), 0 warning(s)

Add --strict to make it exit non-zero. rules.svc runs the same check when it loads the rules and refuses a set that fails it. See Validating a rule set.

An integer literal passed to a float parameter aborts the run. Cap the amount with math.Min(amount, 10000) and the rule fails when it fires:

ERROR: /path/to/quickstart/input/claim-1041.json: executing rules: executing ruleset rules: error while executing rule TriageHighValue. got rule engine execute panic on rule TriageHighValue ! recovered : reflect: Call using int64 as type float64

The load-time check counts arguments; it does not check their types. Write 10000.0.

More failure shapes: troubleshooting.

rules.svc runs the identical GRL. Two things change: the rule set is a folder of the product rather than a path given to -r, and a call names it as a scope.

Put rules/triage.grl unchanged in the product’s rules/claim-triage/ folder. A top folder of rules/ is a scope, called by the folder’s name:

rules/
claim-triage/
triage.grl

The change reaches the service by itself. A load that fails keeps what was served before, and GET /rulesets says why. kis rules check, run in the product’s root, loads the folder the way the service does, so you can check it before it ships:

rules: 1 folder scope(s), 0 static scope(s): they load
claim-triage folder, 1 rule file(s)

Post the facts. The keys of input are the facts, the same top-level keys the input file carried:

Terminal window
curl -sS -X POST https://rules.example.com/rules/execute \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-Customer: acme" -H "X-Env: dev" -H "X-Product: claims" -H "X-Tenant: main" \
-d '{"scope":"claim-triage","input":{"claim_id":"CLM-1041","status":"submitted","amount":12500.0}}'
{
"scope": "claim-triage",
"stamp": "sha256:…",
"principal": { "id": "u-claims-agent", "ptyp": "user" },
"out": { "claim_id": "CLM-1041", "review_tier": "manager" },
"findings": [],
"actions": []
}

out is the output collector, the same object kis rules printed. findings holds what the rules raised with findings.*, stamp identifies the exact rule text that decided, and principal is who the rules ran as. Add "options": {"audit": true} and the trail comes back beside it, the same entries --audit printed above:

{
"audit": [
{ "rule": "TriageHighValue", "message": "executed", "cycle": 1 },
{ "rule": "TriageHighValue", "message": "amount above the 10000 threshold", "cycle": 1 },
{ "rule": "StampClaim", "message": "executed", "cycle": 2 }
]
}
kis rulesPOST /rules/execute
Rule-set selectionThe files -r names. --scope runs a product’s scope by name, as the service does.scope: a top folder of rules/, or a static scope of rules/rules.yaml. Required.
FactsTop-level keys of the input file, plus -f and -vThe keys of input, plus the named facts in facts
Nested factsin.* with a dotted pathThe same
Audit trailPrinted with --audit, written to the sidecarReturned under audit when options.audit is true
FindingsRaised with findings.*; not printedReturned under findings on every call
Documents-b / --with-bboxNone. Rules that read a document run through kis rules only
log namespaceBoundBound

A rule that fails mid-run answers 422 rules-execution-failed, naming the rule, with what the rules had written before the failure under meta.partial. A scope the product does not have answers 404 rules-scope-not-found, and a tenant whose rules have not loaded yet answers 503 rules-tenant-not-loaded with Retry-After. Codes and messages: errors. Routes, headers and tenancy: the rules service.

You want toPage
Chain one rule’s decision into another’s conditionRule patterns
Look up an operator, a literal form or fact-access syntaxThe rule language
Find the function you need in strings, map, array, time, mathFunctions
Copy a rule shape that is known to workPatterns
Run rules over scanned pages instead of JSONOCR input
See every flag on every local commandThe CLI
Declare a scope’s facts and limitsScopes