Skip to content
Talk to our solutions team

Command line

The Rules block ships four top-level kis commands: rules runs a rule set over JSON inputs, optionally with the bounding-box document and page image beside each one; ocr detects form controls on a scanned page; bbox inspects a bounding-box document without running any rules; and hocr2bbox converts hOCR into the .bbox files the others consume.

kis rules is the only command that runs rules. kis ocr page and kis ocr doc used to be a separate implementation of it and have been removed.

CommandWhat it doesRuns rules
kis rulesRuns a rule set over JSON inputs, one execution per input or one over all of themyes
kis ocr checkboxDetect checkboxes on a page and print their statesno
kis ocr radioDetect radio buttons on a page and print their statesno

| kis bbox tables | Detect and print every table | no | | kis bbox table | Print the one table nearest an anchor | no | | kis bbox phrase | Find a phrase, with page, coordinates and context | no | | kis bbox snippet | Extract text between two phrases | no | | kis bbox snippets | Extract several named snippets in one pass | no | | kis hocr2bbox | Convert hOCR to .bbox JSON | no |

The document surface is not CLI-only. rules.svc reaches the same capabilities — the document model, OCR ingestion, field, form and checkbox extraction, tables and repeaters, matching and confidence, and the document bindings a rule calls. What differs is where the input comes from: these commands read .json and .bbox files from disk, while the service takes the same document input on its execute-rule request. The kis bbox subcommands are local inspection tools — they print what they found and run no rules. See Running the service.

All four commands are proxy stubs on the kis binary. Each one forwards its arguments verbatim to a separately installed rules plugin executable, which is where the actual implementation lives. kis looks for that executable in this order and takes the first executable match:

  1. The directory containing the running kis binary.
  2. ~/.kisai/bin/.
  3. Every entry of $KIS_PLUGIN_PATH.
  4. Every entry of $PATH.

kis also passes the program name you typed to the plugin, so kis rules --help prints Usage: kis rules [flags].

Because the proxy commands disable flag parsing, flags for kis itself must come before the command name. Anything after it is forwarded to the plugin untouched.

Terminal window
kis --quiet rules -r rules/ -i pages/
FlagShortTypeDefaultMeaning
--quiet-qboolfalseSuppress non-error output
--tenantstring""Tenant override
--log-level-lstringerrortrace, debug, info, warn, error
--log-tostringstderrstderr, stdout, or a file path
--log-formatstringtexttext or json
--env-filestring""Env YAML file to use instead of ~/.kisai/env.yaml

The plugin itself understands -l/--log-level and --env-file anywhere after the command name, so kis rules -l debug ... works. The other four are interpreted only in the leading position.

Environment variables on this path: KIS_PLUGIN_PATH (extra plugin search directories), KIS_ENV (selects the active env.yaml block), KIS_ENV_FILE (overrides the global env file), and KIS_DEBUG, KIS_QUIET, KIS_TENANT, KIS_LOGLEVEL, KIS_LOGTO, KIS_LOGFORMAT, which seed the context handed to the plugin.

Before a command runs, any flag you did not type on the command line is filled from env.yaml. Keys are the flag’s long name verbatim — lowercase, no prefix, no command nesting.

# ./env.yaml, or ~/.kisai/env.yaml
default:
workers: 4
pattern: "page*.json"
log-level: info

Two files feed flag defaults, lowest precedence first: the global file ($KIS_ENV_FILE or ~/.kisai/env.yaml), then ./env.yaml. Within each file only the block named by $KIS_ENV is read, falling back to default. Resolution is: explicit flag > env.yaml > compiled-in default. The ambient OS environment never feeds flag values. env-file cannot be set this way — it selects the file being loaded.

kis rules -r <rulefile|ruledir> [input...]

Runs a rule set over JSON inputs. Each input’s top-level keys become the facts a rule reads.

A positional argument may be a directory (globbed with -p), a .json or .bbox path (the extension is stripped), or a bare name resolved against -i. With no positional arguments the -i directory itself is globbed. Duplicates are removed.

Terminal window
kis rules -r rules/ -i pages/
kis rules -r rules/ pages/page1.json
kis rules -r rules/ pages/*.json
kis rules -r rules/ --with-bbox --with-image -i pages/
kis rules -r rules/ --with-bbox --mode doc -o result.json pages/
kis rules -r rules/ -i pages/ --update
kis rules -r rules/ --validate --strict
FlagShortTypeDefaultMeaning
--rules-rstring slice— (required)Rule file or directory of rules
--input-istring.Base directory for resolving inputs
--pattern-pstring*.jsonBasename glob for directory arguments, non-recursive
--skip-patternstring slicenilExtra basename globs to exclude
--modestringeacheach runs once per input, doc merges every input and runs once
--output-ostring""Directory in --mode each, file in --mode doc; stdout when unset
--updateboolfalseMerge results into the source .json (--mode each only)
--key-kstring""Nest the result under this key
--rulesetstringrulesName the rule set is compiled and executed under
--workers-wint1Parallel executions in --mode each, clamped to [1, inputs]
--facts-keystringpagesIn --mode doc, the key holding the per-input facts array
--facts-fstring slicenilExtra fact file, key=path or bare path
--vars-vstring slicenilkey=value facts, injected as strings
--bbox-bstring slicenilBounding-box document to inject, [name=]path (default name bbox)
--with-bboxboolfalseLoad <input>.bbox beside each input
--require-bboxboolfalseImplies --with-bbox; an input without a sidecar is dropped
--bbox-namestringper modeName the sidecar is injected under: page in each, doc in doc
--with-imageboolfalseLoad the page image beside each input and attach it to the sidecar
--require-imageboolfalseImplies --with-image; an input without one is dropped
--imagestring slicenilPage image for a named document, [name=]path
--schemastring slicenilA .schema file to register (repeatable)
--schemasstring slicenilDirectory of *.schema files, non-recursive
--recursive-RboolfalseRecurse when discovering rule files
--validateboolfalseCheck rule calls against the registered facts; execute nothing
--strictboolfalseWith --validate, exit non-zero on any error diagnostic
--historyboolfalsePrint the history of value changes
--auditboolfalsePrint the audit log
--history-auditstring""Directory for the -hist-audit.json sidecars
--debugboolfalsePrint load, worker and write progress

--rules, --facts, --vars, --bbox, --image, --schema and --schemas are repeatable and also split on commas, so -r a.grl,b.grl is two values and a path containing a comma breaks.

*-hist-audit.json is always excluded from input matching, in addition to any --skip-pattern you give. An invalid --skip-pattern glob fails up front with invalid --skip-pattern "<pat>": <err>.

--mode each (the default) runs the rule set once per input, across --workers goroutines. Each input’s own JSON keys are its facts.

--mode doc merges every input’s .bbox into one multi-page document under --bbox-name, injects the per-input facts as an array under --facts-key, and runs the rule set exactly once. Merging renumbers pages: each page’s number, and each word’s page, becomes a sequential index over the whole merged document. There is no --workers — it is a single execution.

Terminal window
kis rules -r rules/ --with-bbox --mode doc -i pages/
kis rules -r rules/ --with-bbox --mode doc --facts-key documents pages/

A .bbox that does not parse fails the merge, naming the file.

With neither -o nor --update, results go to stdout:

Terminal window
kis rules -r classify.grl -i pages --with-bbox --history --audit page1
--- History for /work/pages/page1.json ---
1. [DetectInvoice] doctype = "invoice"
--- Audit for /work/pages/page1.json ---
1. [DetectInvoice] executed
2. [DetectInvoice] matched the phrase 'Invoice Number'
Result for /work/pages/page1.json:
{
"doctype": "invoice"
}
Processed 1/1 input(s) successfully

The executed audit entry is added by the engine for every rule that fires; the second line is the rule’s own audit.Log call.

-o writes the rule results alone to the mirrored path under that directory, leaving the source untouched. --update merges the results back into the source .json. An execution that produced an empty result map writes nothing at all.

-o used to write nothing without --update, so -o out/ --update is the idiom in older scripts. It still works: -o wins, and a warning notes that --update was redundant.

warning: --update is redundant with -o and was ignored; -o writes the rule results and leaves the source untouched
Terminal window
kis rules -r rules/ -i pages/ -o results/ -k rules_result
kis rules -r rules/ -i pages/ --update

In --mode doc, -o names a file, and a directory of that name is refused up front rather than failing inside the write.

Terminal window
kis rules -r rules/ -i pages/ -f ref=lookup.json -v env=prod -b mydoc=pages/page1.bbox

-f key=path nests the file’s contents under key; a bare -f path merges the file’s top-level keys into the fact root. -v values are always strings — -v threshold=10 gives a rule the string "10", never the number. Variables overlay facts loaded from -f.

For a sidecar per input, use --with-bbox, and name it to match what your rules call:

Terminal window
kis rules -r classify.grl -i pages/ --with-bbox --bbox-name page

An explicitly named -b of the same name overrides the discovered sidecar rather than racing it.

--with-image loads the page image sitting beside each input — same basename, .png, .jpg, .jpeg, .tif or .tiff, first match wins in that order — and attaches it to the sidecar document.

This is what makes checkbox and radio state read from ink rather than be guessed from OCR glyphs. It requires --with-bbox, since the image attaches to a document, and it cannot combine with --mode doc, since visual fusion is per page and that mode produces one multi-page document. Both combinations are refused up front.

Terminal window
kis rules -r forms.grl -i pages/ --with-bbox --with-image
kis rules -r forms.grl -i pages/ --with-bbox --image page=scan-hi-res.png

An image that is found but cannot be decoded is an error, not a silent downgrade to text-only: decoding page image <path>: image: unknown format. A sidecar image that is simply absent leaves the run text-only unless --require-image was given, which drops the input and reports it.

Terminal window
kis rules -r rules/ --schema schemas/invoice.schema -i pages/
kis rules -r rules/ --schemas schemas/ --mode doc pages/

The filename minus .schema is the name rules call it by, so invoice.schema registers as invoice. --schemas is non-recursive. Duplicate names across --schema and --schemas are a hard error. See Schema-driven extraction.

Terminal window
kis rules -r rules/ --validate
kis rules -r rules/ --validate --strict

--validate resolves every call in the rule set against the registered facts and prints diagnostics without reading any input, so no -i is needed. The bounding-box plugin is registered unconditionally under --validate so bbox.* calls are checked too. Files that failed to parse are listed first — they are not in the rule set to be checked at all — then error diagnostics with their snippet, then warnings rolled up by kind. --strict turns any error, or any unparsed file, into a non-zero exit.

Bad:3: error: bbox has no method NoSuchMethod
then bbox.NoSuchMethod("page");
1 diagnostic(s): 1 error(s), 0 warning(s)
kis ocr checkbox <image|hocr|bbox>...
kis ocr radio <image|hocr|bbox>...

Detects form controls on a page and prints their states as JSON. These run no rules. Full flag detail is in Extraction.

InvocationFlag that sets the nameDefault name
kis rules -b [name=]filethe name= prefixbbox
kis rules --with-bbox--bbox-namepage
kis rules --with-bbox --mode doc--bbox-namedoc

rules.svc runs a document rule set from the same inputs. Facts sit at the top level of the request body exactly as they do in an input .json, and documents arrive under the reserved bbox key, keyed by the name the rules address them by — the HTTP counterpart of -b <key>=<file> and --bbox-name.

Terminal window
curl -X POST https://rules.example.com/rule -H 'Authorization: Bearer <tenant-jwt>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1' -d @request.json
{
"name": "rules",
"doctype_hint": "invoice",
"bbox": {
"page": {
"pages": [
{ "number": 1, "width": 1224, "height": 1584, "words": [] }
]
}
}
}

Each document value is the .bbox JSON kis hocr2bbox produces, sent inline instead of read from disk. The response is the out map — the same object kis rules prints under Result for .... The name match is as strict as it is on the CLI: bbox.Get("page") needs a bbox entry called page.

Read-only analysis of .bbox files. No rules are loaded and nothing is written back to the input.

Every subcommand concatenates its repeatable -i/--input values with its positional file arguments and treats the result as one input list, so shell globs and -i mix freely. All pages across all inputs are renumbered sequentially into one merged document before analysis. -i, --snip and -H are taken literally — they do not split on commas, so repeat the flag instead.

A miss is not an error. No matches found for phrase: "<p>", No snippet found between "<s>" and "<e>", No table found near anchor: <a> and No tables detected. are all printed to the output stream — so they land in an -o file too — and the command exits 0. Of the checks these commands run on what they found, only bbox table -H validation failure exits non-zero. An unreadable input file always exits non-zero.

kis bbox tables [files...]
FlagShortTypeDefaultMeaning
--input-istring arraynilPath to a bounding-box file (repeatable)
--format-fstringtexttext, json or csv
--output-ostring"" (stdout)Output file path
Terminal window
kis bbox tables data/page1.bbox
kis bbox tables *.bbox -f csv -o tables.csv
=== Table 1 (Page 1) ===
Dimensions: 2 rows x 3 columns
Invoice | Number | INV-42
Total | | 120.00

Cells are padded to the widest value in their column, minimum width 3. When the table has detected headers the text output adds a Headers: ... line and a dashed separator under the header rows.

-f json emits {"tables":[{index,page,rows,cols,header_rows,headers?,data}]}. -f csv prefixes each table with a # Table N (Page P) comment line and separates tables with a blank line.

kis bbox table -a "<anchor>" [files...]
FlagShortTypeDefaultMeaning
--anchor-astring— (required)Anchor text; the nearest table is returned
--input-istring arraynilPath to a bounding-box file (repeatable)
--header-Hstring arraynilAn expected column header (repeatable)
--format-fstringtexttext, json or csv
--output-ostring"" (stdout)Output file path
Terminal window
kis bbox table -a "Price List" -H "Item" -H "Price" -H "Qty" doc.bbox
kis bbox table -a "Summary" -f json -o table.json doc.bbox

Every -H value must be present or the command prints Header validation failed: missing headers: <list> (found: <list>) and exits non-zero. A table with no detected header row fails the same way, with Header validation failed: table has no headers. Comparison is case-insensitive and trims whitespace.

The JSON and CSV shapes differ from tables: table -f json emits a bare object with no index field, and table -f csv emits bare rows with no # Table comment line. A consumer cannot treat the two interchangeably.

kis bbox phrase -p "<phrase>" [files...]
FlagShortTypeDefaultMeaning
--phrase-pstring— (required)Phrase to search for; multi-word is supported
--input-istring arraynilPath to a bounding-box file (repeatable)
--before-Bint3Context lines before each match
--after-Aint3Context lines after each match
--format-fstringtexttext or json
--output-ostring"" (stdout)Output file path
--case-insensitiveboolfalseIgnore case; matching is case-sensitive by default
--fuzzyboolfalseEnable Levenshtein fuzzy matching
--max-dist-wordint2Max edit distance per word (fuzzy only)
--max-dist-totalint3Max total edit distance for the phrase (fuzzy only)
Terminal window
kis bbox phrase -p "Invoice Number" data/page1.bbox
kis bbox phrase -p "Inviice Numbr" --fuzzy --max-dist-word 2 --max-dist-total 3 doc.bbox
Found 1 match(es) for phrase "Invoice Number"
=== Match 1 (Page 1) ===
Location: (72.0, 100.0) - (195.0, 112.0)
Line: 1
>> Invoice Number INV-42
Total 120.00

-f json emits {phrase,count,matches:[{index,page,line_index,box:{x0,y0,x1,y1},matched_text, lines_before?,lines_after?}]}. Fuzzy mode marks the text header (fuzzy), adds Edit Distance: and Matched Words: lines, and adds fuzzy, matched_words and total_distance to the JSON.

kis bbox snippet -s "<start>" -e "<end>" [files...]
FlagShortTypeDefaultMeaning
--start-sstring— (required)Starting phrase, included in the output
--end-estring— (required)Ending phrase, excluded unless --include-end
--input-istring arraynilPath to a bounding-box file (repeatable)
--format-fstringtexttext or json
--output-ostring"" (stdout)Output file path
--case-insensitiveboolfalseIgnore case when matching the two phrases
--include-endboolfalseInclude the ending phrase in the output
--fuzzyint0Max Levenshtein distance per word; 0 is exact
--debugboolfalseDump phrase-matching diagnostics to the output stream
Terminal window
kis bbox snippet -s "Invoice" -e "Total" --include-end data/page1.bbox
kis bbox snippet -s "TERMS AND CONDITIONS" -e "SIGNATURE" --fuzzy 2 doc.bbox

Text output is === Snippet (Page A to B) ===, the two phrases, a blank line, then the extracted lines. -f json emits {start_phrase,end_phrase,start_page,end_page,line_count,text,lines}, where text is lines joined by newlines.

--debug writes line and page counts, every start- and end-phrase match, a direct case-insensitive line scan, per-word block listings with their coordinates, and a spatial comparison of the first two matched blocks — all to the output stream, so do not combine it with -o and a JSON consumer.

kis bbox snippets --snip "name|start|end" [--snip ...] [files...]
FlagShortTypeDefaultMeaning
--snipstring array— (required)"name|start phrase|end phrase" (repeatable)
--input-istring arraynilPath to a bounding-box file (repeatable)
--output-ostring"" (stdout)Output file path
--case-insensitiveboolfalseIgnore case for every snippet
--include-endboolfalseInclude the ending phrase for every snippet
--fuzzyint0Max Levenshtein distance per word; 0 is exact

Each --snip value is split on the first two pipes, every part is whitespace-trimmed, and none may be empty. Output is always a single JSON object mapping snippet name to extracted text — there is no format flag.

Terminal window
kis bbox snippets --snip "hdr|Invoice Number|Total" data/page1.bbox
kis bbox snippets --snip "header|Invoice|Total" --snip "footer|Terms|END" -i pages/page1.bbox
{
"hdr": "Invoice Number INV-42"
}

A snippet that is not found maps to "" and the command still exits 0.

kis hocr2bbox -i <file.hocr|dir> [flags]

Converts hOCR into the .bbox JSON that every other command consumes. Input comes from -i only; positional arguments are ignored without a warning.

FlagShortTypeDefaultMeaning
--input-istring""hOCR file, or a directory of them
--output-ostring""Output file (single input) or directory (directory input)
--workers-wint4Parallel conversions, directory mode only
--cleanboolfalseOCR text cleanup: normalise unicode, fix quotes and dashes, drop garbage characters
--clean-formboolfalseForm-aware cleanup that preserves and normalises checkbox and radio indicators
--verbose-vboolfalseLog each parser warning with its type, element, text and context
Terminal window
kis hocr2bbox -i pages/scan1.hocr --clean
kis hocr2bbox -i pages/ -o bbox/ -w 8 --clean-form -v

Output is one pretty-printed .bbox file per input, named after the input basename with the extension replaced. With no -o it lands beside the input. Directory mode globs *.hocr non-recursively. --clean-form takes precedence when both cleanup flags are given.

The produced document, abridged to one word — hOCR line and paragraph grouping is carried through as lines and paragraphs arrays beside words on each page:

{
"pages": [
{
"number": 1,
"width": 1224,
"height": 1584,
"words": [
{
"text": "Invoice",
"box": { "x0": 100, "y0": 100, "x1": 260, "y1": 140 },
"confidence": 0.96,
"page": 1,
"line_id": 1
}
]
}
]
}

--input is not marked required. Omitting it fails with stat : no such file or directory — note the empty path — rather than a required-flag message. A directory containing no *.hocr files logs a warning and exits 0, which at the default log level looks like a silent success; add -l info to see it.

--history and --audit print their section and also record it in a <basename>-hist-audit.json sidecar. --history-audit <dir> routes the sidecars to a directory, mirroring each input’s relative path from -i, and on its own asks for both sections — naming an output directory is only meaningful if something is written there.

Nothing is written unless one of those three flags asked for it. --mode doc writes one sidecar, named after the first resolved input.

{
"history": [
{ "rule_id": "ClassifyStatement", "key": "document_type", "value": "BANK_STATEMENT" }
],
"audit": [
{ "rule_id": "ClassifyStatement", "message": "executed" },
{ "rule_id": "ClassifyStatement", "message": "header matched" }
]
}

Every command exits non-zero on failure. The rule-running commands print a per-input line, continue with the remaining inputs, print the count, then exit 1 if anything failed — partial success is still exit 1.

ERROR: /work/pages/page3.json: executing rules on /work/pages/page3.json: executing ruleset rules: evaluating expression in rule 'ClassifyStatement' the when raised an error. got non existent key header
Processed 2/3 file(s) successfully
1 file(s) failed to process

Rule-evaluation errors arrive fully wrapped like that: the CLI’s per-input context, then executing ruleset <name>:, then the engine’s own message. Read the tail first — it names the rule and the thing it could not resolve.

Those ERROR: lines go to stderr, and so do the warning: lines about dropped inputs. Results and the closing Processed N/M input(s) successfully go to stdout.

kis propagates the plugin’s exit code verbatim, forwards SIGINT, SIGTERM and SIGHUP to it, and reports a signal-terminated plugin as 128+N. Usage text is suppressed on error at every level, so a failing command prints only its error line.

MessageCause
Error: '<command>' is not installed.The rules plugin is not on any search path
required flag(s) "rules" not set-r omitted; same shape for anchor, phrase, start, end, snip
flag needs an argument: --fuzzy--fuzzy with no value on bbox snippet / bbox snippets
no inputs resolved (...)Nothing matched — a hard error, not a no-op. Under --require-bbox, usually every .json lacked its .bbox; the warning: line above names them
at least one input file is required (use positional args or -i flag)A kis bbox subcommand got neither -i nor a file
invalid var format "<v>": expected key=valueA -v entry with no =
invalid fact specification "<spec>": key and filepath must not be emptyA -f entry like =path or key=
invalid bbox specification "<spec>": key and filepath must not be emptyA -b entry like =path or key=
invalid snippet definition "<s>": expected "name|start phrase|end phrase"A --snip value that did not split into three non-empty parts
invalid --skip-pattern "<pat>": <err>A --skip-pattern that is not a valid glob
stat : no such file or directorykis hocr2bbox run without -i
... the when raised an error. got non existent key <fact>A rule read a fact the input did not supply
Header validation failed: missing headers: <list> (found: <list>)A bbox table -H value is absent
Header validation failed: table has no headersbbox table -H on a table with no detected header row

Rule-evaluation failures surface here too. A missing fact key, a wrong document name or a bad function call aborts the whole execution for that input; see Troubleshooting.

kis rules, kis ocr page and kis ocr doc were three copies of one implementation. They are now one: kis rules. The other two were removed, and folding them in changed a handful of behaviours.

BehaviourWasNow
Positional arguments to kis rulesParsed and then ignored without a warningResolved, like every other command
-o on kis rulesWrote nothing without --updateWrites
-o together with --updatekis rules needed both to write anywhere; kis ocr page silently preferred -o-o wins, with a warning that --update was redundant
--ruleset on kis rulesParsed and ignored; always rulesHonoured
-hist-audit.json on the ocr commandsWritten on every runWritten only when --history, --audit or --history-audit asks
Bounding-box document on the ocr commandsHanded over as parsed content, so no page image attachedHanded over as a file path, so it does
-b documentsParsed and handed over as contentHanded over as a file path
Recursion flag-R on rules, --recursive-rules on the ocr commands-R / --recursive; the old spelling still parses
--with-bbox-namerules onlyFolded into --bbox-name; the old spelling still parses
--schema / --schemasocr commands onlyAvailable on kis rules
--validate / --strictrules onlyUnchanged, and now the only surface
--skip-patternocr commands onlyAvailable on kis rules
Per-input errorsstdout on rules, stderr on the ocr commandsstderr
Inputs dropped for a missing sidecarSilentCounted and named in a warning: line on stderr
kis.ai monolithic binaryHad no ocr command treeHas it

Input discovery is a single non-recursive glob everywhere. -R affects rule file discovery only.

A --require-bbox input whose sidecar is missing is dropped when it came from a directory glob or a shell-expanded path — the glob simply matched a file that is not an input — and is an error when you named the prefix yourself, since a name you typed cannot be glob noise. Either way the drop is reported:

warning: skipped 1 input(s) with no .bbox sidecar: page3.json