Rules
Rules and flows are Callora's low-code automation: an operator says "when this event fires and these conditions hold, run these actions" — no code, just configuration. Your plugin's job is to supply the vocabulary the operator composes from. A rule condition is half of that vocabulary (the when); a flow action is the other half (the do).
This page covers conditions. You implement an IRuleConditionEvaluator, export it, and from then on operators can drop your condition into any flow's condition tree via /api/flows.
The worked references are the built-in evaluators (src/Core/Application/Flows/Conditions/): DataFieldConditionEvaluator (data.field) and TimeWindowConditionEvaluator (time.window).
What you'll learn
- What a rule condition is and how it fits the rule/flow model
- How to implement and export an
IRuleConditionEvaluator - The evaluation context (
RuleContext) and the parameter bag operators fill in - How the host combines your leaf condition with
and/or/notinto a tree - A worked custom condition, and how an operator references it
The rule model
A flow carries a condition tree as JSON (FlowDefinition.ConditionsJson). The tree is built from RuleConditionNodes (src/Core/Application/Flows/):
public sealed record RuleConditionNode(
string Type, // "and" / "or" / "not", or a leaf type like "data.field"
RuleConditionNode[]? Children = null,
Dictionary<string, string>? Params = null);and/or/not are combinators the host evaluates itself. Every other Type is a leaf, resolved to an IRuleConditionEvaluator by its Type key. The RuleEvaluator (src/Core/Application/Flows/RuleEvaluator.cs) walks the tree:
and→ all children must be trueor→ any child truenot→ negates its single child- anything else → looked up in the evaluator registry and evaluated as a leaf
Unknown conditions fail closed
If a leaf Type has no registered evaluator, RuleEvaluator logs a warning and evaluates it to false — never silently true. A typo in a condition type makes the flow not match, rather than matching everything. Your evaluator's Type string is the contract operators depend on.
Implement a condition
IRuleConditionEvaluator (src/Core/Application/Flows/Contracts/) is deliberately tiny:
public interface IRuleConditionEvaluator
{
string Type { get; } // the leaf key, e.g. "call.direction" or "time.window"
bool Evaluate(RuleContext context, IReadOnlyDictionary<string, string> parameters);
}Evaluate is synchronous and pure — return true or false, no side effects. It receives two things:
RuleContext— the event that triggered the check.parameters— the leaf node'sParams: the values the operator configured for this condition in this flow.
The RuleContext (src/Core/Application/Flows/Contracts/RuleContext.cs):
public sealed record RuleContext(
string EventName, // the triggering event, e.g. "call.ringing"
string? WorkspaceKey, // null = host-wide
IReadOnlyDictionary<string, string> Data, // flat bag of event fields (from ToEventData())
DateTimeOffset Now); // injected — use this, not DateTimeOffset.UtcNowRead time from Now, never from the clock directly
RuleContext.Now is injected precisely so time-based conditions are testable. The built-in TimeWindowConditionEvaluator reads context.Now; a unit test can then evaluate "is it inside business hours?" for any fixed instant. Reaching for DateTimeOffset.UtcNow inside Evaluate breaks that.
How built-ins do it
DataFieldConditionEvaluator matches one event field against a pattern — one class serving call.direction, call.state, call.target, and every other field, instead of a class per field:
public sealed class DataFieldConditionEvaluator : IRuleConditionEvaluator
{
public string Type => "data.field";
public bool Evaluate(RuleContext context, IReadOnlyDictionary<string, string> parameters)
{
if (!parameters.TryGetValue("field", out var field) ||
!parameters.TryGetValue("value", out var pattern) ||
string.IsNullOrWhiteSpace(field))
{
return false; // misconfigured leaf → does not match
}
if (!context.Data.TryGetValue(field.Trim(), out var actual))
{
return false; // event doesn't carry that field
}
var p = pattern?.Trim() ?? string.Empty;
return p.EndsWith('*')
? actual.StartsWith(p[..^1], StringComparison.OrdinalIgnoreCase) // prefix match
: string.Equals(actual, p, StringComparison.OrdinalIgnoreCase); // exact match
}
}Note the pattern: missing or malformed parameters return false, never throw. A leaf that threw would break the whole flow dispatch; returning false degrades gracefully.
A worked custom condition
Suppose a plugin adds a caller.vip condition that matches when the event's caller is on a maintained VIP list for the workspace. It reads a field from the event data and consults a store:
using Callora.Core.Application.Flows.Contracts;
public sealed class VipCallerConditionEvaluator(IVipDirectory vipDirectory) : IRuleConditionEvaluator
{
public string Type => "caller.vip";
public bool Evaluate(RuleContext context, IReadOnlyDictionary<string, string> parameters)
{
// The operator picks which event field holds the caller (defaulting to "callerNumber").
var field = parameters.TryGetValue("field", out var f) && !string.IsNullOrWhiteSpace(f)
? f.Trim()
: "callerNumber";
if (context.WorkspaceKey is null ||
!context.Data.TryGetValue(field, out var caller) ||
string.IsNullOrWhiteSpace(caller))
{
return false;
}
// Evaluation is synchronous, so use a cached/synchronous lookup here.
return vipDirectory.IsVip(context.WorkspaceKey, caller);
}
}Export it from StartAsync:
public ValueTask StartAsync(IHostPluginContext context, CancellationToken cancellationToken = default)
{
var vipDirectory = context.Services.GetRequiredService<IVipDirectory>();
context.Export<IRuleConditionEvaluator>(new VipCallerConditionEvaluator(vipDirectory));
return ValueTask.CompletedTask;
}Evaluate is synchronous
The interface is bool Evaluate(...), not Task<bool>. Do fast, in-memory or cached lookups. The evaluator runs for every matching flow on every triggering event, so a blocking database call per condition is a hot-path hazard — prime a cache elsewhere and read it here.
How an operator uses it
Once exported, the condition type is available in any flow's condition tree. An operator creates a flow via POST /api/flows (see Flows) whose conditions reference your Type. To match a VIP caller during business hours:
{
"name": "Priority routing for VIP daytime calls",
"triggerEvent": "call.ringing",
"conditions": {
"type": "and",
"children": [
{ "type": "caller.vip", "params": { "field": "callerNumber" } },
{ "type": "time.window", "params": { "days": "mon,tue,wed,thu,fri", "from": "09:00", "to": "17:00", "timezone": "Europe/Berlin" } }
]
},
"actions": [ { "type": "call.accept", "params": {} } ]
}When a call.ringing event fires, the host builds a RuleContext from the event, walks this tree — your caller.vip leaf and the built-in time.window leaf — and, if it matches, enqueues the actions as a flow-execution job. Your job was only to make caller.vip mean something; the operator composed the rest.
Next steps
- Flows — contribute the actions that run when a rule matches, and the full
/api/flowsoperator surface - Background jobs — how a matched flow's actions are executed durably
- Events & jobs — the business events that trigger flows and populate
RuleContext.Data - Exporting extensions — exporting your evaluator