SDK Reference
Conventions
A few things hold for every method on this page:
- You destructure
data. Each call returns{ data, request, response };datais 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 }wherepaginationis{ page, limit, total, totalPages, hasMore }. Paging is manual. - Errors throw. Any non-2xx response throws a
CompetLabErrorwithstatus,code, andmessage. It’s not a returned value; youcatchit. - Types are exported. Every response type named below (
ProjectDetailResponse,PaginationMeta, and the rest) is exported from@competlab/sdkfor 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.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
cl.health.check() | — | { item: HealthResponse } | GET /v1/health |
projects
Your projects — the top of every other call, since most methods take a projectId.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
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.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
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
How the AI engines mention your brand versus competitors — dashboard, history, one check’s detail, and a provider trend over time.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
cl.aiVisibility.dashboard(projectId, query?) | query?: { includeAnswers?, provider?, brand?, promptIndex? } | { item: AiVisibilityDashboardResponse } | GET …/ai-visibility |
cl.aiVisibility.history(projectId, query?) | query?: { page?, limit? } | { items: AiVisibilityHistoryItemResponse[], pagination } | GET …/ai-visibility/history |
cl.aiVisibility.checkDetail(projectId, checkId, query?) | query?: { includeAnswers?, provider?, brand?, promptIndex? } | { item: AiVisibilityCheckDetailResponse } | GET …/ai-visibility/history/{checkId} |
cl.aiVisibility.trend(projectId, query?) | query?: { dateFrom?, dateTo?, provider? } | { items: AiVisibilityTrendDataPointResponse[] } | GET …/ai-visibility/trend |
The provider filter takes an AiProvider: 'openai' | 'claude' | 'gemini'. Note the wire
value for ChatGPT is 'openai' — cl.aiVisibility.trend(projectId, { provider: 'openai' }).
The trend method returns a plain { items } list, not a paginated one.
Set includeAnswers: true on dashboard or checkDetail to also get the models’ raw answers
— every prompt sent, and every brand each model named in rank order with its stated reasoning.
It’s a large block (roughly 12k tokens unfiltered against ~2k with brand), so read
summary.totalEntries to size it first, at about 200 tokens per entry. 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.
positioning
Where you sit in the market narrative — dashboard, history, and one run’s detail.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
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.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
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.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
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.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
cl.techTrust.dashboard(projectId) | projectId: string | { 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.
alerts
Notable changes surfaced across the dimensions, filterable.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
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;
severity is one of critical, high, medium, info.
schedules
The monitoring cadence configured for a project.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
cl.schedules.list(projectId) | projectId: string | { items: ScheduleItemResponse[] } | GET …/schedules |
strategicBriefing
The synthesized monthly read across every dimension. Its envelope is the one exception to the
{ item } pattern.
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
cl.strategicBriefing.get(projectId, query?) | query?: { sections?, includeCharts? } | { item, meta, coverage, contains } | 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? } | { item, meta, coverage, contains } | 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', 'actions']) and includeCharts: true for chart data.
const { data } = await cl.strategicBriefing.get(projectId, {
sections: ['deep-ai-visibility', 'actions'],
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);
}
}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.
Direct (one call, result inline):
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
cl.tools.sitemapVisualizer(body) | body | { item: SitemapVisualizerToolResponse } | POST /v1/tools/sitemap-visualizer |
cl.tools.aiCrawlerChecker(body) | body | { 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):
| Method | Parameters | Returns | Endpoint |
|---|---|---|---|
cl.tools.techStack.startScan(body) | body (e.g. { 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 (e.g. { 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 (e.g. { domain }) | { 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. Of the scan request bodies, only fetchUrl’s is strongly typed; the scanners accept
an open object where the domain field is what they read.
Full method map
All 36 methods, at a glance:
health check
projects list · get
competitors list · get
aiVisibility dashboard · history · checkDetail · trend
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
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.