Hourly CRM inbound orchestrator for three inboxes using Notion-synced SOP, strict business-lead filt
Hourly CRM inbound orchestrator for three inboxes using Notion-synced SOP, strict business-lead filtering, Supabase persistence, and actionable-only Slack reporting.
Use this skill for hourly polling CRM workflows across:
info@yourdomain.comsales@yourdomain.comsupport@yourdomain.comThe source-of-truth SOP is synced from Notion page CRM_SOP_PAGE_ID every run.
Grab the skill package ZIP file using the button above.
Extract and move the folder into your AI agent's skills directory.
Your agent now knows the skill. Just ask it to perform the task!
This is the raw instruction document consumed by your AI agent.
Use this skill for hourly polling CRM workflows across:
info@yourdomain.comsales@yourdomain.comsupport@yourdomain.comThe source-of-truth SOP is synced from Notion page CRM_SOP_PAGE_ID every run.
Required:
NOTION_API_KEYCRM_SOP_PAGE_ID (default: 31288fb313488013924ade7bf704ab6f)CRM_MONITORED_EMAILS (comma-separated)CRM_POLL_QUERY (default: in:inbox is:unread -in:spam -in:trash -category:promotions -category:social -category:updates -category:forums)CRM_POLL_OVERLAP_MINUTES (default: 120)SUPABASE_URLSUPABASE_SECRET_KEYOptional:
CRM_POLL_MAX_RESULTS (default: 200)CRM_POLL_MAX_AGE_HOURS (default: 36)CRM_SOP_CACHE_FILE (default: /tmp/crm-inbound-sop-cache.json)CRM_POLL_STATE_TABLE (default: crm_poll_state)CRM_CONTACTS_TABLE (default: crm_contacts)CRM_ACTIVITIES_TABLE (default: crm_activities)CRM_DRAFTS_TABLE (default: crm_drafts)CRM_ACCOUNTING_TABLE (default: accounting_entries)CRM_JOB_RUNS_TABLE (default: crm_job_runs)GOG_ACCOUNT (fallback sender account for approvals)CRM_OUTSTANDING_LOOKBACK_DAYS (default: 7)CRM_OUTSTANDING_STALE_HOURS (default: 24)CRM_OUTSTANDING_NOTIFY_EMPTY (default: false)CRM_CLASSIFIER_MODEL (default: gpt-5-nano)CRM_REPLY_MODEL (default: gpt-5.2)CRM_USE_MODEL_CLASSIFIER (default: true)CRM_USE_MODEL_REPLY_WRITER (default: true)OPENAI_API_KEY (required to use model classifier/reply writer)CRM_GMAIL_LABEL_APPLY (default: true)CRM_GMAIL_LABEL_LEAD (default: CRM/Lead)tsx {baseDir}/scripts/fetch-sop.ts fetch_sop
Optional flags:
--page-id <id>--cache-file <path>--output <path>tsx {baseDir}/scripts/poll-inboxes.ts poll_inboxes
Optional flags:
--accounts <csv>--query <gmail-query>--overlap-minutes <n>--max-age-hours <n>--output <path>tsx {baseDir}/scripts/process-inbound.ts process_inbound \
--poll-file /tmp/crm-poll.json
Optional flags:
--sop-file <path>--output <path>tsx {baseDir}/scripts/approval-action.ts approval_action \
--action approve \
--draft-id <draft_id> \
--approved-by "U052337J8QH"
Also supported:
--action revise --notes "<feedback>"--action reject --reason "<reason>"tsx {baseDir}/scripts/check-outstanding.ts check_outstanding
Optional flags:
--lookback-days <n> (default: 7)--stale-hours <n> (default: 24)--output <path>For each actionable lead, post a simple Slack card containing only:
Approval/revision happens in the Slack thread, not via command strings in the main message.
account_email:message_id.gpt-5-nano into receipt|sales|support|ignore (fallback to heuristics only if model call fails).classification, lead, inbound, routing, qualification) and inject it into the classifier prompt.sales when business ask is explicit.CRM/Lead (or CRM_GMAIL_LABEL_LEAD) to sales threads.view in browser, unsubscribe, manage preferences, roundup-style blasts, Gmail promotional categories) are forced to ignore unless explicit lead criteria are met.sales guesses are downgraded to ignore.gpt-5.2Use clear headings in your Notion page so policy extraction stays deterministic:
Business Context
Lead Classification Rules
sales): person/company reaching out for consulting, sponsorship, partnerships, affiliate opportunities, expert-network interviews, or any paid advisory callLead Qualification Checklist
Response Playbook
Out-of-Scope
Reference template:
cat {baseDir}/references/notion-inbound-sop-template.md
Tables:
crm_contactscrm_activitiescrm_draftsaccounting_entriescrm_job_runscrm_poll_stateReference DDL:
cat {baseDir}/references/supabase-schema.sql
openclaw cron add \
--name "CRM hourly polling" \
--cron "0 * * * *" \
--tz "America/New_York" \
--session isolated \
--message "Run crm-inbound-orchestrator hourly polling cycle. Use skill crm-inbound-orchestrator. Run fetch_sop, poll_inboxes, process_inbound. Only report actionable items."
openclaw cron add \
--name "CRM morning outstanding check" \
--cron "20 9 * * *" \
--tz "America/Toronto" \
--session isolated \
--message "Run crm-inbound-orchestrator outstanding review. Use skill crm-inbound-orchestrator. Run check_outstanding for last 7 days and post a concise summary to Slack."
degraded.Browse additional components, config blocks, and reference sheets included in the ZIP.
crm-inbound-orchestrator/SKILL.md
crm-inbound-orchestrator/references/notion-inbound-sop-template.md
crm-inbound-orchestrator/references/supabase-schema.sql
crm-inbound-orchestrator/scripts/approval-action.ts
crm-inbound-orchestrator/scripts/check-outstanding.ts
crm-inbound-orchestrator/scripts/fetch-sop.ts
crm-inbound-orchestrator/scripts/poll-inboxes.ts
crm-inbound-orchestrator/scripts/process-inbound.ts
skills/crm-inbound-orchestrator/scripts/fetch-sop.ts
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
type CliArgs = {
command?: string;
flags: Record<string, string | boolean>;
};
type NotionBlock = {
id: string;
type: string;
has_children?: boolean;
[key: string]: unknown;
};
type SopBlock = {
id: string;
type: string;
text: string;
depth: number;
has_children: boolean;
};
type SopSnapshot = {
status: "ok" | "degraded";
degraded: boolean;
source: "notion" | "cache";
page_id: string;
fetched_at: string;
warnings: string[];
sop: {
title: string;
hash: string;
block_count: number;
blocks: SopBlock[];
sections: Array<{ heading: string; items: string[] }>;
};
};
const NOTION_API_BASE = "https://api.notion.com/v1";
const NOTION_VERSION = "2022-06-28";
const DEFAULT_CACHE_FILE = "/tmp/crm-inbound-sop-cache.json";
function parseArgs(argv: string[]): CliArgs {
const tokens = argv.slice(2);
const command = tokens.shift();
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (!token.startsWith("--")) {
continue;
}
const key = token.slice(2);
const next = tokens[i + 1];
if (!next || next.startsWith("--")) {
flags[key] = true;
continue;
}
flags[key] = next;
i += 1;
}
return { command, flags };
}
function asString(value: string | boolean | undefined): string | undefined {
return typeof value === "string" ? value : undefined;
}
function clean(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function extractRichText(block: NotionBlock): string {
const payload = block[block.type] as Record<string, unknown> | undefined;
const richText = payload?.rich_text;
if (!Array.isArray(richText)) {
return "";
}
return richText
.map((entry) => {
if (!entry || typeof entry !== "object") {
return "";
}
const plain = (entry as Record<string, unknown>).plain_text;
return typeof plain === "string" ? plain : "";
})
.join("")
.trim();
}
async function notionRequest(
token: string,
endpoint: string,
init?: RequestInit,
): Promise<Record<string, unknown>> {
const response = await fetch(`${NOTION_API_BASE}${endpoint}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Notion-Version": NOTION_VERSION,
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
const text = await response.text();
const body = text.trim() ? (JSON.parse(text) as Record<string, unknown>) : {};
if (!response.ok) {
const message =
(body.message && typeof body.message === "string" && body.message) ||
`Notion request failed (${response.status})`;
throw new Error(message);
}
return body;
}
async function listBlockChildren(token: string, blockId: string): Promise<NotionBlock[]> {
const blocks: NotionBlock[] = [];
let cursor: string | undefined;
while (true) {
const query = cursor
? `?page_size=100&start_cursor=${encodeURIComponent(cursor)}`
: "?page_size=100";
const body = await notionRequest(token, `/blocks/${blockId}/children${query}`);
const results = body.results;
if (Array.isArray(results)) {
for (const result of results) {
if (!result || typeof result !== "object") {
continue;
}
const block = result as NotionBlock;
if (typeof block.id !== "string" || typeof block.type !== "string") {
continue;
}
blocks.push(block);
}
}
const hasMore = body.has_more === true;
const nextCursor = typeof body.next_cursor === "string" ? body.next_cursor : undefined;
if (!hasMore || !nextCursor) {
break;
}
cursor = nextCursor;
}
return blocks;
}
async function walkBlocks(
token: string,
blockId: string,
depth = 0,
acc: SopBlock[] = [],
): Promise<SopBlock[]> {
const children = await listBlockChildren(token, blockId);
for (const child of children) {
const text = extractRichText(child);
acc.push({
id: child.id,
type: child.type,
text,
depth,
has_children: child.has_children === true,
});
if (child.has_children === true) {
await walkBlocks(token, child.id, depth + 1, acc);
}
}
return acc;
}
function buildSections(blocks: SopBlock[]): Array<{ heading: string; items: string[] }> {
const sections: Array<{ heading: string; items: string[] }> = [];
let current: { heading: string; items: string[] } | undefined;
for (const block of blocks) {
if (block.type.startsWith("heading_")) {
current = { heading: block.text || "Untitled", items: [] };
sections.push(current);
continue;
}
if (!block.text) {
continue;
}
if (!current) {
current = { heading: "General", items: [] };
sections.push(current);
}
current.items.push(block.text);
}
return sections.map((section) => ({
heading: section.heading,
items: Array.from(new Set(section.items)),
}));
}
function buildHash(blocks: SopBlock[]): string {
const normalized = blocks.map((block) => ({
id: block.id,
type: block.type,
text: block.text,
depth: block.depth,
}));
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}
async function writeJson(filePath: string, payload: unknown) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
async function readSnapshot(filePath: string): Promise<SopSnapshot | undefined> {
try {
const raw = await readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as SopSnapshot;
return parsed;
} catch {
return undefined;
}
}
async function fetchSopSnapshot(options: {
notionToken: string;
pageId: string;
}): Promise<SopSnapshot> {
const page = await notionRequest(options.notionToken, `/pages/${options.pageId}`);
const properties = page.properties as Record<string, unknown> | undefined;
const titleCandidates = properties ? Object.values(properties) : [];
let title = "Inbound SOP";
for (const candidate of titleCandidates) {
if (!candidate || typeof candidate !== "object") {
continue;
}
const value = candidate as Record<string, unknown>;
const titleParts = value.title;
if (!Array.isArray(titleParts)) {
continue;
}
const derived = titleParts
.map((part) => {
if (!part || typeof part !== "object") {
return "";
}
const plain = (part as Record<string, unknown>).plain_text;
return typeof plain === "string" ? plain : "";
})
.join("")
.trim();
if (derived) {
title = derived;
break;
}
}
const blocks = await walkBlocks(options.notionToken, options.pageId);
const hash = buildHash(blocks);
return {
status: "ok",
degraded: false,
source: "notion",
page_id: options.pageId,
fetched_at: new Date().toISOString(),
warnings: [],
sop: {
title,
hash,
block_count: blocks.length,
blocks,
sections: buildSections(blocks),
},
};
}
async function main() {
const { command, flags } = parseArgs(process.argv);
if (command !== "fetch_sop") {
console.error(
"Usage: bun fetch-sop.ts fetch_sop [--page-id <id>] [--cache-file <path>] [--output <path>]",
);
process.exit(1);
}
const notionToken = clean(process.env.NOTION_API_KEY);
if (!notionToken) {
throw new Error("NOTION_API_KEY is required");
}
const pageId =
clean(asString(flags["page-id"])) ||
clean(process.env.CRM_SOP_PAGE_ID) ||
"31288fb313488013924ade7bf704ab6f";
const cacheFile =
clean(asString(flags["cache-file"])) ||
clean(process.env.CRM_SOP_CACHE_FILE) ||
DEFAULT_CACHE_FILE;
const output = clean(asString(flags.output)) || cacheFile;
try {
const snapshot = await fetchSopSnapshot({ notionToken, pageId });
await writeJson(cacheFile, snapshot);
if (output !== cacheFile) {
await writeJson(output, snapshot);
}
console.log(JSON.stringify(snapshot, null, 2));
return;
} catch (error) {
const message = error instanceof Error ? error.message : "unknown Notion error";
const cached = await readSnapshot(cacheFile);
if (!cached) {
throw new Error(`SOP fetch failed and no cache is available: ${message}`);
}
const degraded: SopSnapshot = {
...cached,
status: "degraded",
degraded: true,
source: "cache",
fetched_at: new Date().toISOString(),
warnings: [
...cached.warnings,
`Notion fetch failed: ${message}`,
"Using cached SOP snapshot.",
],
};
if (output) {
await writeJson(output, degraded);
}
console.log(JSON.stringify(degraded, null, 2));
}
}
await main();