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.
The CRM enrichment job is a scheduled task, not a standalone script embedded in a skill.
- The daemon or CLI builds shared agent dependencies with
buildAgents(). - The
crm-enrichmenttask searches Notion CRM for recently created companies. - It filters to companies missing a website or contacts.
- For each incomplete company, it loads the
enrich-incomplete-recordsskill and builds the exact tool belt declared by that skill. - It calls
runClineSkill()with the CRM persona, the skill body, the company JSON, the tool belt, and cron telemetry. runClineSkill()starts a non-interactive ClineCore session using OpenRouter, with built-in file/shell tools disabled and only the providedextraToolsexposed.
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.
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_tasktool when enabled.
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.
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.
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.
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_*andresearch_*; - 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;
}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.
The system prompt is assembled from:
- the CRM persona, loaded from
src/agents/crm/AGENTS.md; - the cron rules block;
- an environment block with the current date;
- 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.
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.
- The repo uses
@cline/sdkandClineCore, currently declared inpackage.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
telemetryis provided, withfunctionId: "crm-enrichment"for this task. max_iterations,mistake_limit,aborted, anderrorfinish reasons are treated as failures.- Cron runs must call
submit_and_exit; that tool is added by the harness and marked withlifecycle: { completesRun: true }.