Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 21 additions & 9 deletions examples/lobu-crm/funnel-digest.reaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
*
* Runs after the weekly Monday-9am window completes. `ctx.extracted_data` is
* whatever the watcher's `extraction_schema` produced — funnel snapshot, top
* action, stale leads, etc. We persist the digest as a `funnel_digest` event
* linked to every lead the watcher knows about so the next digest can compare
* stage_counts week-over-week without re-running classification.
*
* Pair with `notification_priority: high` on the watcher — the OS notification
* fires regardless of whether this script succeeds; this just produces durable
* knowledge.
* action, stale leads, etc. We:
* 1. persist the digest as a `funnel_digest` event linked to every lead the
* watcher knows about, so the next digest can compare stage_counts
* week-over-week without re-running classification; and
* 2. push it to the team via `client.notifications.send` — which fans out to
* the org's active bot connections (the #leads Slack connection) and the
* in-app inbox. `watcher_source` attributes it to this window.
*/
import type { ReactionContext } from "@lobu/connector-sdk";
import type { ReactionClient, ReactionContext } from "@lobu/connector-sdk";

interface DigestData {
top_action?: string;
Expand All @@ -20,7 +20,10 @@ interface DigestData {
gap?: string;
}

export default async (ctx: ReactionContext, client: any): Promise<void> => {
export default async (
ctx: ReactionContext,
client: ReactionClient
): Promise<void> => {
const data = ctx.extracted_data as DigestData;
const stageSummary = Object.entries(data.stage_counts ?? {})
.map(([stage, n]) => `${stage}: ${n}`)
Expand Down Expand Up @@ -48,4 +51,13 @@ export default async (ctx: ReactionContext, client: any): Promise<void> => {
top_action: data.top_action ?? null,
},
});

await client.notifications.send({
title: `Weekly funnel digest — ${ctx.window.window_end.slice(0, 10)}`,
body: content,
watcher_source: {
watcher_id: ctx.window.watcher_id,
window_id: ctx.window.id,
},
});
};
25 changes: 19 additions & 6 deletions examples/lobu-crm/inbound-triage.reaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
* Reaction for the `inbound-triage` watcher.
*
* Fires every 2h after the watcher LLM extracts new and enriched leads from
* GitHub/X/HN signals. Persists a `lead_interaction` event per run so the
* next digest can count them.
* GitHub/X/HN signals. Persists a `lead_interaction` event per run so the next
* digest can count them, and — when the run is notable — pushes the recommended
* actions to the team via `client.notifications.send` (fans out to the #leads
* Slack connection + the in-app inbox).
*/
import type { ReactionClient, ReactionContext } from "@lobu/connector-sdk";

Expand All @@ -28,17 +30,28 @@ export default async (
const actions = data.recommended_actions ?? [];
if (actions.length === 0) return;

const summary = [
`Triage run ${ctx.window.window_end.slice(0, 16)} — ${actions.length} action(s)`,
...actions.map((a, i) => `${i + 1}. ${a}`),
].join("\n");

await client.knowledge.save({
entity_ids: ctx.entities.map((e) => e.id),
content: [
`Triage run ${ctx.window.window_end.slice(0, 16)} — ${actions.length} action(s)`,
...actions.map((a, i) => `${i + 1}. ${a}`),
].join("\n"),
content: summary,
semantic_type: "lead_interaction",
metadata: {
window_id: ctx.window.id,
new_lead_count: data.new_leads?.length ?? 0,
action_count: actions.length,
},
});

await client.notifications.send({
title: `Inbound triage — ${actions.length} action(s), ${data.new_leads?.length ?? 0} new lead(s)`,
body: summary,
watcher_source: {
watcher_id: ctx.window.watcher_id,
window_id: ctx.window.id,
},
});
};
2 changes: 2 additions & 0 deletions packages/connector-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,15 @@ export { browserNetworkSync } from './browser-network.js';
export type { ReactionContext } from './reaction-sdk.js';
export type { ReactionClient } from './reaction-client-types.js';
export type {
CardElement,
EntityCreateInput,
EntityLinkInput,
EntityListFilter,
EntityUpdateInput,
KnowledgeReadInput,
KnowledgeSaveInput,
KnowledgeSearchInput,
NotificationsSendInput,
} from './reaction-client-types.js';
export type { Env } from './types.js';

Expand Down
56 changes: 52 additions & 4 deletions packages/connector-sdk/src/reaction-client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ import type { ReactionContext } from "./reaction-sdk.js";

export type { ReactionContext };

/**
* A rich card for chat delivery, as a plain serializable object — a `chat`
* `CardElement` built with the card primitives (`Card`, `Section`, `Field`,
* `Actions`, `Button`, `Select`, …). Typed loosely here so the SDK's published
* declarations don't force consumers to install `chat`; the gateway validates
* and renders it to each platform's native format (Block Kit / Adaptive Cards /
* Google Chat Cards).
*/
export type CardElement = Record<string, unknown>;

// ── Knowledge ────────────────────────────────────────────────────────────────

export interface KnowledgeSearchInput {
Expand Down Expand Up @@ -80,15 +90,44 @@ export interface EntityListFilter {
sort_order?: "asc" | "desc";
}

export interface NotificationsSendInput {
/** Notification title (≤200 chars). */
title: string;
/** Body text (≤1000 chars). */
body?: string;
/**
* Optional rich card built with the `chat` card primitives (`Card`,
* `Section`, `Field`, `Actions`, `Button`, `Select`, …). When set,
* bot-connection delivery posts this card — rendered to each platform's
* native format (Slack Block Kit, Teams Adaptive Cards, Google Chat Cards) —
* instead of the markdown body; the in-app inbox entry still uses title/body.
*/
card?: CardElement;
/**
* Who to notify. `"admins"` (default): org admins/owners. `"all"`: every
* member. Or an array of specific user IDs.
*/
recipients?: "admins" | "all" | string[];
/** Relative URL the notification links to (e.g. `/acme/entities`). */
resource_url?: string;
/** Deliver only through this specific bot connection (its id). */
connection_id?: string;
/** Arbitrary JSON payload appended to the body as formatted JSON. */
data?: Record<string, unknown>;
/** Attribution when sent from a watcher reaction. */
watcher_source?: { watcher_id: number; window_id: number };
}

// ── Client ───────────────────────────────────────────────────────────────────

/**
* The client object available in reaction scripts.
*
* `client.knowledge` — read/write/search knowledge events
* `client.entities` — CRUD entities and relationships
* `client.query` — raw SQL (results as JSON rows)
* `client.log` — structured logging (appears in watcher run logs)
* `client.knowledge` — read/write/search knowledge events
* `client.entities` — CRUD entities and relationships
* `client.notifications` — push a notification to the org's inbox + bot connections (Slack/Telegram)
* `client.query` — raw SQL (results as JSON rows)
* `client.log` — structured logging (appears in watcher run logs)
*/
export interface ReactionClient {
knowledge: {
Expand Down Expand Up @@ -125,6 +164,15 @@ export interface ReactionClient {
search(query: string, options?: { limit?: number }): Promise<unknown>;
};

notifications: {
/**
* Send a notification: writes it to the org inbox and fans it out to the
* org's active bot connections (Slack/Telegram). This is how a reaction
* surfaces its digest to a chat channel.
*/
send(input: NotificationsSendInput): Promise<{ notified_count: number }>;
};

/** Run a read-only SQL query against the org's Postgres. */
query(sql: string): Promise<unknown[]>;

Expand Down
Loading
Loading