Skip to Content
DocsDevelopersSDKSDK Reference

SDK Reference

Conventions

A few things hold for every method on this page:

  • You destructure data. Each call returns { data, request, response }; data is the parsed body and is always present — the client throws on any non-2xx response, so a call that returns has succeeded.
  • Envelopes. A single resource returns { item }, a list returns { items }, and a paginated list returns { items, pagination } where pagination is { page, limit, total, totalPages, hasMore }. Paging is manual.
  • Long lists inside a dimension come a page at a time. Six reads take view and answer compact unless you pass view: 'full'; the sections below name them. See Long lists come a page at a time.
  • Errors throw. Any non-2xx response throws a CompetLabError with status, code, and message. It’s not a returned value; you catch it.
  • Types are exported. Every response type named below (ProjectDetailResponse, PaginationMeta, and the rest) is exported from @competlab/sdk for you to import.

Construct the client once and reuse it:

import CompetLab from '@competlab/sdk'; const cl = new CompetLab({ apiKey: process.env.COMPETLAB_API_KEY! });

health

Service liveness. The one method that needs no API key.

MethodParametersReturnsEndpoint
cl.health.check()—{ item: HealthResponse }GET /v1/health

projects

Your projects — the top of every other call, since most methods take a projectId.

MethodParametersReturnsEndpoint
cl.projects.list()—{ items: ProjectListItemResponse[] }GET /v1/projects
cl.projects.get(projectId)projectId: string{ item: ProjectDetailResponse }GET /v1/projects/{projectId}

competitors

The competitors tracked within a project.

MethodParametersReturnsEndpoint
cl.competitors.list(projectId)projectId: string{ items: CompetitorListItemResponse[] }GET /v1/projects/{projectId}/competitors
cl.competitors.get(projectId, competitorId)projectId: string, competitorId: string{ item: CompetitorDetailResponse }GET /v1/projects/{projectId}/competitors/{competitorId}

aiVisibility

Who the AI models recommend in your category, and where you stand among them — dashboard, history, one check’s detail, and the trend digest.

MethodParametersReturnsEndpoint
cl.aiVisibility.dashboard(projectId, query?)query?: { includeAnswers?, provider?, brand?, promptIndex?, view?, mapOffset?, mapLimit? }{ item: AiVisibilityDashboardResponse }GET …/ai-visibility
cl.aiVisibility.history(projectId, query?)query?: { page?, limit?, view? }{ readingGuide, items: AiVisibilityHistoryItemResponse[], pagination, truncated }GET …/ai-visibility/history
cl.aiVisibility.checkDetail(projectId, checkId, query?)query?: { includeAnswers?, provider?, brand?, promptIndex?, view?, mapOffset?, mapLimit?, includeSummary? }{ item: AiVisibilityCheckDetailResponse }GET …/ai-visibility/history/{checkId}
cl.aiVisibility.trend(projectId, query?)query?: { dateFrom?, dateTo?, provider?, detail? }{ item: AiVisibilityTrendResponse }GET …/ai-visibility/trend

dashboard, checkDetail and history answer in the compact view unless you pass view: 'full'. On the first two, summary.marketMap.brands is one page, with summary.marketMap.brandsPage ({ offset, limit, total, hasMore }) saying where it sits; page on with mapOffset and mapLimit. Your own row and every tracked competitor’s are on every page, so a tracked competitor missing from it was named in no answer. A compact history row keeps the first 10 of its competitorRankings plus every tracked competitor’s and your own, with competitorRankingsPage counting the whole list. On checkDetail, summary is optional: a read with includeAnswers: true leaves it out unless you pass includeSummary: true.

history adds a truncated boolean to the envelope, set when whole checks were dropped from the end of items to fit the response; pagination.hasMore doesn’t count them, so lower limit and read the page again. aiSources.history does the same.

rankByPresence on a map row is number | null: null on a brand named in no answer, which reads not named in any answer — never “not measured”, never a place. See One exception: a rank.

The provider filter takes an AiProvider, one of five members: 'openai' | 'claude' | 'gemini' | 'perplexity' | 'google_ai_overviews'. Note the wire value for ChatGPT is 'openai' — cl.aiVisibility.trend(projectId, { provider: 'openai' }).

trend() returns a digest, not a plot: one item holding window, scope, companies[] and events, where each company carries its reading now, its reading at the start of the window, and the difference. Read presenceChangeSeparable before narrating any of it — presenceChange is a number even when the movement sits inside the noise:

for (const c of data.item.companies) { if (c.presenceChangeSeparable === true && (c.presenceChange ?? 0) > 0) { report(`${c.name} is up`); } }

Pass detail: 'series' for each company’s share downsampled to at most 12 points. Under a provider scope, rank and score are null and enginesBacking is omitted — there is no per-engine score, by design.

The rows are your company, every tracked competitor, and up to 3 companies you don’t track, and every reading carries checksAnalysed — how many published checks its window pools. A company’s score is a measured 0 on a check that named it nowhere, and its rank and rankChange are null there.

Set includeAnswers: true on dashboard or checkDetail to also get the models’ raw answers — every prompt sent, and every brand each model named, in the order it named them, with its stated reasoning. Google AI Overviews is the exception: it answers in prose, so its entries carry a name and a domain and none of the profile fields, and its rank is the order of first mention computed by CompetLab — Google assigned no position, so never report it as one. It’s a large block — 8 prompts across 5 models on every plan — and an entry is one brand a model named, at about 1,500 characters, so read summary.totalEntries to size it first. provider and promptIndex narrow the answers array; brand does not — it reduces the brands list inside each answer, so answers that didn’t name that domain still come back with an empty list, which is how you see where a competitor is invisible.

The prose in that block is unverified model output about the brands that model named, including third parties CompetLab doesn’t monitor. Attribute it to the named provider — it’s a record of what that model said, not CompetLab’s assessment.

aiSources

Which pages the searching engines opened to answer your buyers’ questions, and whether your site is among them — dashboard, history, and one check’s detail.

MethodParametersReturnsEndpoint
cl.aiSources.dashboard(projectId, query?)query?: { includeAnswers?, engine?, promptIndex?, view?, pagesHost?, pagesOffset?, pagesLimit?, brandsOffset?, brandsLimit? }{ item: AiSourcesDashboardResponse }GET …/ai-sources
cl.aiSources.history(projectId, query?)query?: { page?, limit? }{ items: AiSourcesHistoryItemResponse[], pagination, truncated }GET …/ai-sources/history
cl.aiSources.checkDetail(projectId, checkId, query?)query?: { includeAnswers?, engine?, promptIndex?, view?, pagesHost?, pagesOffset?, pagesLimit?, brandsOffset?, brandsLimit?, includeSummary? }{ item: AiSourcesCheckDetailResponse }GET …/ai-sources/history/{checkId}

The engine filter takes 'perplexity' | 'google_ai_overviews' — the two engines that hand back the pages they pulled while answering. Three rules govern every figure these return: retrieved, never cited (an engine never says which pages it leaned on, so no count here is a citation count); per engine, never pooled (the two read different pages, so adding their page counts describes a list neither produced); and counts, never rates (report n of 8 answers, never a percentage).

dashboard and checkDetail answer in the compact view unless you pass view: 'full': summary.pages and summary.brands are one page each, with summary.pagesPage and summary.brandsPage saying where each sits. Page on with pagesOffset / pagesLimit and brandsOffset / brandsLimit, or narrow the pages to one host with pagesHost. Your own brand row is on every page. pagesPage.total is a paging figure — rows of the list, across both engines — never an engine’s count of pages retrieved; that count is per engine, on summary.perEngine. Each summary.coreHosts[] lists its pages as pageUrls in place of pages: read them as rows of summary.pages, with pagesHost set to that host. On checkDetail, summary is optional: a read with includeAnswers: true leaves it out unless you pass includeSummary: true.

rankByPresence on a summary.brands row is null on a brand no answer named: beside answers.answersNaming: 0 it reads not named in any answer, never a place.

The sharpest trap is status on a core host, which has more than two values — filtering by “not already named” sweeps in pages that were never read:

const hosts = data.item.summary.coreHosts; // WRONG — turns a page we could not read into one that omits you const wrong = hosts.filter((h) => h.status !== 'already_named'); // right: only 'missing' means we read the page and your name was not on it const toWork = hosts.filter((h) => h.status === 'missing');

actionHint.text on each core host and the sentences under summary.limits.sentences are payload — render them verbatim rather than composing your own from the code beside them. actionHint.code is typed string rather than a union, so a new code is not a breaking change and gets no exhaustiveness check.

positioning

Where you sit in the market narrative — dashboard, history, and one run’s detail.

MethodParametersReturnsEndpoint
cl.positioning.dashboard(projectId)projectId: string{ item: PositioningDashboardResponse }GET …/positioning
cl.positioning.history(projectId, query?)query?: { page?, limit? }{ items: PositioningHistoryItemResponse[], pagination }GET …/positioning/history
cl.positioning.runDetail(projectId, runId)projectId: string, runId: string{ item: PositioningRunDetailResponse }GET …/positioning/history/{runId}

pricing

Competitor pricing and plan changes — dashboard, history, and one run’s detail.

MethodParametersReturnsEndpoint
cl.pricing.dashboard(projectId)projectId: string{ item: PricingDashboardResponse }GET …/pricing
cl.pricing.history(projectId, query?)query?: { page?, limit? }{ items: PricingHistoryItemResponse[], pagination }GET …/pricing/history
cl.pricing.runDetail(projectId, runId)projectId: string, runId: string{ item: PricingRunDetailResponse }GET …/pricing/history/{runId}

content

Competitor content and messaging changes — dashboard, history, run detail, and a changelog of what changed.

MethodParametersReturnsEndpoint
cl.content.dashboard(projectId)projectId: string{ item: ContentDashboardResponse }GET …/content
cl.content.history(projectId, query?)query?: { page?, limit? }{ items: ContentHistoryItemResponse[], pagination }GET …/content/history
cl.content.runDetail(projectId, runId)projectId: string, runId: string{ item: ContentRunDetailResponse }GET …/content/history/{runId}
cl.content.changelog(projectId, query?)query?: { page?, limit?, competitorId?, category?, allUrlsPerCategory? }{ items: ContentChangelogItemResponse[], pagination, truncated }GET …/content/changelog

The changelog adds a truncated boolean to the envelope, set when the result was capped.

category filters by the same twelve the content dashboard counts — blog, docs, tools, landing, caseStudies, comparison, integrations, changelog, webinars, legal, programmatic, other. Since 3.2.0 it is a union of those twelve rather than a plain string, so a misspelling is a compile error — and TypeScript names the correction — instead of a 400 you discover at runtime.

To narrow a value that arrives as a plain string — a CLI argument, a query-string value — import the type rather than retyping the twelve literals, which go stale the moment a thirteenth category ships:

import { type ContentCategory } from '@competlab/sdk'

ContentCategory is exported from 3.3.0. Before that the union was declared inline and had to be reached through the method signature — NonNullable<NonNullable<Parameters<typeof cl.content.changelog>[1]>['category']> — which still compiles and resolves to the same union if you have it in your codebase.

Filtering by programmatic works: a changelog row carries the same category the content dashboard reports for that URL, decided over the competitor’s whole sitemap rather than over the changed URLs alone, so a page added into a templated catalog filters under programmatic rather than other.

techTrust

Tech stack and trust signals — dashboard, history, and one run’s detail.

MethodParametersReturnsEndpoint
cl.techTrust.dashboard(projectId, query?)query?: { view? }{ item: TechTrustDashboardResponse }GET …/tech-trust
cl.techTrust.history(projectId, query?)query?: { page?, limit? }{ items: TechTrustHistoryItemResponse[], pagination }GET …/tech-trust/history
cl.techTrust.runDetail(projectId, runId)projectId: string, runId: string{ item: TechTrustRunDetailResponse }GET …/tech-trust/history/{runId}

Each competitor carries an optional aiAccess object — per-assistant reach across six named assistants, per-operator training access across nine, and the crawlers, directive and line number behind each verdict. 4.0.0 deleted allowsAiAccess, blockedAiBotsCount and aiBotsBlocked with no replacement boolean, so reading any of them is now a compile error rather than a wrong answer. Check aiAccess.measurement.status before the verdict arrays: on could_not_measure they are absent rather than empty, and ?? [] turns that into a false claim. See the omitted-key rule.

aiAccess verdicts live on the check, not the run summary — so dashboard and runDetail carry them and history does not.

dashboard answers in the compact view unless you pass view: 'full'. Each crawler’s catalog facts — what it is for (crawlerPurpose) and whether a robots.txt rule against it binds (honoursRobotsTxt) — are stated once in crawlerCatalog, keyed by userAgentToken, instead of on every verdict that names it, and an explanation that reads the same everywhere carries only its code, with the sentence in explanationCatalog. view: 'full' repeats them on every item.

alerts

Notable changes surfaced across the dimensions, filterable.

MethodParametersReturnsEndpoint
cl.alerts.list(projectId, query?)query?: { page?, limit?, dimension?, severity?, competitorId? }{ items: AlertListItemResponse[], pagination }GET …/alerts

dimension is one of tech-trust, content, positioning, pricing, ai-visibility, ai-sources; severity is one of critical, high, medium, info.

schedules

The monitoring cadence configured for a project.

MethodParametersReturnsEndpoint
cl.schedules.list(projectId)projectId: string{ items: ScheduleItemResponse[] }GET …/schedules

strategicBriefing

The synthesized read across every dimension, every month or every two weeks on Process. Its envelope carries more than { item }.

MethodParametersReturnsEndpoint
cl.strategicBriefing.get(projectId, query?)query?: { sections?, includeCharts? }{ meta, item, coverage, contains, tickets }GET …/strategic-briefing
cl.strategicBriefing.history(projectId, query?)query?: { page?, limit? }{ items, pagination }GET …/strategic-briefing/history
cl.strategicBriefing.edition(projectId, runId, query?)query?: { sections?, includeCharts? }{ meta, item, coverage, contains, tickets }GET …/strategic-briefing/history/{runId}

Check meta.status — 'running' | 'done' | 'failed' | null — before reading item, which is null unless the latest run finished. Pass sections to fetch specific parts (e.g. ['deep-ai-visibility', 'competitors']) and includeCharts: true for chart data.

An edition has no actions section, and 'actions' is not in the sections type. Every move in the briefing lands on your Strategic Tickets board — as a new ticket, most important first, or on the ticket already there for that work. tickets on the envelope says what the edition did to the board: opened (the ids of the tickets it opened that are still on the board), commented (the comments it wrote on tickets already there), alreadyOnBoard (the tickets its moves matched instead of opening a second one) and recheckedUnchanged (open tickets it measured again and found where they stood), with total and byStatus counting the opened tickets per column as the board stands now. A ticket in neither commented nor recheckedUnchanged was not measured by this edition — not checked, never unchanged. tickets is null unless meta.status is 'done'. Read the tickets themselves with cl.tickets.list(projectId, { origin: 'briefing', briefingRunId: data.meta.runId! }).

const { data } = await cl.strategicBriefing.get(projectId, { sections: ['deep-ai-visibility', 'competitors'], includeCharts: true, }); if (data.meta.status === 'done') { console.log(data.item); }

get() returns the latest run in whatever state it is in. Only meta.status === null means the project genuinely has no briefing — on running or failed, list past editions and read the newest finished one instead of reporting that none exists:

const { data } = await cl.strategicBriefing.get(projectId); if (!data.item && data.meta.status !== null) { const { data: past } = await cl.strategicBriefing.history(projectId); const newest = past.items.find((row) => row.status === 'done'); if (newest) { const { data: edition } = await cl.strategicBriefing.edition(projectId, newest.runId); console.log(edition.item); } }

tickets

The project’s Strategic Tickets board — the one the team sees in the app, and the only part of the API that changes a project. Every method works on any plan and after one ends, and every method that writes needs a read_write key — a read key gets 403 insufficient_scope. What you write carries origin: 'api', so the board tells your automation’s tickets and comments from a person’s.

MethodParametersReturnsEndpoint
cl.tickets.list(projectId, query?)query?: { page?, limit?, status?, sort?, closed?, origin?, dimension?, briefingRunId?, assignee?, labelId?, number?, q?, impactMin?, effort?, maxMinutes?, dueFrom?, dueBefore?, activeSince?, include? }{ items: TicketListItemResponse[], pagination, byStatus }GET …/tickets
cl.tickets.get(projectId, ticketId)ticketId: string{ item: TicketResponse }GET …/tickets/{ticketId}
cl.tickets.create(projectId, body)body: { title, status, description?, labelIds?, assigneeUserId?, dueDate?, effort?, impact? }{ item: TicketResponse }POST …/tickets
cl.tickets.update(projectId, ticketId, body)body: { title?, description?, labelIds?, assigneeUserId?, dueDate?, effort?, impact? }{ item: TicketResponse }PATCH …/tickets/{ticketId}
cl.tickets.move(projectId, ticketId, body)body: { status, beforeId?, afterId?, position? }{ item: TicketResponse, placement }PATCH …/tickets/{ticketId}/move
cl.tickets.delete(projectId, ticketId)ticketId: string{ item: TicketDeletedResponse }DELETE …/tickets/{ticketId}
cl.tickets.assignees(projectId)projectId: string{ items: TicketPersonResponse[] }GET …/tickets/assignees
cl.tickets.comments.list(projectId, ticketId)ticketId: string{ items: TicketCommentResponse[] }GET …/tickets/{ticketId}/comments
cl.tickets.comments.create(projectId, ticketId, body)body: { body }{ item: TicketCommentResponse }POST …/tickets/{ticketId}/comments
cl.tickets.comments.update(projectId, ticketId, commentId, body)body: { body }{ item: TicketCommentResponse }PATCH …/tickets/{ticketId}/comments/{commentId}
cl.tickets.comments.delete(projectId, ticketId, commentId)commentId: string{ item: TicketDeletedResponse }DELETE …/tickets/{ticketId}/comments/{commentId}
cl.tickets.labels.list(projectId)projectId: string{ items: TicketLabelResponse[] }GET …/tickets/labels
cl.tickets.labels.create(projectId, body)body: { name, color }{ items: TicketLabelResponse[] }POST …/tickets/labels
cl.tickets.labels.update(projectId, labelId, body)body: { name?, color? }{ items: TicketLabelResponse[] }PATCH …/tickets/labels/{labelId}
cl.tickets.labels.delete(projectId, labelId)labelId: string{ items: TicketLabelResponse[] }DELETE …/tickets/labels/{labelId}

list answers a page at a time — limit defaults to 50, at most 100 — with pagination.total counting every match and byStatus the matches per column, whatever status you passed. sort is 'board' (the default: the columns in board order, each in the order the team keeps, and the only order to read neighbours for a move from), 'priority', 'due' or 'activity'. closed defaults to 'all', and q matches the title or the description. A row is a TicketListItemResponse: every field but description, which include: ['description'] adds; get always carries it.

const { data: page } = await cl.tickets.list(projectId, { status: ['triage', 'todo'], sort: 'priority' }); // page.pagination => { page: 1, limit: 50, total: 23, totalPages: 1, hasMore: false } // page.byStatus => { triage: 9, todo: 14, in_progress: 3, done: 11, dismissed: 2 } const { data: created } = await cl.tickets.create(projectId, { title: 'Answer the pricing-page objection Acme now leads with', status: 'todo', }); // Name its neighbours (beforeId above, afterId below) or a position, not both. // Naming nothing puts it at the bottom of the column. const { data: moved } = await cl.tickets.move(projectId, created.item.id, { status: 'in_progress', position: 'top', }); // moved.placement => { status: 'in_progress', above: null, below: { id, number: 12, title }, ignored: [] }
  • number is a name, not an address. It’s the ticket’s number on the board (#14 in the app): list(projectId, { number: 14 }) finds it, and every method that acts on a ticket takes its id.
  • move says where the ticket landed. placement carries its neighbours now, above and below, and ignored[] — each neighbour you named that was not used, and why. A ticket changes column only through move, never through update.
  • On update, null clears a field and an omitted field is left alone; the description clears with "", labels with [].
  • A ticket a Strategic Briefing opened can’t be deleted. Read deletable, and move it to dismissed instead.
  • A briefing’s ticket carries briefing: the edition’s runId; the dimension and the edition’s estimate of the work (estimatedMinutes), each only where the edition gave it — a null estimate is unsized, never quick; and extendsTicketId, the ticket already on the board it builds on. It’s null on a ticket a person or a key opened.
  • A comment carries briefing too — { runId, kind, editionNumber, completedAt } on one an edition wrote, null on every other. kind is a TicketCommentKind ('result', 'basis_changed', 'basis_weaker', 'basis_stronger', 'basis_gone'), or null on a kind the API doesn’t know yet — the comment is still there. Its body is dated facts: report it as the comment states it, never as the fix having worked.
  • A comment a person wrote in the app can’t be edited through the API — 403 forbidden, whatever the key.
  • Labels are the project’s own. A ticket names them by ID from labels.list, and a label write answers with the project’s whole label list.

tools

The free scan tools — the same public scans the MCP server exposes. These don’t take a projectId; they run against a URL you give them. Three return their result directly; the other three are asynchronous scans you start and then poll. All six fetch or scan a site on CompetLab’s side, so the key’s organization needs a plan — without one the call fails with 402 subscription_required and nothing runs; a finished scan’s get* read still answers.

Direct (one call, result inline):

MethodParametersReturnsEndpoint
cl.tools.sitemapVisualizer(body)body: { domain, sitemapUrl?, includeUrls? }{ item: SitemapVisualizerToolResponse }POST /v1/tools/sitemap-visualizer
cl.tools.aiCrawlerChecker(body)body: { domain, industry? }{ item: AiCrawlerCheckerToolResponse }POST /v1/tools/ai-crawler-checker
cl.tools.fetchUrl(body)body: { url, bodyNeeded?, headersNeeded?, cleanHtml?, maxTimeoutMs?, bodyMaxBytes? }{ item: FetchUrlToolResponse }POST /v1/tools/fetch-url

On fetchUrl, branch on headersAvailable — never on whether headers exists. headers is optional in the type because it is genuinely absent when you pass headersNeeded: false, so its presence answers “did you ask for headers”, not “did we get any”. When you did ask and the target revealed nothing, headers arrives as {} with headersAvailable: false: a measurement, not a gap.

Async scans (start, then poll — see the Quickstart):

MethodParametersReturnsEndpoint
cl.tools.techStack.startScan(body)body: { domain }{ item: TechStackScanResponse }POST /v1/tools/tech-stack/scans
cl.tools.techStack.getScan(scanId)scanId: string{ item: TechStackScanResponse }GET /v1/tools/tech-stack/scans/{scanId}
cl.tools.trustSignals.startScan(body)body: { domain }{ item: TrustSignalsScanResponse }POST /v1/tools/trust-signals/scans
cl.tools.trustSignals.getScan(scanId)scanId: string{ item: TrustSignalsScanResponse }GET /v1/tools/trust-signals/scans/{scanId}
cl.tools.agentAdoption.startScan(body)body: { domain, debugMode?, includeFixPrompts? }{ item: AgentAdoptionScanResponse }POST /v1/tools/agent-adoption/scans
cl.tools.agentAdoption.getScan(scanId)scanId: string{ item: AgentAdoptionScanResponse }GET /v1/tools/agent-adoption/scans/{scanId}

A scan response carries status — 'queued' | 'running' | 'completed' | 'failed' — with result present once completed and error present if failed. Scan IDs expire after 24 hours. Every tool’s request body is typed; where it takes a domain, that is a bare hostname or a full URL.

Full method map

All 54 methods, at a glance:

health check projects list · get competitors list · get aiVisibility dashboard · history · checkDetail · trend aiSources dashboard · history · checkDetail positioning dashboard · history · runDetail pricing dashboard · history · runDetail content dashboard · history · runDetail · changelog techTrust dashboard · history · runDetail alerts list schedules list strategicBriefing get · history · edition tickets list · get · create · update · move · delete · assignees comments.{list,create,update,delete} labels.{list,create,update,delete} tools sitemapVisualizer · aiCrawlerChecker · fetchUrl techStack.{startScan,getScan} trustSignals.{startScan,getScan} agentAdoption.{startScan,getScan}

For the underlying HTTP — status codes, full request and response schemas, error codes — see the REST API reference. The SDK is a typed layer over exactly those endpoints.

Last updated on