Defining operators
An operator is a named AI worker with a goal, a fixed set of capabilities, and a three-phase loop: it senses through connectors, reasons with a base model, and proposes a validated ExecutionPlan that a deterministic executor disposes. This page is the author's reference for defineOperator(): subscriptions, the reasoning contract and the plan it must produce, the action allowlist, guardrail binding, and what state an operator may keep. The concept page covers the why; your first operator is the tutorial.
Anatomy of an operator
import { defineOperator } from '@fibric/sdk';
export default defineOperator({
name: 'ship-risk',
goal: 'Catch orders that will miss their promised ship date, and hold the ones a human should look at first.',
requires: ['orders.read', 'orders.hold', 'notify.send'],
model: 'router:reasoning', // resolved by the model-router seam
trigger: { on: 'order.*' }, // or { every: '15m' } / { on: 'ask' }
async run(ctx) {
const open = await ctx.sense('orders.read', { status: 'open' });
const plan = await ctx.reason(open); // model proposes; returns an ExecutionPlan
return plan; // the executor disposes. the operator stops here.
},
});
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | The operator's name within the tenant. It appears on every plan and receipt the operator produces. |
goal | string | yes | The outcome, in plain language. It anchors the model's reasoning on every run and is part of the reviewed contract, not a tunable. |
requires | string[] | yes | The complete list of capabilities the operator may sense or propose through. This is the allowlist; see below. |
model | string | no | A model-router alias (router:reasoning), never a hardcoded vendor model. The router seam resolves it per tenant. |
trigger | object | yes | What wakes the operator: an event subscription, a schedule, or an explicit ask. |
run | (ctx) => Promise<ExecutionPlan> | yes | The whole loop: sense, reason, return a plan. It cannot act; there is no side-effecting call available on ctx. |
Senses: event subscriptions
The trigger decides when run() executes. Event subscriptions are the primary form: the kernel's router matches the trigger pattern against every envelope's event_type and hands matching envelopes to the operator.
| Trigger | Fires when | What run() receives |
|---|---|---|
{ on: 'order.created' } | An envelope with exactly that event_type arrives. | The envelope, on ctx.event. |
{ on: 'order.*' } | Any envelope whose type matches the glob. * matches a single dot-delimited segment, so order.* matches order.created but not order.item.shipped. | The envelope, on ctx.event. |
{ every: '15m' } | On the schedule. The tick itself is an envelope with source: 'cron'. | The tick envelope. |
{ on: 'ask' } | A person asks explicitly, from the workspace or the CLI. | An envelope carrying the ask. |
A subscription narrows attention; it does not deliver state. Inside run(), the operator reads current state through ctx.sense(), which resolves a capability to whichever connector the tenant bound it to. Sense from the world, not from the triggering payload, whenever the decision matters: the envelope says something happened, and time has passed since.
async run(ctx) {
// the envelope that woke us: identity of the entity, not the source of truth
const orderId = ctx.event.payload.entity?.id;
// current state, through the capability binding (Magento today, anything tomorrow)
const order = await ctx.sense('orders.read', { order_id: orderId });
const thread = await ctx.sense('conversations.read', { about: orderId });
// ...
}
The reasoning contract
The platform tenet, stated once more because everything here follows from it: the LLM proposes a validated ExecutionPlan; a deterministic executor disposes. ctx.reason() is the only model call in the loop. It receives what the operator sensed, reasons inside the goal, and must return a plan that validates against the kernel type; anything else, malformed JSON, an unknown tool, a missing key, is rejected before it goes anywhere near the executor. A model cannot talk its way past a schema.
The plan an operator returns
export interface PlannedAction {
connector: string;
tool: string;
args: Record<string, unknown>;
value?: number; // e.g. a refund amount, for max_value policies
entity_key: string; // single-flight key: serialize side-effects per entity
idempotency_key: string; // dedup key for the side-effect
}
export interface ExecutionPlan {
reasoning?: string;
actions: PlannedAction[];
}
An empty plan is a first-class outcome. An operator that senses, reasons, and concludes nothing should be done returns { actions: [] }, ideally with its reasoning filled in; proposing something because the loop ran is exactly the failure mode the contract exists to prevent. Populate reasoning in every plan: it is stored with the plan, shown in review queues, and is what a human reads when deciding whether to trust the operator with more.
Constructing the keys
The two keys on every action are the operator's responsibility, built deterministically in code after reasoning, never left to the model to invent:
| Key | Convention | Guarantees |
|---|---|---|
entity_key | kind:id — order:SO-11290, zone:12 | Single-flight: the executor serializes all side effects sharing the key, so two plans racing on one order cannot interleave. |
idempotency_key | operator:entity:action — ship-risk:SO-11290:hold | Dedup: the same intended effect is applied at most once, no matter how many runs propose it. |
const verdicts = await ctx.reason(risky); // model decides WHAT; code decides the keys
return {
reasoning: verdicts.summary,
actions: verdicts.holds.map((h) => ({
connector: ctx.binding('orders.hold'), // the bound connector's id
tool: 'order.hold',
args: { order_id: h.order_id, reason: h.reason },
entity_key: `order:${h.order_id}`,
idempotency_key: `ship-risk:${h.order_id}:hold`,
})),
};
The action allowlist
The requires array is a hard boundary, enforced in three places, none of which is the model's good behavior:
- At sense time:
ctx.sense()resolves only capabilities inrequires. There is no call that reaches an unbound capability. - At validation time: a returned plan whose action names a tool outside
requiresfails validation; it is never submitted. - At disposition time: the executor evaluates the tenant's trust policy default-closed. Even an allowlisted tool is blocked if no policy rule allows it.
Keep the list minimal and by intent, never by vendor: orders.hold, not cn-magento. The tenant's capability bindings decide which connector answers, which is what makes swapping the vendor a configuration change under a running operator.
Guardrail binding
Guardrails do not live in the operator; they live in the tenant's policy, expressed in the kernel's TrustPolicy shape, and the executor consults them on every side-effecting action. The operator author's obligations are narrower but real:
- Set
valueon every action a value ceiling could apply to. A refund action withoutvaluecannot be bounded by amaxValuerule, and review treats an unbounded money action as a defect. - Expect
BLOCKand design for it. A blocked action is a receipt, not an exception. The next run should sense the unchanged world and may propose again; it must not escalate around the refusal. - Ship recommended defaults if the operator will be packaged. A pack carries a suggested
TrustPolicy[]the installer may accept; the tenant policy always has the last word. See the pack manifest.
const policy: TrustPolicy[] = [
{ tool: 'order.hold', decision: 'ALLOW' },
{ tool: 'notify.send', decision: 'ALERT' }, // act, and page a human
// no rule for order.refund: proposing it yields BLOCK. fail closed.
];
Memory and state
Runs are stateless by default, and most operators should stay that way: the world is the state, and sensing it fresh each run is what keeps behavior auditable. What continuity exists is explicit and bounded:
| Mechanism | Scope | Use for |
|---|---|---|
correlation_id | One thread of work | Everything a run produces shares the triggering envelope's correlation id, so plans, receipts, and follow-up events read as one thread. Free; you do nothing. |
| Idempotency keys | Per intended effect | The durable memory of "already done". An operator does not need to remember it held SO-11290; proposing the hold again is a DEDUP, not a duplicate. |
ctx.state | Per operator, small, key-value | Watermarks and cursors: the last reading a threshold operator compared against, the window a digest last covered. Read at the top of run(), written by returning it alongside the plan. |
| Own envelopes | The tenant's event stream | An operator can propose emitting an event (source: 'operator:ship-risk', with agent_id set) as a durable, queryable record other operators can sense. |
State that grows without bound, caches of sensed data, or conclusions carried across runs make an operator's behavior depend on history no reviewer can see. If a decision needs a fact, sense it. ctx.state is for watermarks, not memory of the world.
A complete operator
import { defineOperator } from '@fibric/sdk';
export default defineOperator({
name: 'ship-risk',
goal: 'Catch orders that will miss their promised ship date, and hold the ones a human should look at first.',
requires: ['orders.read', 'orders.hold', 'notify.send'],
model: 'router:reasoning',
trigger: { every: '15m' },
async run(ctx) {
// SENSE
const open = await ctx.sense('orders.read', { status: 'open' });
// REASON: the model decides which orders are at risk and why
const verdicts = await ctx.reason({
input: open,
instruction: 'Identify orders that will miss their promised ship date. For each, give a hold reason and a one-line explanation.',
});
if (verdicts.holds.length === 0) {
return { reasoning: 'No orders at risk this run.', actions: [] };
}
// PROPOSE: deterministic keys, allowlisted tools, value-free actions
return {
reasoning: verdicts.summary,
actions: verdicts.holds.flatMap((h) => [
{
connector: ctx.binding('orders.hold'),
tool: 'order.hold',
args: { order_id: h.order_id, reason: h.reason },
entity_key: `order:${h.order_id}`,
idempotency_key: `ship-risk:${h.order_id}:hold`,
},
{
connector: ctx.binding('notify.send'),
tool: 'notify.send',
args: { channel: 'ops', text: `held ${h.order_id}: ${h.explanation}` },
entity_key: `order:${h.order_id}`,
idempotency_key: `ship-risk:${h.order_id}:notify`,
},
]),
};
},
});
Nothing in this file can reach the order system. The only exit to the world is the returned plan, and the executor stands behind it with the tenant's policy, per-order single-flight, and dedup on every key. That split, intelligence in the operator, guarantees in the kernel, is the entire design.
Keep going
- Operator pack manifest: packaging this shape for others to install.
- Exposing actions: the contract on the other side of every proposed tool.
- Governance & trust: the disposition machinery in full.
- Example projects: complete operators, including MQTT and scheduled reporting.