Reactions
Reactions are part of @lobu/connector-sdk — they’re the typed hook you write to take action after a watcher’s LLM extraction completes (there is no separate @lobu/reaction-sdk npm package). The default watcher path is: LLM extracts data → Lobu validates against the schema → result is persisted to memory. Adding a reaction lets you do imperative work on top of that — post a Slack message, write a derived event, mutate an external system — before the run lands in the durable log.
Reactions are optional. A watcher without one is pure extraction; a watcher with one is extraction + a typed hook.
Install
Section titled “Install”The reaction surface ships inside @lobu/connector-sdk:
bun add @lobu/connector-sdkYou only need the ReactionContext type at authoring time:
import type { ReactionContext } from "@lobu/connector-sdk";The client runtime is injected by the Lobu sandbox at execution time — there’s nothing to import for it.
A typed reaction, end to end
Section titled “A typed reaction, end to end”A reaction is a default-exported async function. The runtime invokes it with (ctx, client) after a watcher window completes.
The example below pairs with a critical-detection watcher whose extraction_schema produces a CriticalDetection payload. When the LLM flags severity critical, the reaction posts to a Slack incoming webhook and writes a derived incident event so dashboards have a stable row to count.
import type { ReactionContext } from "@lobu/connector-sdk";
// The shape the watcher's `extraction_schema` produces. The schema lives// in YAML; we mirror it as a TypeScript interface so the reaction is// fully typed against the same contract.interface CriticalDetection { severity: "low" | "medium" | "high" | "critical"; summary: string; evidence_event_ids?: number[];}
// Slack incoming webhook URL is provisioned per-org and surfaced to the// reaction via the watcher's metadata bag.interface ReactionParams { slack_webhook_url?: string;}
export default async ( ctx: ReactionContext, client: { knowledge: { save: (input: Record<string, unknown>) => Promise<unknown> } }, params?: ReactionParams,): Promise<void> => { const detection = ctx.extracted_data as unknown as CriticalDetection; if (detection.severity !== "critical") return;
// 1. Notify Slack via the org's incoming webhook. const webhook = params?.slack_webhook_url; if (webhook) { await fetch(webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: `:rotating_light: *${ctx.watcher.name}* — ${detection.summary}`, }), }); }
// 2. Persist a derived `incident` event so the renewal-risk view and // weekly digest have a queryable record without re-extracting. await client.knowledge.save({ entity_ids: ctx.entities.map((e) => e.id), content: `[${detection.severity.toUpperCase()}] ${detection.summary}`, semantic_type: "incident", metadata: { severity: detection.severity, window_id: ctx.window.id, evidence_event_ids: detection.evidence_event_ids ?? [], }, });};A few notes:
- The
clientargument is typed inline. There is no exportedClientSDKtype from@lobu/connector-sdk(the runtime shape lives inpackages/server/src/sandbox/client-sdk.ts). Declare the subset you actually call — the example above pins justclient.knowledge.save— and TypeScript will catch typos at the call site without anyas any. ctx.extracted_datais typed asRecord<string, unknown>because the watcher’sextraction_schemalives in YAML and TypeScript can’t see it. Cast once to your interface at the top of the function and you’re done.- Network calls follow the gateway’s egress policy. The Slack webhook host must be in the agent’s
WORKER_ALLOWED_DOMAINS(or routed through the egress judge) — see Network.
ReactionContext
Section titled “ReactionContext”The first argument. Read-only — every field comes from the watcher run that just completed.
| Field | Type | Description |
|---|---|---|
extracted_data | Record<string, unknown> | The LLM’s output, validated against the watcher’s extraction_schema. Cast to a typed interface in your reaction. |
entities | ReactionEntity[] | Every entity the watcher is attached to. Each has id, name, entity_type, and metadata. |
window | object | The window that was just analyzed: id, watcher_id, window_start, window_end, granularity, content_analyzed. |
watcher | object | Watcher identity: id, slug, name, version. Use slug for log lines you’ll grep on. |
organization_id | string | Org UUID. Useful when calling out to external systems that need org-scoping. |
The full type is at reference/reaction-sdk › ReactionContext.
The client runtime
Section titled “The client runtime”The second argument is a ClientSDK injected by the sandbox. The exact surface lives in packages/server/src/sandbox/client-sdk.ts. The most useful pieces for reactions:
| API | What it does |
|---|---|
client.knowledge.save({...}) | Append a new event to memory. Set entity_ids to attach to the right entities, semantic_type to classify it, supersedes_event_id to tombstone an earlier event. |
client.knowledge.search({...}) | Hybrid (vector + full-text) search across the org’s events. Use for “have I seen this before?” checks before writing duplicates. |
client.knowledge.delete({...}) | Tombstone an event. Append-only: this writes a new superseding row, it never DELETEs. |
client.knowledge.read({...}) | Fetch a single event by id, or pull the events that were in the watcher’s window. |
For side effects on external systems (Slack, Linear, GitHub), call those APIs directly with fetch — credentials live in the connector’s auth_profile, not on the reaction.
The sandbox times reactions out, sandboxes their network access through the worker proxy (so the same WORKER_ALLOWED_DOMAINS rules apply), and captures stdout/stderr to the run log.
Where the file lives
Section titled “Where the file lives”In your Lobu project, drop the reaction next to the watcher it pairs with:
my-agent/├── lobu.config.ts├── reactions/│ └── critical-detection.reaction.ts└── agents/my-agent/...The watcher names its reaction. Point a watcher at a reaction with the reaction field in defineWatcher:
import { defineWatcher, reactionFromFile } from "@lobu/cli/config";import type criticalDetectionReaction from "./reactions/critical-detection.reaction.ts";
const criticalDetection = defineWatcher({ agent: myAgent, slug: "critical-detection", prompt: "Flag any critical incidents.", extractionSchema: { type: "object", properties: {} }, reaction: reactionFromFile<typeof criticalDetectionReaction>( "./reactions/critical-detection.reaction.ts" ),});The path is relative to the config file and must stay under the project directory. Passing the handler’s type via the generic (import type + reactionFromFile<typeof criticalDetectionReaction>) is optional — bare reactionFromFile("./reactions/critical-detection.reaction.ts") still works — but it gives you go-to-definition, rename, and a tsc error if the reaction’s default export drifts from the (ctx, client, params?) handler signature. The import type is erased at compile time, so the reaction module is never loaded while your config is evaluated.
If you don’t want a reaction, omit the reaction field. The watcher’s extraction still gets persisted; the reaction just doesn’t fire.
When to reach for a reaction
Section titled “When to reach for a reaction”| Need | Reaction? |
|---|---|
| ”Persist the LLM’s output to memory” | No — the watcher already does that. |
| ”Notify Slack when the LLM flags X” | Yes — fetch the Slack incoming webhook inside the reaction. |
| ”Write a derived, denormalized event for fast querying” | Yes — client.knowledge.save with a distinct semantic_type. |
| ”Mutate an external system based on extraction” | Yes — fetch the target API; the worker’s egress policy still applies. |
| ”Suppress some extractions” | Conditional return; early — no save call, no notification. Note the extraction itself still lands in the watcher window record. |
See it in production
Section titled “See it in production”examples/sales/account-health-monitor.reaction.ts— filters worsening risk transitions out of a watcher’s account-changes extraction and persists each one as a typedhealth_changeevent.
See also
Section titled “See also”- Connector SDK — how external events arrive in the first place.
- Reactions reference — every type a reaction can read, all exported from
@lobu/connector-sdk. - Memory — how reactions plug into the entity model.