Skip to content

Instantly share code, notes, and snippets.

@mdp
Created May 20, 2026 18:04
Show Gist options
  • Select an option

  • Save mdp/0839c799e85d733aff3a19d326730260 to your computer and use it in GitHub Desktop.

Select an option

Save mdp/0839c799e85d733aff3a19d326730260 to your computer and use it in GitHub Desktop.
CRM enrichment and Cline SDK integration

CRM Enrichment and Cline SDK Integration

This note explains how BigBrother runs the CRM enrichment task and how the CRM agent skill is executed through the Cline SDK. It is aimed at engineers who need to understand the moving parts, not at end users. Code samples below are trimmed excerpts from the referenced files.

High-Level Flow

The CRM enrichment job is a scheduled task, not a standalone script embedded in a skill.

  1. The daemon or CLI builds shared agent dependencies with buildAgents().
  2. The crm-enrichment task searches Notion CRM for recently created companies.
  3. It filters to companies missing a website or contacts.
  4. For each incomplete company, it loads the enrich-incomplete-records skill and builds the exact tool belt declared by that skill.
  5. It calls runClineSkill() with the CRM persona, the skill body, the company JSON, the tool belt, and cron telemetry.
  6. runClineSkill() starts a non-interactive ClineCore session using OpenRouter, with built-in file/shell tools disabled and only the provided extraTools exposed.

The important boundary is:

  • Task code owns deterministic orchestration: selecting records, batching, error handling, and summary aggregation.
  • Skill markdown owns agent instructions: which CRM fields to fill, when to call research tools, and what not to overwrite.
  • Cline harness owns LLM runtime behavior: prompt assembly, ClineCore config, events, telemetry, step-limit failures, and cron completion enforcement.

Runtime Entry Points

The job is registered in src/tasks/crm-enrichment.ts:

export const crmEnrichmentTask: Task = {
  id: "crm-enrichment",
  name: "CRM Record Enrichment",
  description: "Find recently created companies missing a website or founder contact; enrich from partner Gmail + LinkedIn lookup",
  schedule: "10 6 * * *",
  enabled: true,
  run,
};

It can run from:

  • the scheduler, using the cron expression above;
  • the CLI, with pnpm cli crm-enrichment;
  • chat, through the local run_task tool when enabled.

The CRM Enrichment Task

The task starts with a deterministic CRM search. It does not ask the model to decide which records to process.

async function run(_params: BaseTaskParams, agents: BuiltAgents): Promise<string> {
  const searchTool = agents.crmTools["notion_crm_searchCompanies"] as any;
  if (!searchTool) return "CRM tools unavailable - skipping enrichment.";

  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
  const raw = await searchTool.execute({ createdAfter: sevenDaysAgo, limit: 50 });
  const companies: Array<Record<string, any>> = raw?.companies ?? raw?.results ?? (Array.isArray(raw) ? raw : []);
  const incomplete = companies.filter((c) => !c.website || (c.contacts?.length ?? 0) === 0);

  if (incomplete.length === 0) return "No incomplete records in the last 7 days.";

  const { skill, tools } = await buildTaskToolBelt(agents, "enrich-incomplete-records");
  const lines: string[] = [];

  for (const company of incomplete) {
    const result = await runTaskSkill({
      skillBody: skill.body,
      userMessage: `Enrich this company record:\n\n${JSON.stringify(company, null, 2)}`,
      extraTools: tools,
      agentContext: CRM_PERSONA,
      telemetry: { functionId: "crm-enrichment" },
    });
    lines.push(`${company.name ?? company.id}: ${firstLine(result, 150)}`);
  }

  return lines.join("\n");
}

Source: src/tasks/crm-enrichment.ts.

The task uses agents.crmTools["notion_crm_searchCompanies"] directly because candidate selection is mechanical. Once it has a specific company record, it delegates the enrichment judgment to the Cline-run skill.

Skill Loading and Tool Scoping

Tasks share a helper that loads a skill from the registry and turns its frontmatter tools list into Cline AgentTool[] instances:

export async function buildTaskToolBelt(
  agents: BuiltAgents,
  skillName: string,
): Promise<TaskToolBelt> {
  const skill = agents.registry.get(skillName);
  if (!skill) throw new Error(`${skillName} skill not found`);
  const tools = await buildToolsForNames(skill.tools, getLightToolRegistry());
  return { skill, tools };
}

export async function runTaskSkill(input: Omit<ClineSkillInput, "profile">): Promise<string> {
  return runClineSkill({
    ...input,
    profile: "cron",
  });
}

Source: src/tasks/run.ts.

The actual enrichment skill declares only the tools it needs:

---
name: enrich-incomplete-records
description: Enrich a single CRM company: find its website domain and founder contact via research tools, then update Notion.
channels: [discord]
tools: [research_enrich_company, research_enrich_person, notion_update_company, notion_create_contact]
---

Source: src/skills/enrich-incomplete-records/SKILL.md.

This means the model cannot use arbitrary MCP tools during this run. The Cline session receives only the four declared tools, plus the cron-only completion tool added by the harness.

CRM Skill Instructions

The skill body gives the model the business rules for a single company:

1. Research the company. Call `research_enrich_company({ companyName: "<name>" })`.
2. Update website if missing. If a domain was found and website is blank:
   `notion_update_company({ companyId: "<id>", website: "https://<domain>" })`
3. Find founder if missing:
   `research_enrich_person({ domain: "<domain>", organizationName: "<company name>" })`
4. If a person is found with a name and email:
   `notion_create_contact({ name, email, companyId: "<id>", linkedinUrl })`.
5. Return one line summarizing website and founder status.

The anti-patterns matter operationally:

  • never guess a domain;
  • never create a contact without companyId;
  • never overwrite an existing non-null website.

MCP Tools as Cline Tools

BigBrother converts remote MCP tools into Cline SDK tools with createTool() from @cline/sdk.

const [{ tools: defs }, aiTools] = await Promise.all([client.listTools(), client.tools()]);
for (const def of defs) {
  const toolName = `${prefix}${def.name}`;
  const aiTool = (aiTools as Record<string, any>)[def.name];
  if (!aiTool) continue;
  results.push(
    createTool({
      name: toolName,
      description: def.description ?? toolName,
      inputSchema: (def.inputSchema ?? { type: "object", properties: {} }) as any,
      execute: async (input: unknown) => {
        try {
          return await (aiTool as any).execute(input, { messages: [], toolCallId: toolName });
        } catch (err) {
          return structuredToolError(toolName, err);
        }
      },
    }),
  );
}

Source: src/cline/mcp-manager.ts.

Two Cline SDK details are important here:

  • tool names are normalized with server prefixes such as notion_* and research_*;
  • tool failures are returned as structured data rather than thrown, so one bad MCP response does not automatically count as a Cline agent mistake.

buildToolsForNames() then filters the full MCP inventory down to the names declared by the skill:

export async function buildToolsForNames(
  toolNames: string[],
  lightToolRegistry: Map<string, AgentTool>,
  threadId?: string,
): Promise<AgentTool[]> {
  const allMcp = await getAllMcpTools();
  const mcpByName = new Map(allMcp.map((t) => [t.name, t]));

  const result: AgentTool[] = [];
  for (const name of toolNames) {
    const mcp = mcpByName.get(name);
    if (mcp) {
      result.push(name === "goog_gmail_send" && threadId ? wrapWithThreadId(mcp, threadId) : mcp);
      continue;
    }
    const light = lightToolRegistry.get(name);
    if (light) result.push(light);
  }
  return result;
}

ClineCore Harness

All agent runs go through runClineSkill() in src/cline/harness.ts.

The harness lazily creates one ClineCore instance:

let _cline: ClineCore | null = null;

async function getCline(): Promise<ClineCore> {
  if (!_cline) {
    _cline = await ClineCore.create({ clientName: "bigbrother", backendMode: "local" });
  }
  return _cline;
}

For cron runs, the harness uses yolo mode, disables built-in tools, and appends a Cline tool that completes the run:

const PROFILE_DEFAULTS: Record<RunProfile, ProfileDefaults> = {
  chat: { mode: "act", enableTools: false, reasoningEffort: "medium" },
  email: { mode: "act", enableTools: false },
  cron: { mode: "yolo", enableTools: false },
};

const submitAndExitTool: AgentTool = createTool({
  name: "submit_and_exit",
  description: "Submit the final result and end this autonomous run.",
  lifecycle: { completesRun: true },
  retryable: false,
  maxRetries: 0,
  execute: async (input: unknown) => input,
});

The start config is where ClineCore receives the model, prompt, tools, and runtime restrictions:

const startConfig = {
  config: {
    providerId: "openrouter",
    modelId,
    apiKey: env.OPENROUTER_API_KEY,
    cwd: process.cwd(),
    mode,
    enableTools,
    enableSpawnAgent: false,
    enableAgentTeams: false,
    disableMcpSettingsTools: true,
    systemPrompt,
    extraTools: toolsForProfile(profile, input.extraTools),
  },
  prompt: input.userMessage,
  initialMessages: input.initialMessages?.map((m) => ({ role: m.role, content: m.content })),
  interactive: false,
};

Then the harness starts ClineCore and returns the resulting text:

const result = await cline.start(startConfig);
captureSessionId(result.sessionId);
assertRunCompleted(result.result, profile, activeTelemetry);
return textFromRunResult(result.result, profile);

For cron runs, assertRunCompleted() requires a successful submit_and_exit tool call. A final assistant message without that tool is treated as incomplete.

Prompt Composition

The system prompt is assembled from:

  1. the CRM persona, loaded from src/agents/crm/AGENTS.md;
  2. the cron rules block;
  3. an environment block with the current date;
  4. the skill body from enrich-incomplete-records/SKILL.md.

The relevant call site is:

await runTaskSkill({
  skillBody: skill.body,
  userMessage: `Enrich this company record:\n\n${JSON.stringify(company, null, 2)}`,
  extraTools: tools,
  agentContext: CRM_PERSONA,
  telemetry: { functionId: "crm-enrichment" },
});

This keeps durable role behavior in the CRM persona and task-specific behavior in the portable skill.

Related CRM Delegation Tool

The ask_crm local tool uses the same pattern for ad hoc CRM work from other agents. It builds a small CRM/research tool set and starts another Cline run with the CRM persona:

const CRM_TOOLS = new Set([
  "notion_find_contacts",
  "notion_get_company",
  "notion_search_companies",
  "notion_create_company",
  "notion_create_contact",
  "notion_update_company",
  "notion_update_contact",
  "notion_append_company_notes",
  "research_enrich_company",
  "research_enrich_person",
]);

const allMcp = await getAllMcpTools();
const crmTools = allMcp.filter((t) => CRM_TOOLS.has(t.name));

return await runClineSkill({
  skillBody: systemPrompt,
  userMessage,
  extraTools: crmTools,
  telemetry: { functionId: "ask-crm" },
});

Source: src/cline/tools/ask-crm.ts.

crm-enrichment is batch-oriented and scheduled. ask_crm is request-oriented and used as a delegation tool by chat/email agents.

Operational Notes

  • The repo uses @cline/sdk and ClineCore, currently declared in package.json.
  • All model calls for the CRM task route through OpenRouter via runClineSkill().
  • Built-in Cline file and shell tools are off by default; task capabilities come from extraTools.
  • MCP tool lists are cached after first load.
  • Cline run telemetry is recorded when telemetry is provided, with functionId: "crm-enrichment" for this task.
  • max_iterations, mistake_limit, aborted, and error finish reasons are treated as failures.
  • Cron runs must call submit_and_exit; that tool is added by the harness and marked with lifecycle: { completesRun: true }.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment