The inference rules engine automatically enriches document metadata based on file attributes using a declarative JSON Schema-based system with type coercion and runtime values tracking.
When a file is processed, the watcher:
match JSON Schema against these attributesschema array (left-to-right, property-level), resolves set templates and coerces values to declared types, runs its map (JsonMap) and renders its template or renderPOST /metadata) over the rule outputmatched_rulesThis creates a flexible, declarative metadata pipeline with strong type guarantees.
v0.5.0 introduces a complete redesign of the inference rules system. The v1 set property has been replaced by declarative JSON Schemas with type coercion, UI hints, and runtime values tracking.
| Aspect | v1 (≤ 0.4.x) | v2 (≥ 0.5.0) |
|---|---|---|
| Metadata definition | set object with template strings |
schema arrays referencing global schemas |
| Type handling | All values are strings | Type coercion to declared type |
| Rule identity | Anonymous | Requires name and description |
| Schema definition | Inline only | Global schemas collection + inline |
| Property metadata | None | uiHint, description, enum |
| Values tracking | None | Runtime values index per rule |
| Matched rules | Not tracked | matched_rules array in payload |
Each inference rule has these fields:
{
"name": "jira-issue",
"description": "Jira issue metadata from JSON exports",
"match": {
"properties": {
"json": { "required": ["entityKey"] }
}
},
"schema": [
"base",
"jira-common",
{
"properties": {
"status": {
"type": "string",
"set": "{{json.current.fields.status.name}}"
}
}
}
],
"template": "jira-issue"
}
name (required): Unique identifier for the rule (duplicate names fail rule compilation)description (required): Human-readable description of the rule's purposematch (required): JSON Schema object that the attributes must satisfyschema (optional): Array of schema references (named strings or inline objects), merged left-to-rightmap (optional): JsonMap transformation (inline definition, named map reference, or .json file path)template (optional): Handlebars content template (inline string, named ref, or .hbs/.handlebars file path); here jira-issue names an entry of the top-level templatesrender (optional): Declarative structured renderer configuration (mutually exclusive with template)renderAs (optional): Output file extension override (e.g. "md", "html", "txt"). 1–10 lowercase alphanumeric characters, without dot. Requires template or render.An inferenceRules entry may also be a string: the path of a JSON file holding one rule, resolved relative to the config file's directory when the config is loaded.
Define reusable schemas in the top-level schemas config:
{
"schemas": {
"base": {
"type": "object",
"properties": {
"domain": {
"type": "string",
"description": "Content domain",
"uiHint": "select"
},
"created": {
"type": "integer",
"description": "Record creation date as unix timestamp (seconds)",
"uiHint": "date"
}
}
},
"jira-common": "schemas/jira-common.json"
}
}
Schema entries can be:
"schemas/base.json"), read when a rule using them is appliedThe schema property accepts an array of schema references, merged left-to-right at the property level:
{
"inferenceRules": [
{
"name": "jira-issue",
"description": "Jira issue metadata",
"match": {
"properties": {
"json": { "required": ["entityKey"] }
}
},
"schema": [
"base",
"jira-common",
{
"properties": {
"domain": { "set": "jira" },
"status": {
"type": "string",
"description": "Current workflow status",
"uiHint": "select",
"set": "{{json.current.fields.status.name}}"
},
"created": { "set": "{{json.current.fields.created}}" }
}
}
]
}
]
}
Merge semantics:
"base") are resolved from the global schemas collection; an unknown name makes the rule's schema step fail (see Schema Validation at Processing Time)type, description, uiHint, enum, set, and any other) from whichever schema last defines themThis pattern promotes DRY: base defines that domain is type: "string" with a description, and each rule's inline tail provides set: "jira" or set: "email".
For array-typed properties, set values from multiple schemas in the merge chain are concatenated rather than replaced. This enables composable metadata: a base schema can define initial array values, and subsequent schemas can append to them.
Example — composable domains:
{
"schemas": {
"base": {
"properties": {
"domains": {
"type": "array",
"description": "Content domains",
"set": ["general"]
}
}
}
},
"inferenceRules": [
{
"name": "jira-and-engineering",
"description": "Jira issues in the engineering domain",
"match": {
"properties": {
"json": { "required": ["entityKey"] }
}
},
"schema": [
"base",
{ "properties": { "domains": { "set": ["jira", "engineering"] } } }
]
}
]
}
The resolved domains value for a matching file would be ["general", "jira", "engineering"]: the base array concatenated with the inline tail array. Concatenation applies when the property merged so far has type: "array" and an array set; a later scalar set is appended as one element. Otherwise last-write-wins applies. Concatenation happens within one rule's schema array; across rules, a later rule's value replaces an earlier rule's.
Each string element of an array set is resolved as a template; array values are not type-coerced.
set KeywordThe set keyword within a property schema serves three purposes:
type"set": "jira") from extracted values ("set": "{{json.status}}")Template interpolation:
{
"status": {
"type": "string",
"set": "{{json.current.fields.status.name}}"
},
"domain": {
"type": "string",
"set": "jira"
}
}
Templates use Handlebars {{path.to.field}} syntax against the attributes object, without HTML escaping; registered templateHelpers are available. Undefined paths resolve to an empty string. A non-string set value (for example a number) is passed to coercion unchanged. Properties without set produce no value.
After template interpolation, values are automatically coerced to their declared type:
| Type | Coercion Rules | Examples |
|---|---|---|
string |
Strings unchanged; numbers and booleans stringified; objects and arrays JSON-stringified | "42" → "42", "" → "" |
integer |
The trimmed string must be exactly an integer; numbers must be integers; else undefined |
"42" → 42, "42.5" → undefined, "" → undefined |
number |
Parse as float (parseFloat); non-finite → undefined |
"3.14" → 3.14, "" → undefined |
boolean |
"true" / "false" (case-insensitive) or a boolean; else undefined |
"TRUE" → true, "" → undefined |
array |
Parse JSON array string; already array → passthrough | "[1,2,3]" → [1,2,3], [1,2] → [1,2] |
object |
Parse JSON object string; already object → passthrough | "{\"x\":1}" → {x:1} |
Empty string behavior: Empty strings ("") coerce to undefined for all non-string types, and null coerces to undefined for every type. A property whose value coerces to undefined is left out of the metadata, so invalid data never reaches Qdrant.
Example:
{
"created": {
"type": "integer",
"description": "Creation date as unix timestamp",
"set": "{{json.current.fields.created}}"
}
}
If json.current.fields.created is the string "1735689600", coercion converts it to the integer 1735689600 for storage in Qdrant.
uiHint KeywordThe uiHint keyword tells consuming UIs how to render a property for search filtering:
| Value | Renders as | Use with |
|---|---|---|
text |
Free text input | Text fields, substring search |
number |
Numeric input / range slider | Numeric fields with range queries |
date |
Date picker / date range | Integer timestamps (unix seconds) |
select |
Single-value dropdown | Enum fields, known values |
multiselect |
Multi-value dropdown | Array fields with enum-like values |
check |
Boolean toggle / checkbox | Boolean fields |
Properties without uiHint are not displayed in the UI. This is an explicit opt-in: removing a uiHint hides the field; adding one exposes it.
uiHint also serves as intent metadata for LLM consumers. It augments the property description by signaling how the property is meant to be used in queries.
uiHint and enum are only valid on string, number, integer, boolean and array properties. On any other type (e.g. object) the rule's schema step fails (see Schema Validation at Processing Time).
Example:
{
"created": {
"type": "integer",
"description": "Record creation date as unix timestamp (seconds)",
"uiHint": "date",
"set": "{{json.current.fields.created}}"
},
"priority": {
"type": "string",
"description": "Issue priority",
"enum": ["Highest", "High", "Medium", "Low", "Lowest"],
"uiHint": "select",
"set": "{{json.current.fields.priority.name}}"
}
}
uiHint changes take effect immediately on config reload (no reindex needed).
Every property in a resolved (merged) rule schema must have a declared type. A property that appears in an inline tail with only set, and is never given a type by any schema in the merge chain, is an error. So is uiHint or enum on a non-facetable type, or a named schema reference that does not exist.
Why: This prevents silent string-defaulting and ensures the resolved schema is always self-describing.
When it is checked:
POST /config/validate reports these problems as errors (path inferenceRules[<rule>]).jeeves-watcher config validate) does not check them.map, render and template are skipped, and the failure is recorded as an interpolation_error issue. A file is still indexed without that rule's output; a source document fails and nothing is written.Example - valid:
{
"schemas": {
"base": {
"properties": {
"domain": { "type": "string" }
}
}
},
"inferenceRules": [
{
"name": "example",
"description": "Example rule",
"match": { "type": "object" },
"schema": ["base", { "properties": { "domain": { "set": "jira" } } }]
}
]
}
Example - invalid (domain has no type anywhere in the merge chain):
{
"inferenceRules": [
{
"name": "example",
"description": "Example rule",
"match": { "type": "object" },
"schema": [{ "properties": { "domain": { "set": "jira" } } }]
}
]
}
The watcher writes these payload fields itself: file_path, source, source_rule, chunk_index, chunk_text, total_chunks, content_hash, matched_rules, line_start, line_end, created_at, modified_at.
A rule or enrichment value under one of these names never reaches the payload: reserved names are dropped from rule (set and map) and enrichment metadata before every write, and the watcher's own values are written instead.
A rule schema property with a reserved name is reported as a non-fatal warning (the config still loads):
inferenceRules[<rule>].schema.<name>: Property "<name>" is a reserved system payload field; the system value overwrites rule metadata on upsert. Rename the property.
Warnings are emitted through loadConfig's onWarning: printed to the console once at startup (prefixed [jeeves-watcher]), and logged as warnings on each config reload. POST /config/validate returns them in warnings.
The watcher builds an attributes object for each file:
interface FileAttributes {
file: {
path: string; // Full path with forward slashes (case preserved)
directory: string; // Directory containing the file
filename: string; // File name with extension
extension: string; // Extension including dot (e.g., ".md")
sizeBytes: number; // File size in bytes
modified: string; // ISO-8601 timestamp of last modification
};
frontmatter?: Record<string, unknown>; // YAML frontmatter from .md/.markdown files
json?: Record<string, unknown>; // Parsed content from .json files
}
Example (for /data/tickets/PROJ-123.json):
{
"file": {
"path": "/data/tickets/PROJ-123.json",
"directory": "/data/tickets",
"filename": "PROJ-123.json",
"extension": ".json",
"sizeBytes": 8452,
"modified": "2026-02-24T08:15:00.000Z"
},
"json": {
"entityKey": "PROJ-123",
"current": {
"fields": {
"summary": "Fix login bug",
"status": { "name": "In Progress" },
"created": "1735689600"
}
}
}
}
Source documents (v0.20.0+) are matched against different attributes: a source object (ref, name, id, rule, retrievedAt) and json (the callback body), with no file key. Only the document's designated rule is applied to it. See Source Documents.
Rules use standard JSON Schema for matching. The watcher uses ajv (all errors collected, non-strict mode, standard formats from ajv-formats) with full support for properties, required, type, const, enum, nested objects, arrays, and string patterns.
glob KeywordThe watcher registers a custom glob keyword for path matching using picomatch. Matching is case-insensitive and */** also match dotfiles and dot-directories:
{
"match": {
"properties": {
"file": {
"properties": {
"path": { "type": "string", "glob": "/data/tickets/**/*.json" }
}
}
}
}
}
Glob syntax:
** — matches any number of directories* — matches any characters within a segment{md,txt} — brace expansion for multiple patternsThis is the only custom keyword — everything else is pure JSON Schema.
When multiple rules match a file, they are processed in order with last-match-wins semantics at the property level:
{
"inferenceRules": [
{
"name": "default-category",
"description": "Default category for all files",
"match": { "type": "object" },
"schema": [
{ "properties": { "category": { "type": "string", "set": "general" } } }
]
},
{
"name": "important-override",
"description": "Override category for important files",
"match": {
"properties": {
"file": {
"properties": {
"path": { "glob": "**/important/**" }
}
}
}
},
"schema": [
{
"properties": { "category": { "type": "string", "set": "important" } }
}
]
}
]
}
Files under **/important/** get category: "important" (second rule wins).
Rules registered at runtime through POST /rules/register are evaluated after the config rules, so they win on conflict.
Every embedded point includes a matched_rules field: an array of the names of the inference rules that matched the document, in evaluation order. Only the watcher writes it.
Benefits:
{ "key": "matched_rules", "match": { "value": "jira-issue" } } returns all documents processed by that ruleExample Qdrant payload (excerpt):
{
"file_path": "/data/tickets/PROJ-123.json",
"chunk_index": 0,
"matched_rules": ["jira-issue", "json-subject"],
"domain": "jira",
"status": "In Progress",
"created": 1735689600
}
The watcher maintains a values index (values.v8 in stateDir, a V8-serialized file) tracking distinct values per rule. For each rule that matched a document, the distinct values of the document's metadata fields are recorded under that rule's name. Only trackable primitives (string, number, boolean) are indexed; arrays contribute their primitive elements.
Purpose: Populate dropdowns for select and multiselect fields when no enum is declared in the schema.
Shape (as returned per rule in GET /config):
{
"jira-issue": {
"status": ["Done", "In Progress", "In Review", "To Do"],
"priority": ["High", "Highest", "Low", "Lowest", "Medium"],
"assignee": ["Alice Example", "Bob Example"]
},
"slack-message": {
"channel_name": ["general", "project-jeeves-watcher"],
"userName": ["Alice Example", "Jeeves"]
}
}
Values are kept sorted.
Update dynamics:
Deletion behavior: When a file is deleted, its chunks are removed from Qdrant but the values index is not updated (no reference counting). Stale values persist until the next full reindex. This is acceptable: a stale value in a dropdown is cosmetic, not a correctness issue.
Queryable via JSONPath:
$.inferenceRules[?(@.name=='jira-issue')].values.status
Returns [["Done", "In Progress", "In Review", "To Do"]] in result when queried through GET /config?path=....
Location: {stateDir}/issues.json
A persistent, self-healing ledger of documents that failed to process. Keyed by file path (or source reference, for source documents), with a list of issue records per document.
Structure:
{
"/data/email/archive/msg-456.json": [
{
"type": "interpolation_error",
"message": "Rule \"email-archive\" schema failed: Property \"author_email\" in rule \"email-archive\" has no declared type. Every property must have a type.",
"timestamp": 1771865100,
"operation": "Rule interpolation",
"rules": ["email-archive"],
"details": [
{
"rule": "email-archive",
"stage": "schema",
"message": "Property \"author_email\" in rule \"email-archive\" has no declared type. Every property must have a type."
}
]
}
]
}
Rule-related issue types:
interpolation_error - a matching rule's schema step (merging, completeness, set resolution), render or template failed. A file is still indexed without the failed output (raw content is embedded when a template or render failed); a source document fails with this code and nothing is written (v0.20.0+). details lists each failed step (rule, stage, message).type_collision - defined in the issue code list, but not recorded by the current rule engine: when two rules set the same property, the later rule's value wins.Since v0.20.0 processing failures are recorded too (extraction_error, embedding_error, upsert_error), as are source-document failures (match_failed, rule_missing, no_content, source_unavailable, unsupported_content_type). See Issue Codes for the full list.
Behavior:
API: GET /issues returns { "count": <documents with issues>, "issues": { ... } }.
When configWatch.enabled is true (the default), the watcher monitors its config file. On config change:
configWatch.debounceMs (default: 1000ms)watch settings changedconfigWatch.reindex settingA reload that fails validation is logged and the previous config stays in effect.
Reindex modes:
{
"configWatch": {
"enabled": true,
"debounceMs": 10000,
"reindex": "issues"
}
}
| Mode | Behavior |
|---|---|
"issues" (default) |
Re-process only files in the issues file (cheap, targeted) |
"rules" |
Re-apply inference rules to all files (no re-embedding) |
"full" |
Full reindex of all watched files (use when changes affect the embedded text) |
Note:
"path"and"prune"are NOT valid for config-watch auto-trigger (config validation rejects them). They can only be triggered via explicit API calls.
Note: A config-triggered reindex covers files only. To re-apply changed rules to source documents, call
POST /reindexwith scoperulesorfull.
Issues reindex is the default because config changes typically fix issues: an interpolation error is resolved by editing a rule, and re-processing just the affected file is sufficient.
Rules reindex is enough when only metadata changes, for example:
Re-applying rules writes the changed values and removes payload keys the rules no longer project (system fields and stored enrichment are kept). Moving a file does the same against its new path.
Full reindex is needed when a template, render or other change alters the text that is embedded.
map)In addition to schema-based set values, rules can run a JsonMap transform to derive metadata from the attributes.
map can be an inline JsonMap object, a string reference to a named map defined in top-level config maps, or a path ending in .json (resolved relative to the config directory)map output overrides schema set output on field conflictrender and template are skippedThe $.lib functions are split, slice, join, toLowerCase, replace, get, lookupJson, mapLookup and fetchSiblings, plus every function exported by a mapHelpers module as <namespace>_<export>. fetchSiblings reads neighbouring files of a file document. Source rules get no file reader, so for a source document fetchSiblings returns an empty array.
map extracts a path segment{
"name": "extract-project",
"description": "Extract project name from path",
"match": { "type": "object" },
"schema": [
{ "properties": { "domain": { "type": "string", "set": "docs" } } }
],
"map": {
"project": {
"$": [
{ "method": "$.lib.split", "params": ["$.input.file.path", "/"] },
{ "method": "$.lib.slice", "params": ["$[0]", 0, 1] },
{ "method": "$.lib.join", "params": ["$[0]", ""] }
]
}
}
}
For a file path docs/readme.md, this produces:
{ "domain": "docs", "project": "docs" }
See Configuration Reference for details on maps.
Rules can include a template field — a Handlebars template that renders the document's data into embeddable markdown. When a template is present, the rendered output replaces the raw content for embedding.
Why: Raw JSON from API responses embeds poorly. Templates transform structured data into clean, readable markdown at index time.
.hbs or .handlebars is a file path (relative to the config directory); a key of the top-level templates is a named template; anything else is an inline template.json spread at the top level, then the attributes (file, frontmatter, json, or source), then the metadata merged so far (including this rule's set and map output).render is the declarative alternative: YAML frontmatter keys plus ordered Markdown body sections. A rule has template or render, never both.interpolation_error and the raw content is used (a source document fails instead).See Configuration Reference for templates and templateHelpers.
Metadata is built in layers with clear precedence:
Rules are evaluated in order. For each matching rule:
set templates are resolved and coercedmap (JsonMap) transformation runs (if present)map output overrides set output on field conflictLater rules override earlier rules on field conflict.
Enrichment written via the POST /metadata API is stored in the SQLite enrichment store (<stateDir>/enrichments.sqlite, built-in node:sqlite) and merged over the inference rule output on every write:
Note: Enrichment metadata is validated against the declared types of the rules matching the file's path. Invalid metadata is rejected with descriptive errors; properties no rule declares are allowed.
Reserved names are dropped from the merged metadata, and the watcher writes its own system fields (see Reserved Field Names).
inferred (from rules) → enrichment (from enrichments.sqlite) → system fields → final payload
↑ wins conflicts ↑ always win
{
"schemas": {
"base": {
"properties": {
"domain": {
"type": "string",
"description": "Content domain",
"uiHint": "select"
}
}
}
},
"inferenceRules": [
{
"name": "email-domain",
"description": "Email archive messages",
"match": {
"properties": {
"file": {
"properties": {
"path": { "glob": "/data/email/archive/**" }
}
}
}
},
"schema": ["base", { "properties": { "domain": { "set": "email" } } }]
},
{
"name": "meetings-domain",
"description": "Meeting transcripts and notes",
"match": {
"properties": {
"file": {
"properties": {
"path": { "glob": "/data/meetings/**" }
}
}
}
},
"schema": ["base", { "properties": { "domain": { "set": "meetings" } } }]
}
]
}
{
"name": "frontmatter-metadata",
"description": "Extract metadata from YAML frontmatter",
"match": {
"properties": {
"frontmatter": {
"required": ["title"]
}
}
},
"schema": [
{
"properties": {
"title": {
"type": "string",
"description": "Document title",
"uiHint": "text",
"set": "{{frontmatter.title}}"
},
"created": {
"type": "integer",
"description": "Creation date as unix timestamp",
"uiHint": "date",
"set": "{{frontmatter.created}}"
}
}
}
]
}
If frontmatter has created: "1735689600", type coercion converts it to integer 1735689600.
{
"name": "jira-issue",
"description": "Jira issue metadata from JSON exports",
"match": {
"properties": {
"json": {
"required": ["entityKey"]
}
}
},
"schema": [
{
"properties": {
"issue_key": {
"type": "string",
"description": "Jira issue key",
"uiHint": "text",
"set": "{{json.entityKey}}"
},
"status": {
"type": "string",
"description": "Current workflow status",
"uiHint": "select",
"set": "{{json.current.fields.status.name}}"
},
"priority": {
"type": "string",
"description": "Issue priority",
"enum": ["Highest", "High", "Medium", "Low", "Lowest"],
"uiHint": "select",
"set": "{{json.current.fields.priority.name}}"
}
}
}
]
}
This rule uses the base schema from example 1.
{
"name": "project-docs",
"description": "Markdown documentation under projects",
"match": {
"properties": {
"file": {
"properties": {
"path": { "glob": "/data/projects/**" },
"extension": { "const": ".md" }
},
"required": ["path", "extension"]
}
}
},
"schema": [
"base",
{
"properties": {
"domain": { "set": "projects" },
"category": { "type": "string", "set": "documentation" }
}
}
]
}
Besides the config file, rules can be registered at runtime (for example by a plugin). They are held in memory, evaluated after the config rules, and lost on restart. See the API Reference for full request and response details.
| Endpoint | Request body | Response |
|---|---|---|
POST /rules/register |
{ "source": "<non-empty string>", "rules": [ { "name": "<non-empty>", ... } ] } |
{ source, registered, totalVirtualRules } |
DELETE /rules/unregister |
{ "source": "<non-empty string>" } |
{ source, removed } |
DELETE /rules/unregister/:source |
none (source in the path) | { source, removed } |
POST /rules/reapply |
{ "globs": ["<non-empty string>", ...] } (at least one) |
{ matched, updated } |
400 invalid_request (error is the first problem, for example Missing required field: source, Missing required field: rules (array), Every rule must be an object with a name or Missing required field: globs (non-empty string array); details lists every Zod issue).POST /rules/register also returns 400 invalid_request (error: "Invalid rules") when the rules have duplicate names or a match schema does not compile; nothing is registered. Registering again under the same source replaces that source's rules.match.properties.file.properties.path.glob string, a rules reindex runs for those globs (files only).POST /rules/reapply re-applies the current rules, without re-embedding, to every indexed file whose path matches a glob (case-insensitive, dotfiles included), removing payload keys the rules no longer project. Source documents are never matched. A file that fails is logged and skipped.Use jeeves-watcher config validate to check the config file against the schema:
jeeves-watcher config validate -c ./my-config.json
This is a schema check only. To also check merged rule schemas, helper files and reserved field names, call POST /config/validate on a running watcher. With testPaths (files that exist) it also returns the matched rules and metadata per path, computed from each file's path and stats only (frontmatter and JSON content are not read):
curl -X POST http://localhost:1936/config/validate \
-H "Content-Type: application/json" \
-d '{"testPaths": ["/data/tickets/PROJ-123.json"]}'
For runtime testing, check logs:
jeeves-watcher start --config ./my-config.json
Query Qdrant to inspect payloads:
curl -X POST http://localhost:1936/search \
-H "Content-Type: application/json" \
-d '{"query": "test", "limit": 1}'
Use POST /config/match to test paths against rules without indexing (matching uses the path only):
curl -X POST http://localhost:1936/config/match \
-H "Content-Type: application/json" \
-d '{"paths": ["/data/tickets/PROJ-123.json"]}'
domain, created, updated belong in base schemaset wiringPOST /config/validate catches missing types before files douiHint deliberately — Every exposed filter field is a conscious decisionGET /issues surfaces problems before they become mysteriesuiHint: "select" with runtime values for dynamic enumsBefore (v0.4.x, no longer valid):
{
"inferenceRules": [
{
"match": { "type": "object" },
"set": {
"domain": "jira",
"status": "${json.current.fields.status.name}"
}
}
]
}
After (v0.5.0+):
{
"schemas": {
"base": {
"properties": {
"domain": { "type": "string", "description": "Content domain" }
}
}
},
"inferenceRules": [
{
"name": "jira-issue",
"description": "Jira issue metadata",
"match": { "type": "object" },
"schema": [
"base",
{
"properties": {
"domain": { "set": "jira" },
"status": {
"type": "string",
"description": "Current workflow status",
"set": "{{json.current.fields.status.name}}"
}
}
}
]
}
]
}
Key migration steps:
name and description to every ruleset object with schema arraytype for every propertyuiHint for search-filterable fieldsjeeves-watcher config validate -c <path>, then POST /config/validaterenderAs)When a rule includes template or render, the renderAs field declares the output content type as a file extension (without dot):
{
"name": "slack-message",
"description": "Slack channel messages",
"match": { "type": "object" },
"schema": ["base", "slack-common"],
"render": {
"frontmatter": ["channelName", "userName"],
"body": [{ "path": "text", "heading": 2 }]
},
"renderAs": "md"
}
Resolution order for files (used by POST /render):
renderAs from the last matching rule that declares it.md → "md")"txt" for extensionless filesA source document renders as "md" when its rule produced content, else "json".
Validation: renderAs must be 1–10 lowercase alphanumeric characters (/^[a-z0-9]{1,10}$/). The config rejects renderAs if neither template nor render is present on the rule.
interface InferenceRule {
name: string; // Required unique identifier
description: string; // Required human-readable description
match: Record<string, unknown>; // Required JSON Schema object
schema?: SchemaReference[]; // Array of named refs and inline objects
map?: Record<string, unknown> | string; // JsonMap definition, named map ref, or .json file path
template?: string; // Handlebars template (inline, named ref, or file path)
render?: RenderConfig; // Declarative structured renderer (mutually exclusive with template)
renderAs?: string; // Output file extension override (requires template or render)
}
type SchemaReference =
| string // Named reference to a global schema
| { type?: 'object'; properties?: Record<string, ResolvedProperty> };
interface ResolvedProperty {
type?: string; // JSON Schema type (required after merging)
description?: string; // Human-readable description
uiHint?: string; // UI rendering hint
enum?: unknown[]; // Enum values
items?: Record<string, unknown>; // Items schema for arrays
set?: string | unknown[]; // Interpolation template or array value
[keyword: string]: unknown; // Other JSON Schema keywords
}
See Configuration Reference for integration into the main config.