Skip to Content
FeaturesTool / Integration Layer

Tool / Integration Layer

Purpose

Give agents the ability to take real-world marketing actions — pull live data, create ads, publish content, send reports — beyond what the LLM can produce from its training data alone. Each integration is a typed module that exposes a JSON schema so the LLM knows exactly how to invoke it.


Design Principles

  • Opt-in per agent role — agents only receive the tools their role requires (least-privilege).
  • JSON schema per tool method — the LLM receives the schema in its system prompt and calls tools by emitting structured JSON; the executor intercepts and dispatches.
  • Approval gate for write operations — any tool call that mutates external state (publish content, send email, create ad) requires a corresponding approvals record to be in approved status before the integration executes.
  • Rate limit middleware — every integration client enforces per-API throttling to prevent quota exhaustion.
  • Audit logging — every tool call (input + output summary) is written to tool_calls table.
  • Input validation — every tool call input is validated against a Zod schema before reaching the integration. Malformed LLM output is rejected with a structured error returned to the LLM (not thrown as an exception), allowing the agent to self-correct.
  • Output sanitisation — HTML in any string field of the integration response is stripped with DOMPurify before the output is stored or returned to the LLM, preventing stored XSS from scraped external content.
  • Scraped content framing — content from web scraping tools (playwright, scrapePageText) is wrapped in explicit prompt delimiters before injection into the agent context to prevent prompt injection from adversarial page content.

Tool Invocation Flow

LLM emits tool call JSON: { "tool": "google_search_console", "method": "getKeywordRankings", "input": { "siteUrl": "...", "query": "..." } } Executor intercepts tool_call event Permission check: is this tool in agent's toolNames[]? ┌────┴────┐ YES NO │ │ │ Reject: return error to LLM Write action? Check approvals table for approved record (read-only or approved) Validate + sanitise input (Zod schema check; reject malformed LLM outputs before they reach integrations) Call integration client method Sanitise output (strip HTML from any string fields using DOMPurify; redact credentials before logging) Log to tool_calls table (taskRunId, tool, method, input, output, status, durationMs) Return result to LLM as tool result message

Integrations

Google Search Console API

Used by: SEO Specialist Type: Read-only

Methods:

interface GoogleSearchConsoleClient { // Pull keyword rankings, impressions, clicks, CTR, position getKeywordRankings(params: { siteUrl: string; startDate: string; // YYYY-MM-DD endDate: string; query?: string; // filter to specific keyword limit?: number; }): Promise<SearchAnalyticsRow[]>; // Top landing pages by traffic getTopPages(params: { siteUrl: string; startDate: string; endDate: string; limit?: number; }): Promise<PageAnalyticsRow[]>; // Index coverage issues getIndexingIssues(siteUrl: string): Promise<IndexingIssue[]>; }

Auth: OAuth 2.0 service account (per-client GOOGLE_SERVICE_ACCOUNT_JSON) Rate limits: 1,200 requests/minute per project — throttle to 20 req/s


Used by: Paid Ads Manager (read + write) Type: Read + Write (writes require approval gate)

Methods:

interface GoogleAdsClient { // Read getCampaignPerformance(customerId: string, dateRange: DateRange): Promise<CampaignStats[]>; getAdGroupPerformance(customerId: string, campaignId: string): Promise<AdGroupStats[]>; getAdPerformance(customerId: string, adGroupId: string): Promise<AdStats[]>; getKeywordPerformance(customerId: string, adGroupId: string): Promise<KeywordStats[]>; // Write (approval-gated) createResponsiveSearchAd(params: { customerId: string; adGroupId: string; headlines: string[]; // up to 15, max 30 chars each descriptions: string[]; // up to 4, max 90 chars each finalUrl: string; }): Promise<CreatedAd>; pauseAd(customerId: string, adId: string): Promise<void>; updateBid(customerId: string, adGroupId: string, bidMicros: number): Promise<void>; }

Auth: OAuth 2.0 with refresh token (per-client credentials) Rate limits: 15,000 operations/day per customer — tracked in Redis, alert at 80%


Meta Ads API

Used by: Paid Ads Manager, Social Media Manager Type: Read + Write (writes require approval gate)

Methods:

interface MetaAdsClient { // Read getCampaignInsights(adAccountId: string, dateRange: DateRange): Promise<CampaignInsights[]>; getAdSetInsights(adAccountId: string, campaignId: string): Promise<AdSetInsights[]>; getAdInsights(adAccountId: string, adSetId: string): Promise<AdInsights[]>; getPageInsights(pageId: string, metrics: string[]): Promise<PageInsights>; getAudienceInsights(adAccountId: string, targeting: TargetingSpec): Promise<AudienceSize>; // Write (approval-gated) createAd(params: { adAccountId: string; adSetId: string; name: string; creative: { primaryText: string; // ≤ 125 chars headline: string; // ≤ 40 chars description: string; // ≤ 30 chars imageUrl: string; callToAction: string; }; }): Promise<CreatedAd>; pauseAd(adAccountId: string, adId: string): Promise<void>; }

Auth: Long-lived user access token (per-client, with ads_management scope) Rate limits: Usage-based throttling via Meta’s BUC system — monitor headers


Google Analytics 4 API

Used by: Data Analyst Type: Read-only

Methods:

interface GA4Client { // Core traffic report getTrafficReport(params: { propertyId: string; startDate: string; endDate: string; dimensions: string[]; // e.g. ['sessionDefaultChannelGroup', 'landingPage'] metrics: string[]; // e.g. ['sessions', 'conversions', 'bounceRate'] }): Promise<GA4Report>; // Conversion funnel getConversionPaths(propertyId: string, dateRange: DateRange): Promise<ConversionPath[]>; // Real-time active users (for anomaly detection) getRealtimeData(propertyId: string): Promise<RealtimeData>; }

Auth: OAuth 2.0 service account Rate limits: 10 requests/second per property — throttle with p-limit


SEMrush API

Used by: SEO Specialist, Content Researcher Type: Read-only

Methods:

interface SEMrushClient { getKeywordOverview(keyword: string, database: string): Promise<KeywordOverview>; getKeywordIdeas(seed: string, database: string, limit?: number): Promise<KeywordIdea[]>; getDomainOrganicKeywords(domain: string, database: string): Promise<OrganicKeyword[]>; getCompetitorDomains(domain: string): Promise<CompetitorDomain[]>; getBacklinkReport(domain: string): Promise<BacklinkReport>; }

Auth: API key per workspace Rate limits: Plan-dependent (typically 10 req/s) — use token bucket


Ahrefs API

Used by: SEO Specialist Type: Read-only

Methods:

interface AhrefsClient { getDomainRating(target: string): Promise<DomainRating>; getBacklinks(target: string, limit?: number): Promise<Backlink[]>; getOrganicKeywords(target: string, country: string): Promise<OrganicKeyword[]>; getTopPages(target: string): Promise<TopPage[]>; }

Auth: API token (workspace-level) Rate limits: Row-based limits per plan — track usage in DB


DataForSEO API

Used by: SEO Specialist, system (automated backlink status checker) Type: Read-only Purpose: Check live status of built backlinks — is the link still live? Is the linking page indexed by Google?

Methods:

interface DataForSEOClient { // Check if a specific backlink is live checkBacklinkLive(targetUrl: string, sourceUrl: string): Promise<BacklinkLiveStatus>; // Bulk check multiple backlinks checkBacklinksBulk(links: Array<{ targetUrl: string; sourceUrl: string }>): Promise<BacklinkLiveStatus[]>; // Get full backlink profile for a domain getDomainBacklinks(domain: string, filters?: BacklinkFilters): Promise<DomainBacklink[]>; } interface BacklinkLiveStatus { targetUrl: string; sourceUrl: string; isLive: boolean; // Is the link currently present in the source page? isIndexed: boolean; // Is the source page indexed by Google? anchorText?: string; linkType?: 'dofollow' | 'nofollow' | 'ugc' | 'sponsored'; domainRating?: number; lastSeenOn?: Date; }

Auth: API key (login + password in Base64) Rate limits: Task-based limits per plan — each check is a “task” that counts against the monthly quota

Automated status checker (system cron):

  • Runs daily per tenant with published backlinks
  • Fetches all backlinks records where outreach_status = 'published' and last_checked_on < NOW() - INTERVAL '24 hours'
  • Calls checkBacklinksBulk() in batches of 100
  • Updates is_live, is_indexed, dataforseo_check_result, last_checked_on
  • Creates alert activity if a link that was live is no longer live

WordPress API

Used by: Copywriter (publish blog posts, post-approval) Type: Write (approval-gated)

Methods:

interface WordPressClient { createPost(params: { siteUrl: string; title: string; content: string; // HTML status: 'draft' | 'publish'; categories: string[]; tags: string[]; metaTitle?: string; metaDescription?: string; featuredImageUrl?: string; }): Promise<CreatedPost>; updatePost(siteUrl: string, postId: number, fields: Partial<PostFields>): Promise<void>; }

Auth: Application Password (per-client site) Note: Defaults to status: 'draft' even post-approval. A second human action is required to publish on the WordPress side. (Belt-and-suspenders approach.)


Mailchimp API

Used by: Copywriter (create email campaigns, post-approval) Type: Write (approval-gated)

Methods:

interface MailchimpClient { createCampaign(params: { listId: string; subject: string; previewText: string; fromName: string; replyTo: string; htmlBody: string; plainText: string; }): Promise<CreatedCampaign>; scheduleCampaign(campaignId: string, sendAt: string): Promise<void>; // Note: never auto-sends; requires schedule action (also approval-gated) }

Auth: API key + server prefix (per-client account)


Klaviyo API

Used by: Copywriter (alternative to Mailchimp) Type: Write (approval-gated)

Methods:

interface KlaviyoClient { createEmailTemplate(params: { name: string; html: string; }): Promise<Template>; createCampaign(params: { listId: string; templateId: string; subject: string; fromEmail: string; fromName: string; }): Promise<Campaign>; }

Auth: Private API key (per-client account)


Slack API

Used by: Data Analyst, Activity Planner (reports + alerts) Type: Write

Methods:

interface SlackClient { postMessage(params: { channel: string; // e.g. '#client-acme' or channel ID text: string; // fallback plain text blocks?: SlackBlock[]; // rich message blocks attachments?: SlackAttachment[]; }): Promise<void>; uploadFile(params: { channel: string; content: string; filename: string; title: string; }): Promise<void>; }

Auth: Bot token with chat:write + files:write scopes Note: Slack messages for internal reporting are not approval-gated. Client-facing Slack posts are.


Google Docs / Drive API

Used by: Copywriter, Data Analyst Type: Write

Methods:

interface GoogleDocsClient { createDocument(params: { title: string; content: string; // Markdown converted to Docs format folderId?: string; }): Promise<{ docId: string; url: string }>; appendToDocument(docId: string, content: string): Promise<void>; shareDocument(docId: string, emailAddress: string, role: 'reader' | 'writer'): Promise<void>; }

Auth: OAuth 2.0 service account Note: Documents created as private by default. Shared with the client’s Google account post-approval.


Playwright (Browser Automation)

Used by: Content Researcher, SEO Specialist Type: Read-only (no external mutations)

Methods:

interface BrowserClient { scrapePageText(url: string): Promise<string>; scrapePageStructure(url: string): Promise<{ headings: string[]; links: string[]; metaTags: Record<string, string> }>; captureScreenshot(url: string): Promise<Buffer>; // PNG scrapeMultiplePages(urls: string[], selector?: string): Promise<ScrapedPage[]>; }

Note: Playwright runs in a separate BullMQ worker queue (agent__content-researcher) with concurrency 2 to avoid CPU contention. Each browser context is isolated and closed after the task.


Control Plane Tools

Control plane tools affect platform state rather than calling external APIs. They are registered in the tool dispatcher alongside integration tools but route to internal handlers instead of third-party clients.

report_blocker

Used by: All agent roles Type: Control plane write

Signals that the agent cannot proceed due to an external dependency requiring human action. Suspends the activity and creates a resolution task in the DM Portal inbox. The agent should call this instead of failing when the blocker is recoverable (a human can fix it) rather than a code error or reasoning failure.

interface ReportBlockerInput { blockerType: 'channel_auth_expired' | 'missing_integration' | 'insufficient_data' | 'api_unavailable' | 'needs_human_input'; message: string; // Clear explanation: what is blocked + what action resolves it channelId?: string; // Required when blockerType === 'channel_auth_expired' integrationName?: string; // Required when blockerType === 'missing_integration' }

Blocker type guidance:

blockerTypeWhen to useExample
channel_auth_expiredOAuth token is expired or revoked”Google Search Console token for this tenant is expired. Re-authenticate GSC in Settings → Channels.”
missing_integrationRequired channel/API not connected”This task requires a Google Ads account. Connect Google Ads in Settings → Channels.”
insufficient_dataData needed for the task doesn’t exist yet”The website hasn’t been crawled yet. The RAG knowledge base has no website content to search.”
api_unavailableThird-party API is returning errors consistently”SEMrush API is returning 503 errors. The service may be down.”
needs_human_inputTask requires a decision or content only a human can provide”The brand guidelines don’t specify a tone for crisis communications. Human guidance needed.”

Tool call example:

{ "tool": "report_blocker", "input": { "blockerType": "channel_auth_expired", "message": "Google Search Console OAuth token is expired. Re-authenticate GSC in Settings → Channels to allow keyword ranking data to be fetched.", "channelId": "ch_abc123" } }

Tool result returned to agent:

{ "status": "blocked", "message": "Your task has been suspended and a resolution task created in the DM Portal. You will be re-dispatched once the blocker is resolved." }

After calling report_blocker, the agent should stop work immediately. The tool result confirms suspension. Any further output will not be stored.

What NOT to use report_blocker for:

  • Errors in the agent’s own reasoning or output (use normal error handling)
  • Missing information that rag_search or load_skill could retrieve
  • Uncertainty about what to write — make a reasonable decision and note it in the output

create_subtask

Used by: All agent roles (Activity Planner unrestricted; worker agents limited to depth 1) Type: Control plane write

Creates a subordinate activity and assigns it to another agent role or a human. By default the current activity suspends until the subtask completes (blocking: true); pass blocking: false to fire-and-forget.

interface CreateSubtaskInput { assigneeType: 'agent' | 'human'; agentRole?: AgentRole; // required if assigneeType === 'agent' humanRole?: 'reviewer' | 'admin'; // required if assigneeType === 'human' taskName: string; // short label shown in the DM Portal and activity list taskDescription: string; // full brief for the assignee — be specific blocking?: boolean; // default true — current activity suspends until done priority?: 'normal' | 'high'; // default 'normal' }

Depth limit: Subtasks cannot create further subtasks. The tool handler checks currentActivity.parentActivityId — if already set, the call is rejected with "Subtask depth limit reached (max 1)". This prevents unbounded recursion.

Tool result returned to agent:

// blocking: true { "status": "subtask_created", "subtaskId": "act_abc123", "message": "Subtask created and assigned to seo-specialist. Your task is suspended until it completes." } // blocking: false { "status": "subtask_created", "subtaskId": "act_abc123", "message": "Subtask created. Continue your current task — the subtask runs in parallel." }

When subtask completes (blocking: true): parent is re-enqueued with wakeReason: 'subtask_completed' and the subtask output injected into the prompt.

Example — SEO Specialist creates a Content Researcher subtask:

{ "tool": "create_subtask", "input": { "assigneeType": "agent", "agentRole": "content-researcher", "taskName": "Research competitor blog topics for Acme Corp", "taskDescription": "Scrape the top 5 blog posts from acmecorp-competitor.com and identify the 10 most common topics covered. Return a structured list with topic, approximate word count, and primary keyword for each post.", "blocking": true, "priority": "normal" } }

request_human_review

Used by: All agent roles Type: Control plane write

Pauses the current task and requests an ad-hoc human review of work in progress. Different from a predefined pipeline approval gate — this is agent-initiated and can happen at any point during execution when the agent determines human judgment is needed before proceeding.

interface RequestHumanReviewInput { reviewTitle: string; // label shown in the DM Portal inbox content: string; // the work/draft to review (Markdown) instructions: string; // what the reviewer should evaluate and decide options?: string[]; // if the reviewer must choose between options // e.g. ['Proceed with approach A', 'Proceed with approach B'] }

Tool result returned to agent:

{ "status": "review_requested", "message": "Review requested. Your task is paused. Resume after the reviewer responds." }

The agent should stop producing output immediately after calling this tool. The tool result signals that the task is suspended.

Human reviewer response options:

  • Approve — agent resumes with wakeReason: 'review_approved'; reviewer may optionally add notes
  • Send feedback — agent resumes with wakeReason: 'review_feedback' + reviewerFeedback

When to use — guidance for agents:

UseDon’t use
You have a draft and need strategic direction before continuingRoutine output — write it and let the predefined approval gate handle it
You are choosing between two meaningful approachesMinor stylistic uncertainty — make a call
The brief is ambiguous on something that will affect the entire outputQuestions that load_skill or rag_search could answer

Example — Copywriter requests tone check before writing full article:

{ "tool": "request_human_review", "input": { "reviewTitle": "Tone check: Blog post intro for Acme Corp", "content": "## Draft intro (300 words)\n\nAcme Corp has been building...", "instructions": "Please confirm the tone is right for this client before I write the full 2,000-word article. Specifically: is the level of formality correct, and should I lead with the problem or the solution?" } }

reassign_task

Used by: All agent roles Type: Control plane write

Hands the current task to a different agent role or human. The current activity is marked reassigned (terminal — the calling agent will not continue this task). A new activity is created for the target assignee with the same task description and input.

interface ReassignTaskInput { assigneeType: 'agent' | 'human'; agentRole?: AgentRole; // required if assigneeType === 'agent' humanRole?: 'reviewer' | 'admin'; // required if assigneeType === 'human' reason: string; // why this task needs a different assignee }

Tool result returned to agent:

{ "status": "reassigned", "message": "Task reassigned to seo-specialist. Your activity is now closed." }

The agent should produce no further output after calling this tool.

When to use:

  • The task requires skills outside the calling agent’s role (e.g. Copywriter receives an SEO audit task)
  • The task description specifies a human must make a decision the agent cannot make
  • The task requires access to tools the calling agent does not have

Do not use to avoid difficult work. Reassignment is audited — the reason field is stored on the original activity and visible in the DM Portal. If an agent reassigns frequently, it signals a pipeline configuration problem.

Example — Copywriter reassigns an SEO-scoped task:

{ "tool": "reassign_task", "input": { "assigneeType": "agent", "agentRole": "seo-specialist", "reason": "This task requests a technical SEO audit (Core Web Vitals, crawl errors, schema markup). This is SEO Specialist scope, not Copywriter scope." } }

add_comment

Used by: All agent roles Type: Control plane write (non-blocking — agent continues after calling)

Adds a comment to the current activity’s comment thread. Comments are visible to all users and agents who can view the activity. Supports @mentions to notify specific humans or flag for specific agent roles.

interface AddCommentInput { body: string; // Markdown — supports @mentions (see below) internal?: boolean; // default false; if true, hidden from tenant Dashboard (DM Portal + Manage only) }

@mention syntax:

SyntaxTargetsEffect
@agent:seo-specialistAgent roleInformational — noted in the comment, no queue action
@human:reviewerAny DM reviewerCreates an in-app notification for all reviewers
@human:adminTenant adminsCreates an in-app notification for tenant admins
@user:user_idSpecific userCreates an in-app notification for that user

Tool result returned to agent:

{ "status": "comment_added", "commentId": "cmt_xyz789" }

Unlike the other control plane tools, add_comment is non-blocking — the agent continues executing after the call returns.

Example — SEO Specialist flags an observation before completing:

{ "tool": "add_comment", "input": { "body": "Completed keyword research. Note: the primary keyword 'digital marketing agency london' has very high competition (KD 78). I've included 3 lower-competition alternatives in the brief. @human:reviewer — please confirm which direction to take before the Copywriter starts writing.", "internal": false } }

Database: Comments are stored in the activity_comments table in PostgreSQL.

CREATE TABLE activity_comments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), activity_id UUID NOT NULL REFERENCES activities(id), tenant_id UUID NOT NULL REFERENCES tenants(id), author_type VARCHAR(10) NOT NULL, -- 'agent' | 'human' agent_role VARCHAR(100), -- set when author_type = 'agent' user_id UUID REFERENCES users(id), -- set when author_type = 'human' body TEXT NOT NULL, -- Markdown internal BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX ON activity_comments(activity_id); CREATE INDEX ON activity_comments(tenant_id, created_at);

create_approval

Used by: All agent roles (Activity Planner and orchestration agents most commonly) Type: Control plane write

Creates a first-class approval record that can block one or many activities until a human resolves it. Use this for decisions that affect multiple activities or require explicit human sign-off on strategic or budget matters — not for reviewing a single piece of output (use request_human_review for that).

Full approval lifecycle: See Governance & Guardrails — Layer 3 for schema, resolution flow, and expiry design.

interface CreateApprovalInput { type: ApprovalType; // 'content_direction' | 'brand_direction' | 'strategy_change' // | 'budget_authorization' | 'content_review' | 'channel_action' title: string; // short label shown in DM Portal approval inbox description: string; // full context for the reviewer — what are they deciding? riskLevel?: 'low' | 'medium' | 'high'; // default derived from type (see governance-guardrails.md) linkedActivityIds?: string[]; // activities to block until this approval resolves // can be empty — approval blocks only the calling activity blockCurrentActivity?: boolean; // also suspend the calling agent's activity; default true options?: string[]; // if the reviewer must choose between named options // e.g. ['Proceed with formal tone', 'Proceed with casual tone'] expiresInHours?: number; // override default expiry window }

Tool result returned to agent:

{ "status": "approval_created", "approvalId": "apv_abc123", "message": "Approval created. 10 activities suspended until resolved by a reviewer." }

The agent should stop producing output for the blocked activities after calling this. The approval will appear in the DM Portal approval inbox. When the reviewer resolves it, all linked activities are re-enqueued simultaneously with wakeReason: 'review_approved' or 'review_feedback'.

When to use create_approval vs request_human_review:

Use create_approvalUse request_human_review
Decision affects multiple activitiesDecision only affects your current task
Strategic / budget / brand direction decisionReviewing a specific piece of output
Needs formal audit trail with type and risk levelAd-hoc check-in before continuing
Reviewer may need to choose between optionsReviewer approves or gives feedback

Example — Activity Planner blocks 12 social posts on a brand direction decision:

{ "tool": "create_approval", "input": { "type": "brand_direction", "title": "Approve tone direction for April social campaign", "description": "The April social calendar covers a product launch. I need a direction decision before writing 12 posts:\n\n- **Option A (Professional):** Focus on product specs, B2B language, LinkedIn-first\n- **Option B (Conversational):** Benefit-led, casual tone, Instagram-first\n\nThis decision will be applied to all 12 posts in the April campaign.", "riskLevel": "medium", "linkedActivityIds": ["act_001", "act_002", "act_003", "..."], "blockCurrentActivity": true, "options": [ "Option A — Professional / B2B tone", "Option B — Conversational / Instagram-first tone" ] } }

Example — SEO Specialist requests budget authorization before running paid tool queries:

{ "tool": "create_approval", "input": { "type": "budget_authorization", "title": "Authorize SEMrush API spend for competitor gap analysis", "description": "The competitor gap analysis requires approximately 200 SEMrush API rows (~$0.40 at current usage rate). This is within the monthly tool budget but I want explicit authorization before proceeding as the client is on a cost-sensitive plan.", "riskLevel": "low", "blockCurrentActivity": true } }

create_activity (post-MVP)

Reserved. Would allow agents to create top-level activities not subordinate to the current one — effectively allowing agents to extend the pipeline. Not available at MVP. See the Heartbeat Model section in Agent Execution Engine for rationale.


Rate Limit Middleware

All integration clients are wrapped by a rate-limit layer backed by Redis:

class RateLimitedClient<T extends object> { constructor(private client: T, private limiter: Bottleneck) {} // Proxies all method calls through the limiter wrap(): T { /* Proxy pattern */ } } // Example: Google Search Console — 20 req/s const gscClient = new RateLimitedClient( new GoogleSearchConsoleClient(credentials), new Bottleneck({ reservoir: 20, reservoirRefreshAmount: 20, reservoirRefreshInterval: 1000 }) ).wrap();

Package Location

packages/integrations/

integrations/ ├── src/ │ ├── google-search-console/ │ │ ├── client.ts │ │ ├── schema.ts # JSON schema for LLM tool invocation │ │ └── types.ts │ ├── google-ads/ │ ├── meta-ads/ │ ├── ga4/ │ ├── semrush/ │ ├── ahrefs/ │ ├── dataforseo/ # Backlink live/indexed status checker │ ├── wordpress/ │ ├── mailchimp/ │ ├── klaviyo/ │ ├── slack/ │ ├── google-docs/ │ ├── browser/ # Playwright wrapper │ ├── control-plane/ │ │ ├── report-blocker.ts # report_blocker │ │ ├── create-subtask.ts # create_subtask │ │ ├── create-approval.ts # create_approval │ │ ├── request-review.ts # request_human_review │ │ ├── reassign-task.ts # reassign_task │ │ ├── add-comment.ts # add_comment │ │ └── schema.ts # JSON schemas for all control plane tools │ ├── rate-limiter.ts # RateLimitedClient wrapper │ ├── tool-dispatcher.ts # Routes LLM tool calls to correct client or control plane │ └── index.ts └── package.json

© 2026 Leadmetrics — Internal use only