Skip to content

Rule Engine ​

The rule engine decides, before the agent runs, what happens to a message that arrives through a channel: the web workspace or a chat plugin. A rule matches on what the message says, where it came from, who sent it, or how it was made. Its decision lets the message through to the agent, stops it, starts a scheduled job for its sender, or runs an action on the conversation it arrived in.

Overview ​

inbound message
    │
    ▼  (priority 50)   RoutingHook    — tag detection, tool restriction, context injection
    │
    ▼  (priority 100)  RuleEngineHook — rule file evaluation
         │
         ├── no rule matched      → the agent answers
         ├── PassToDefaultAgent   → the agent answers
         ├── Ignore               → stopped; the sender is told it was ignored
         ├── StartWorkflow        → stopped; a cron job starts for the sender
         └── RunNativeAction      → stopped; an action runs on the conversation

Both run at BeforeAgentTurn. Rules are tried in descending priority, ties in the order they appear in the file; the first rule whose condition matches decides, and the rest are skipped.

Rules and routing are distinct features that both run before the model. Use routing to shape the prompt (detect tags, inject context, restrict tools). Use rules to decide whether the agent gets the message at all.

The rule engine runs in the daemon and in the REPL, on messages that arrive through channels. Text typed into the REPL is never stopped by a rule, and a TUI session applies no rules at all: it reads the file only for /rules test.

Config File ​

Rules live in their own TOML file. Point ugent.toml at it:

toml
# ugent.toml
[rules]
file = "/srv/ugent/rules.toml"

file is used as written: a relative path is taken from the workspace directory, which UGENT enters as it starts (the --workspace directory, else the one it was started in), and ~ is not expanded, so give an absolute path. Without [rules] file, UGENT uses the first rules.toml it finds in:

  1. <workspace>/rules.toml
  2. <workspace>/.ugent/rules.toml
  3. ~/.ugent/rules.toml

With the injection firewall's default settings, a rules.toml found inside the workspace loads only once you trust the workspace (/firewall workspace approve); until then it is skipped, with a warning that says so. With no rules file, there is no rule engine and every message goes to the agent.

Quick Start ​

toml
# rules.toml

# Drop obvious spam before it reaches the agent.
[[rules]]
name = "ignore-spam"
priority = 100
decision = "Ignore"
match = { field = "text", contains_any = ["unsubscribe", "you won the lottery"] }

# Everything else goes to the agent. A message no rule matches does anyway;
# this rule only says so.
[[rules]]
name = "default"
priority = 0
decision = "PassToDefaultAgent"
match = { always = true }

Each [[rules]] entry has:

KeyRequiredMeaning
nameyesUnique within the file.
prioritynoAn integer, default 0. Higher runs first.
matchyesOne condition.
decisionyesOne decision.

Write decision, and match when it is inline, before any [rules.match] or [rules.decision.…] header of the same rule: TOML puts every key after a header into that header's table. A rule has no enabled switch, and keys the engine does not know are ignored, so enabled = false leaves a rule live. Comment a rule out to turn it off.

Conditions ​

match holds one condition, as an inline table or as a [rules.match] table. A condition compares one field of the message:

ConditionMatches when the field
{ field = "text", contains_any = ["a", "b"] }contains any of the strings
{ field = "text", starts_with = "order" }starts with the string
{ field = "channel", equals = "web" }equals the string

All three ignore case. Conditions combine:

ConditionMatches when
{ all_of = [ ... ] }every condition in the list matches
{ any_of = [ ... ] }at least one condition in the list matches
{ not = { ... } }the condition does not match
{ always = true }always; always = false never matches

The fields a condition can name:

FieldHolds
textThe message text, after any routing rewrite. For a voice message, what was said.
channelThe channel it came through: web for the web workspace, or the plugin's channel, such as slack, discord or line.
source_typeThe kind of conversation, as the channel reports it: user from the web workspace; user, group or room from LINE; unknown from a channel that reports none.
user_idThe sender's id on that channel.
conversation_idThe conversation's id on that channel.
input_modeHow the message was made: voice or live. Absent on a typed message; see Matching Voice Messages.

A field the message does not carry never matches, so not around it always does.

Decisions ​

DecisionWritten asWhat happens
PassToDefaultAgentdecision = "PassToDefaultAgent"The agent answers as usual. Useful ahead of a lower-priority rule that would otherwise catch the message.
Ignoredecision = "Ignore"The message stops here, and the sender is answered rule engine: message ignored. It is not silent.
StartWorkflowa [rules.decision.StartWorkflow] tableThe message stops here, and the cron job it names starts for the sender. See Starting a Workflow.
RunNativeActiona [rules.decision.RunNativeAction] tableThe message stops here, and the action runs on the sender's conversation. See Conversation Actions.
toml
[[rules]]
name = "digest"
priority = 50
match = { field = "text", starts_with = "digest" }

[rules.decision.StartWorkflow]
workflow = "digest"         # a cron job with rule_trigger = true
inject_context = true       # optional, default false

[[rules]]
name = "remember"
priority = 40
match = { field = "text", starts_with = "remember this" }

[rules.decision.RunNativeAction]
action = "extract_memory"   # or compact_conversation; neither takes params

Starting a Workflow ​

StartWorkflow starts a cron job now, with the message as its input. Only a job that opts in can be started this way: it sets rule_trigger = true in cron.toml, and may then leave out schedule so that it runs only when a rule starts it.

toml
# cron.toml
enabled = true

[[jobs]]
id = "digest"
enabled = true
rule_trigger = true        # a rule may start it; no schedule, so only a rule does

[[jobs.tasks]]
id = "summarise"
prompt = "Summarise what the sender asked for, in one line."

The run a rule starts:

  • acts for the sender, not the owner: their memory, files and context-engine access, as a delegated task acts for the person who delegated it. A sender UGENT cannot identify starts a run that acts for nobody.
  • is held to the sender's limits: what their own turn would have declined (memory recall, memory write, code context), whether their request asked or the operator's caller defaults did, and the tools their request limited it to.
  • reads the message. Each prompt in the job is followed by a [rule_trigger] block holding the message, fenced so that nothing in it can pass for the job's own words, and, with inject_context = true, the channel, conversation and user it came from.
  • keeps the job's own rules: its overlap policy, max_concurrent_runs, tasks and destinations.

The sender is answered at once:

OutcomeAnswer
started, on a channel that can take the resultStarted workflow 'digest'. Its result will follow here.
started, from the web workspaceStarted workflow 'digest'. Its result goes to the workflow's destinations.
still running, and the job skips overlapsWorkflow 'digest' is already running. Try again when it finishes.
max_concurrent_runs reachedWorkflow 'digest' could not start: too many workflows are running.
no such job, disabled, or not opted inWorkflow 'digest' could not start: <reason>.
cron is not runningWorkflow 'digest' could not start: cron is not running here.

When the run ends, its result goes back to the conversation the message came from, besides the job's own destinations. The web workspace keeps no queued messages, so nothing is sent back there: give a job that web users start a destination they read.

Conversation Actions ​

RunNativeAction runs one of two actions on the conversation the message arrived in. Neither takes params.

actionWhat it does
compact_conversationCompacts the conversation now, as /compress does, and answers with what /compress reports. On a conversation's first message: There is nothing to compact yet.
extract_memorySaves durable facts from the conversation's latest six exchanges and from the message itself into the sender's own memory, and answers Saved 3 facts to memory., Saved 1 fact to memory. or Found nothing new to save to memory.

extract_memory saves nothing, and says why, when:

  • the sender's turn would decline memory writes (their request, their caller defaults or voice), or voice keeps speech out of memory (memory_write = false): Memory writes are off for this conversation. A conversation does not record which of its messages were spoken, so that setting covers all of it.
  • the sender is not identified: Memory cannot be saved for a sender who is not identified.
  • memory uses the default backend: Saving to memory needs the sqlite_fts or lance_embedded memory backend.
  • extraction fails: Saving to memory failed; the agent's log says why.

Once started, neither action stops for a stop request: a compaction dropped midway would be worse than one that finishes.

Rules Refused as They Load ​

UGENT checks each rule as the file loads and drops one it could never run, logged at error with the reason; the rest of the file loads:

text
rule dropped (rule=start-nightly, reason=workflow 'nightly' is not a cron job with rule_trigger = true)
rule dropped (rule=bad-action, reason=unknown native action 'memory_compaction'; the actions are compact_conversation and extract_memory)

A rule that gives an action params is dropped the same way. The workflow check reads the jobs UGENT runs now, so an edit to cron.toml counts without a restart. A rule dropped before its job opted in stays dropped until you save rules.toml again. The job is checked again when the rule fires, so one that opts out later is refused, and the sender is told why.

In the TUI, /rules test reads the file without these checks, so a rule the daemon drops can still match there.

Example: Channel Filtering ​

Only Slack and Discord reach the agent; everything else is ignored:

toml
[[rules]]
name = "channel-allowlist"
priority = 200
decision = "Ignore"

[rules.match]
not = { any_of = [
  { field = "channel", equals = "slack" },
  { field = "channel", equals = "discord" },
] }

The web workspace is a channel too (web), so this rule ignores it as well.

Example: Per-User VIP Routing ​

One person may use the Slack channel; everyone else there is ignored:

toml
[[rules]]
name = "vip-through"
priority = 300
decision = "PassToDefaultAgent"

[rules.match]
all_of = [
  { field = "channel", equals = "slack" },
  { field = "user_id", equals = "U12345678" },
]

[[rules]]
name = "slack-closed"
priority = 200
decision = "Ignore"
match = { field = "channel", equals = "slack" }

The higher priority runs first, so that person's messages are let through before the second rule can ignore them.

Example: Boolean Logic ​

Combine conditions with nested all_of, any_of and not:

toml
# In group chats, answer only messages that ask for the agent.
[[rules]]
name = "groups-need-a-trigger"
priority = 150
decision = "Ignore"

[rules.match]
all_of = [
  { field = "source_type", equals = "group" },
  { not = { any_of = [
    { field = "text", starts_with = "/ask" },
    { field = "text", contains_any = ["#agent"] },
  ] } },
]

This rule matches a group message that neither starts with /ask nor contains #agent; direct messages are untouched. Each message it ignores is still answered, so in a busy group rule engine: message ignored follows every message it catches.

Matching Voice Messages ​

A voice message is transcribed before the rule engine runs, so text is what was said: any typed text first, then the transcript. How the message was made is a field of its own, input_mode:

input_modeThe message
voiceA voice message
liveA request a live call handed to the agent
absentTyped, so a condition on input_mode never matches it
toml
# Do not take payment instructions by voice.
[[rules]]
name = "no-spoken-payments"
priority = 150
decision = "Ignore"

[rules.match]
all_of = [
  { field = "input_mode", equals = "voice" },
  { field = "text", contains_any = ["transfer", "payment"] },
]

source_type keeps its meaning, the kind of conversation, and is never set to the input mode. Only the web channel marks messages this way: a voice note from another channel arrives as an ordinary message with an audio attachment, transcribed the same way but with no input_mode.

Hot Reload ​

UGENT watches the rules file and re-reads it when it changes; no restart is needed. An edit that parses replaces the rules and is logged as rule set auto-reloaded, with the new count. An edit that does not parse is logged as rule set auto-reload failed — retaining previous rule set, and the rules already loaded stay in force.

A file that did not exist when UGENT started is not watched, and the log says rule engine watch disabled. Create it, then reload: in the REPL with /reload; in the daemon a rules.toml created in one of the locations above is picked up within five seconds, and a [rules] file elsewhere needs a restart or an edit to ugent.toml.

When the File Does Not Parse ​

The engine fails open. When UGENT starts, or rebuilds its runtime after a configuration reload, a rules file it cannot read or parse is logged as

text
failed to load rule set — starting with empty rules (fail-open)

with the error, and the engine runs with no rules at all. Nothing else says so: every message goes to the agent, and every rule silently does nothing. A key in the wrong table (the error says missing field), a condition of no known shape, and two rules with the same name each refuse the whole file this way.

To tell whether your rules are in force:

  • Read the log after a start or an edit. rule set loaded or rule set auto-reloaded, with the number of rules, means the file parsed, and rule dropped names each rule refused as it loaded.
  • Run /rules test <message> with a message a rule should match. [dry-run] matched rule "…" names the rule that would decide; [dry-run] no rule matched for: "…" means none would, or that the file did not load. In the REPL it checks the rules the running engine holds; in the TUI it reads the file itself. It tests as channel cli (tui in the TUI), source type user, and with no input_mode, so a rule that depends on those cannot be checked this way.
  • Do not rely on /rules status or /rules reload. The first says only whether a file is configured. The second answers Rule set reloaded. even when the file fails to parse; only the log shows the failure.

Routing vs Rule Engine ​

RoutingRule Engine
Priority50 (runs first)100 (runs after routing)
PurposeShape the promptDecide whether the agent gets the message
Can rewrite textYes (rewriters)No
Can restrict toolsYes (per-turn)No
Can block messagesYes (intent-reject)Yes (Ignore)
Can start workYes (delegate-research)Yes: a cron job that opts in (StartWorkflow)
Can match how a message was madeNoYes (input_mode)
Config filerouting.tomlrules.toml
Where it is setA discovered routing.toml; an inline [routing] in ugent.toml is ignored[rules] file in ugent.toml, or a discovered rules.toml

Use both together: routing enriches and shapes the prompt, then the rule engine decides whether the agent gets it.

Released under the Private Beta License.