These pages describe Fibric’s target architecture. Public CLI, API, SDK, and sandbox access are not available. Examples do not establish deployed capabilities.
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`,
},
]),
};
},
});
This definition returns a plan for the executor to check. It should not call the order system directly. Tenant policy, single-flight coordination, and idempotency controls belong to the controlled execution path.
#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.