Fibric. Docs fibric.io →
v0.9 ยท preview
Build

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

defineOperator
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.
  },
});
FieldTypeRequiredDescription
namestringyesThe operator's name within the tenant. It appears on every plan and receipt the operator produces.
goalstringyesThe outcome, in plain language. It anchors the model's reasoning on every run and is part of the reviewed contract, not a tunable.
requiresstring[]yesThe complete list of capabilities the operator may sense or propose through. This is the allowlist; see below.
modelstringnoA model-router alias (router:reasoning), never a hardcoded vendor model. The router seam resolves it per tenant.
triggerobjectyesWhat wakes the operator: an event subscription, a schedule, or an explicit ask.
run(ctx) => Promise<ExecutionPlan>yesThe 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.

TriggerFires whenWhat 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.

sensing inside run()
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

@fibric/kernel — ExecutionPlan
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:

KeyConventionGuarantees
entity_keykind:idorder:SO-11290, zone:12Single-flight: the executor serializes all side effects sharing the key, so two plans racing on one order cannot interleave.
idempotency_keyoperator:entity:actionship-risk:SO-11290:holdDedup: the same intended effect is applied at most once, no matter how many runs propose it.
deterministic keys around the model
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:

  1. At sense time: ctx.sense() resolves only capabilities in requires. There is no call that reaches an unbound capability.
  2. At validation time: a returned plan whose action names a tool outside requires fails validation; it is never submitted.
  3. 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:

the tenant side: what disposes this operator's plans
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:

MechanismScopeUse for
correlation_idOne thread of workEverything 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 keysPer intended effectThe 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.statePer operator, small, key-valueWatermarks 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 envelopesThe tenant's event streamAn operator can propose emitting an event (source: 'operator:ship-risk', with agent_id set) as a durable, queryable record other operators can sense.
!
Do not accumulate a private world model

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

operators/ship-risk/operator.ts
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