Your first rule set
One .grl file, one JSON input, one command. Every rule on this page compiles and runs against the
pinned engine, and the console output is what the commands print for it.
Before you start
Section titled “Before you start”| You need | Note |
|---|---|
The kis CLI | The entry point for every local command below. |
| The rules plugin | kis rules is a proxy command, not a compiled-in subcommand — see below. |
| Nothing else | No database, no service, no network. The local pass is entirely offline. |
kis rules, kis ocr, kis bbox and kis hocr2bbox 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:
kis rules --helpIf 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.
Lay out the project
Section titled “Lay out the project”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. -i must be a directory; it processes 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.
Supply the facts
Section titled “Supply the facts”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.
{ "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.
Write the rule set
Section titled “Write the rule set”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"); Retract("TriageHighValue");}
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"); Retract("TriageStandard");}
rule StampClaim "Copy the identifier onto the result" salience 80 { when strings.ToUpper(status) == "SUBMITTED" then out.Set("claim_id", claim_id); Retract("StampClaim");}Five things in that file are load-bearing:
| Element | Why it matters |
|---|---|
out.Set(key, value) | The only thing that leaves the engine. The result is exactly what the rules Set. |
Retract("SelfName") | Removes the rule from the pool for the rest of this execution. Without it the rule re-fires every cycle. |
Distinct salience | The only ordering control. Higher fires first. |
10000.0 vs 10000 | Comparison 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 statement | Including the last one. A missing semicolon is a parse error. |
The full grammar is on the rule language; the callable surface is on functions.
Run it
Section titled “Run it”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 file(s) successfullyTwo 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 runnable rules went first; it did not stop the other one running.
Every retracted rule leaves the pool, and every remaining rule is re-evaluated 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.
See what fired
Section titled “See what fired”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 file(s) successfullyEvery 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.
The same two collections are also written to a <basename>-hist-audit.json file:
{ "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" } ]}Write the results
Section titled “Write the results”Printing is the default. -o alone writes nothing — --update is what turns on writing.
kis rules -r rules/ -i input/ -o results/ -k triage --update --history-audit ./audit/{ "triage": { "claim_id": "CLM-1041", "review_tier": "manager" }}| Combination | Effect |
|---|---|
neither -o nor --update | Print only. |
--update, no -o | Merge the results back into each source file. |
--update with a different -o | Write only the rule results, mirrored under -o. Source untouched. |
-k <key> | Nest the results under that key instead of merging at the root. |
Four failures you will hit on run two
Section titled “Four failures you will hit on run two”In the order a new rule set tends to produce them.
The sidecar becomes an input. Run the first command twice in a row without --history-audit:
ERROR: /path/to/input/claim-1041-hist-audit.json: executing rules on /path/to/input/claim-1041-hist-audit.json: executing ruleset rules: evaluating expression in rule 'TriageStandard' the when raised an error. got left hand expression error. got non existent key amountProcessed 1/2 file(s) successfully1 file(s) failed to processkis ocr filters *-hist-audit.json out of its inputs. kis rules does not.
A missing fact aborts the whole execution, not the rule. That is the same error, read a different
way: the sidecar carries none of the four facts, so the first rule the engine reaches cannot be
evaluated, and evaluation failure is unconditional abort — every rule is evaluated every cycle, so one
rule referencing an unsupplied fact discards all output for that input. Guard optional facts with
in.Has("status") && strings.ToUpper(status) == "SUBMITTED" — && short-circuits, so the guarded
term is never evaluated when the fact is absent, and in.Has is the one input accessor that works.
A rule with no Retract runs until the cycle cap. Delete the Retract line from StampClaim:
ERROR: ...: executing ruleset rules: the GruleEngine successfully selected rule candidate for execution after 5000 cycles, this could possibly caused by rule entry(s) that keep added into execution pool but when executed it does not change any data in context. Please evaluate your rule entries "When" and "Then" scope. You can adjust the maximum cycle using GruleEngine.MaxCycle variableNot a hang — a hard failure at cycle 5001, and no partial result. The cap the message tells you to
raise is fixed at 5000 on both surfaces. Neither kis rules nor rules.svc exposes a flag or a
config key for it — fix the rule set instead, usually by adding the missing Retract.
in.GetStr and friends return the zero value. Reading a fact through the typed input accessors
silently yields nothing:
rule Probe salience 10 { when in.Has("status") then out.Set("direct", status); out.Set("via_getstr", in.GetStr("status")); Retract("Probe");}{ "direct": "submitted", "via_getstr": ""}in.GetStr, in.GetInt, in.GetFloat, in.GetBool and in.GetMap all return their zero value on
facts that are present, and in.Get returns an opaque reflection wrapper that serializes to {}. The
lookup hands back that wrapper rather than the value, so every type assertion inside the accessor
fails. Read facts by their bare name. in.Has and the in.Mem* scratch-memory family work normally.
More failure shapes: troubleshooting.
The same rule set over HTTP
Section titled “The same rule set over HTTP”rules.svc runs the identical GRL. Two things change: the rules arrive as product metadata rather
than from -r, and the rule set is selected by name.
Deliver the rule set under the product’s rules metadata. Each entry’s name becomes a rule-set
name and rule carries the GRL source:
rules: - name: claim-triage rule: | 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"); Retract("TriageHighValue"); }
rule TriageStandard "Everything else is auto-adjudicated" salience 90 { when amount <= 10000.0 then out.Set("review_tier", "auto"); Retract("TriageStandard"); }
rule StampClaim "Copy the identifier onto the result" salience 80 { when strings.ToUpper(status) == "SUBMITTED" then out.Set("claim_id", claim_id); Retract("StampClaim"); }Only name and rule are read. A rule set that fails to compile is logged and skipped, not fatal —
so a typo removes one rule set and leaves the service running.
Post the facts. The entire body becomes the fact map, so the facts are the same top-level keys the input file carried:
curl -sS -X POST https://rules.example.com/rule \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"claim-triage","claim_id":"CLM-1041","status":"submitted","amount":12500.0}'{ "claim_id": "CLM-1041", "review_tier": "manager"}The response body is the output collector. Add "audit": true to the request and the trail comes
back beside it, under a reserved audit key holding the same entries --audit printed above:
curl -sS -X POST https://rules.example.com/rule \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"claim-triage","claim_id":"CLM-1041","status":"submitted","amount":12500.0,"audit":true}'{ "claim_id": "CLM-1041", "review_tier": "manager", "audit": [ {"rule_id": "TriageHighValue", "message": "executed"}, {"rule_id": "TriageHighValue", "message": "amount above the 10000 threshold"} ]}What differs from the local run
Section titled “What differs from the local run”kis rules | POST /rule | |
|---|---|---|
| Rule-set selection | Fixed. --ruleset is accepted and never read. | The body’s name field, required. |
| Facts | Top-level keys of the input file, plus -f and -v | Every top-level key of the request body |
| Nested facts | Bracket access, one level | The same — bracket access, one level |
| Audit trail | Printed with --audit, written to the sidecar | Returned under audit when the body sets "audit": true |
| Findings | Not surfaced — no rule can record one | Not surfaced |
| Document namespace | Available with -b / --with-bbox | Available with a bbox object on the body |
log namespace | Absent | Absent |
Document rules run here too. Add a reserved bbox object beside the facts, keyed by the name the
rule passes to bbox.Get, and the service parses and analyses each document before the first rule is
evaluated — the HTTP counterpart of -b page=invoice.bbox:
{ "name": "invoice-checks", "total": 1200, "bbox": { "page": {"pages": [{"number": 1, "width": 612, "height": 792, "words": []}]} }}The rule text is unchanged between the two surfaces — see documents in rules.
Execution failures come back as 400 with a kiserr envelope — including rule engine not initialized, which is a readiness condition rather than a client error and is what an empty metadata
load looks like from the caller’s side. Codes and messages: errors.
Boot, metadata polling and tenancy: running rules.svc.
Where to go next
Section titled “Where to go next”| You want to | Page |
|---|---|
| Chain one rule’s decision into another’s condition | Writing rules |
| Look up an operator, a literal form or fact-access syntax | The rule language |
Find the function you need in strings, map, array, time, math | Functions |
| Copy a rule shape that is known to work | Patterns |
| Run rules over scanned pages instead of JSON | OCR input |
| See every flag on every local command | The CLI |
| Send documents and facts on one request | Running rules.svc |