CompetLab TypeScript SDK
What it is
The SDK is the typed TypeScript path to CompetLab. It wraps the same
REST API the dashboards use, so every method maps to a real
endpoint and every response comes back as a typed object — your projects, competitors, the
six monitored dimensions, your alerts and schedules, the Strategic Briefing, your Strategic
Tickets board, and the free scan tools. You write cl.pricing.dashboard(projectId); the SDK builds the request, sends
your key, checks the status, and hands you back a typed result.
Pick the SDK when you’re building on CompetLab in TypeScript or Node.js — a backend service, a scheduled job, an internal tool. It’s the same data as the REST API and the MCP server, aimed at a different caller: the REST API is for any language over plain HTTP, the MCP server is for AI agents that discover and call tools mid-conversation, and the SDK is for TypeScript code that wants types and autocomplete instead of raw HTTP. Under the hood all three read the same backend.
Install
npm
npm install @competlab/sdkIt’s published on npm as @competlab/sdk,
MIT-licensed, with no runtime dependencies — it uses the native fetch built into
Node.js 20+. The package ships both ESM and CommonJS builds with TypeScript declarations for
each, so both import CompetLab from '@competlab/sdk' and
const CompetLab = require('@competlab/sdk').default work, with types either way. (The client
is the default export, so CommonJS reaches it via .default.)
Quick start
import CompetLab from '@competlab/sdk';
const cl = new CompetLab({ apiKey: process.env.COMPETLAB_API_KEY! });
// See who the AI models recommend in your category, and where you stand among them
const { data } = await cl.aiVisibility.dashboard('65a1b2c3d4e5f6a7b8c9d0e1');
console.log(data.item.summary);That’s the whole shape of it: construct once with a key, then call a method. The
Quickstart walks the first request end to end — including why
data is destructured and how failures surface.
The surface
The client is organized into 14 resources that mirror the API. Each method is a thin, typed call over one endpoint:
| Resource | Methods | What you reach |
|---|---|---|
health | 1 | Service health check (the one call that needs no key). |
projects | 2 | List your projects; get one project’s setup and freshness. |
competitors | 2 | List a project’s competitors; get one competitor’s detail. |
aiVisibility | 4 | AI Visibility dashboard, history, one check’s detail, and the trend digest. |
aiSources | 3 | AI Sources dashboard, history, and one check’s detail. |
positioning | 3 | Positioning dashboard, history, and one run’s detail. |
pricing | 3 | Pricing dashboard, history, and one run’s detail. |
content | 4 | Content dashboard, history, run detail, and the content changelog. |
techTrust | 3 | Tech & Trust dashboard, history, and one run’s detail. |
alerts | 1 | List a project’s alerts, filtered by dimension and severity. |
schedules | 1 | List a project’s monitoring schedules. |
strategicBriefing | 3 | Pull the Strategic Briefing, list past editions, and read one edition in full. |
tickets | 15 | The Strategic Tickets board — list, read, create, update, move, and delete tickets; the people a ticket can be assigned to; each ticket’s comments; the project’s labels. |
tools | 9 | The free scans — sitemap, AI-crawler, URL fetch, and start/get for the tech-stack, trust-signals, and agent-adoption scans. |
That’s 54 methods in total. Every one, with its parameters and return type, is in the SDK reference.
Authentication
Pass your CompetLab API key when you construct the client — it’s the one required option:
const cl = new CompetLab({ apiKey: 'cl_live_...' });The SDK sends that key on every request as a CL-API-Key header — the same header the REST
API expects. Keys start with cl_live_ and are 40 characters; create and manage them in your
CompetLab organization settings. A key is read or read_write, fixed when you create it — only
the Strategic Tickets board’s writes need read_write. Two honest notes:
- The SDK does not read environment variables for you. The
apiKeyoption is required and has no fallback — if you keep your key inCOMPETLAB_API_KEY, you pass it in yourself (process.env.COMPETLAB_API_KEY!). Nothing is read from the environment behind your back. - The only other option is
baseUrl, which defaults tohttps://api.competlab.com. You’d override it only to point at a different environment; almost no one needs to.
Responses and types
Every method returns the parsed response alongside the raw request and response objects, so
you destructure data:
const { data } = await cl.projects.list();
// data.items => ProjectListItemResponse[]Responses follow the REST API’s envelopes, and the types match:
- A single resource comes back as
{ item: ... }. - A list comes back as
{ items: [...] }. - A paginated list adds
{ items, pagination }, wherepaginationis{ page, limit, total, totalPages, hasMore }. Paging is manual — pass{ page, limit }and loop onhasMore; there’s no auto-paging iterator.tickets.listaddsbyStatus, the matches per column. - Two answers carry more beside
item. The Strategic Briefing returns{ meta, item, coverage, contains, tickets }, and you branch onmeta.statusbefore readingitem;tickets.movereturns{ item, placement }.
All response types are exported from the package, so you can import and reference them
directly. data is always present on a returned result — the client throws on any non-2xx
response, so a call that returns has succeeded. You need no ! assertion and no
if (result.error) check; use try/catch. The
Quickstart shows the pattern in context.
Long lists come a page at a time
Six reads can return large payloads: aiVisibility.dashboard, aiVisibility.history and
aiVisibility.checkDetail, aiSources.dashboard and aiSources.checkDetail, and
techTrust.dashboard. Each takes view: 'compact' | 'full', and the API answers compact
unless you pass view: 'full'.
- Compact returns one page of each long list —
summary.marketMap.brands, AI Sources’summary.pagesandsummary.brands— with a…Pageobject beside it (offset,limit,total,hasMore). Your own row is on every page, and on the AI Visibility map so is every tracked competitor’s, so a tracked competitor missing from the page was named in no answer. Those kept rows repeat on every page, so de-duplicate by domain when you read more than one. A history row keeps the first 10 of itscompetitorRankingsplus every tracked competitor’s and your own. An AI Sources core host lists its pages aspageUrls, and Tech & Trust states each crawler’s facts once, incrawlerCatalog. - Full returns every row of every list, as stored.
Every response of these reads opens with readingGuide — the rule for reading each field it
carries, keyed by its path. A check detail read for its answers (includeAnswers: true) leaves
summary out unless you pass includeSummary: true, which is why summary is optional on both
check details’ types.
const { data } = await cl.aiVisibility.dashboard(projectId);
const { brands, brandsPage } = data.item.summary.marketMap;
// brandsPage => { offset: 0, limit: 10, total: 96, hasMore: true }
await cl.aiVisibility.dashboard(projectId, { mapOffset: 10, mapLimit: 50 }); // the next rows
await cl.aiVisibility.dashboard(projectId, { view: 'full' }); // every row
await cl.aiSources.dashboard(projectId, { pagesHost: 'g2.com' }); // one host's pagesA loop over summary.marketMap.brands or summary.pages reads one page of it, and it compiles.
Check hasMore on the list’s …Page object and page on with the method’s offset and limit, or
pass view: 'full'. Quote total as how many there are, never the length of the page.
Three combinations answer 400: a paging parameter beside view: 'full'
(paging_requires_compact_view), a paging parameter beside includeSummary: false
(paging_requires_summary), and includeSummary: false without includeAnswers: true
(nothing_to_return).
null means unmeasured
The same rule as the REST API: null means we did not measure it —
never zero, never empty, never “no”. A measured 0 or false is reported as itself and is
a real finding.
The types carry it, but TypeScript can’t stop you flattening it — if (!plan.hasFreePlan) is
true for both false and null, and ?? or || turns a null straight back into the
placeholder the API stopped sending. So the SDK exports three guards:
import { isMeasured, isMeasuredTrue, isMeasuredFalse } from '@competlab/sdk';
if (isMeasuredFalse(plan.hasFreePlan)) {
// We read the pricing page. There is no free plan. That's a finding.
} else if (!isMeasured(plan.hasFreePlan)) {
// We couldn't read it — say "not measured", or say nothing.
}isMeasured narrows away the null; isMeasuredTrue and isMeasuredFalse assert a measured
value. Reach for them anywhere a nullable boolean or number reaches a sentence a customer reads.
One exception: a rank
A rank is null on a brand no answer named — rankByPresence on the AI Visibility market map
and the AI Sources brand list, and rank and rankChange on the AI Visibility trend. Brands are
ordered by how often they are named, so a brand named in no answer has no place in that order.
That null sits beside a measured zero (answersNaming: 0 on the map, answers.answersNaming: 0
on AI Sources): read it as not named in any answer — never “not measured”, never a place, never
a fall. isMeasured would read it the wrong way, so branch on the count beside it. (Under a
provider filter the trend’s rank is null for a different reason: a rank exists only across
every model.)
An omitted key is a third state
null says we tried and could not measure this. An omitted key says something else: that
reporting any value at all would assert something we never established. Tech & Trust’s aiAccess
works this way, and no guard protects you here — the field simply isn’t there.
When a competitor’s robots.txt couldn’t be read, assistantAccess and modelTrainingAccess are
absent rather than empty, because [] would claim we evaluated all six assistants and none can
reach the site. That’s a different finding from “we couldn’t read the file”.
c.aiAccess?.assistantAccess ?? [] reintroduces exactly the bug the shape prevents. Branch on
measurement.status before you touch the arrays.
for (const c of data.item.competitors) {
if (!c.aiAccess) continue; // no AI-access section at all
if (c.aiAccess.measurement.status === 'could_not_measure') continue; // no verdicts exist
// 'measured' and 'measured_no_policy_found' both carry real verdicts — the second
// is a site with no robots.txt, which under the standard allows every crawler.
for (const a of c.aiAccess.assistantAccess ?? []) {
console.log(a.assistantName, a.crawlerAccessStatus);
}
}AI Visibility omits keys the same way. openai, claude and gemini on the per-provider
records, and the ten profile fields on AiVisibilityAnswerBrandResponse, are optional: a check
records the AI models it actually asked, and Google AI Overviews answers in prose and describes
nobody, so absent means not measured — never zero. Reaching for ?? 0 here turns “we didn’t
ask” into “it found nothing”.
Two honest notes on the types, both true as of 7.0.0:
-
crawlerAccessStatus,trainingAccessStatus,measurement.status,crawlerPurposeandhonoursRobotsTxtare typedstring, not unions — the API publishes them without anenum, so aswitchover them gets no exhaustiveness check and a mistypedcasefails silently at runtime. Narrow them yourself with anas constarray and a type guard. The free tools’ equivalents do carry unions, so the asymmetry is real rather than something you’re misreading. -
7.0.0 is a breaking release, and one part of it is quiet. Four breaks fail loudly, at build:
tickets.listreturns{ items, pagination, byStatus }in place of{ items, total, hasMore };summaryis optional on both check details;rankByPresenceisnumber | null; and a ticket list row carries nodescriptionunless you passinclude: ['description']. (The release before, 6.0.0, addedcl.ticketsand took'actions'out ofBriefingSectionName— every move in the briefing lands on the Strategic Tickets board.)The quiet one compiles: the six reads above now answer in the compact view by default, so a loop over
summary.marketMap.brandsor AI Sources’summary.pagesreads one page of it, a core host listspageUrlsin place ofpages, and Tech & Trust states each crawler’s facts once, incrawlerCatalog. See Long lists come a page at a time.
Errors
Every failure throws a single typed error, CompetLabError:
import CompetLab, { CompetLabError } from '@competlab/sdk';
try {
const { data } = await cl.projects.get('does-not-exist');
} catch (err) {
if (err instanceof CompetLabError) {
console.error(err.status, err.code, err.message);
// e.g. 404 "project_not_found" "Project not found"
}
}CompetLabError carries the HTTP status (number), a snake_case code matching the REST
API’s error codes, and a human-readable message. code is typed: the documented codes
autocomplete — project_not_found, subscription_required, insufficient_scope,
paging_requires_compact_view and the rest — and a code the API adds later still type-checks.
A failure that never reached the API throws the same class with code: "network_error", and a
gateway or proxy answering in the API’s place gives code: "http_error". There’s one error
class, not a hierarchy — you branch on status or code.
What it doesn’t do
The SDK is deliberately a thin, predictable client — no hidden magic between your call and the wire. That’s a design choice, not a gap: you stay in control of retries, timing, and paging rather than inheriting a framework’s opinions. Concretely:
- It doesn’t retry or time out for you. Each call is a single request. Wrap it yourself if you want retry or timeout behavior — the SDK won’t second-guess your policy.
- It doesn’t auto-paginate. Pagination is explicit via
{ page, limit }and thepaginationobject, so you decide when to fetch the next page. - No environment-variable magic, streaming, webhooks, or CLI. You pass the key explicitly, and there’s no bundled command-line tool — the SDK is a typed request layer, nothing more.
- It changes one thing in your CompetLab account: the Strategic Tickets board.
cl.ticketscreates, updates, moves, and deletes tickets, and writes ticket comments and the project’s labels. Every write needs aread_writekey — areadkey gets403 insufficient_scope— and the board works on any plan and after one ends. The three asynchronous scan tools — tech stack, trust signals, and agent adoption — start a live scan of a URL you give them and return an ID you poll; the other three free tools (sitemap, AI-crawler, and URL fetch) are plain reads that return inline. All six fetch or scan a site on CompetLab’s side, so they need a plan (402 subscription_requiredwithout one). Nothing else in the SDK changes your CompetLab projects, competitors, alerts, schedules, or settings.
Next steps
- Quickstart → — install, construct, your first call, and the async-scan poll loop, end to end.
- SDK reference → — all 54 methods, grouped by resource, with parameters, return types, and the endpoint each maps to.
FAQ
What is @competlab/sdk?
It's the official TypeScript SDK for the CompetLab REST API — a typed client you install with npm install @competlab/sdk. Instead of writing fetch calls, you construct a client with your API key and call typed methods like cl.pricing.dashboard(projectId). It covers the whole API — 54 methods across 14 resources, one per endpoint — spanning your projects, competitors, the six monitored dimensions, alerts, schedules, the Strategic Briefing, the Strategic Tickets board (which it reads and writes), and the free scan tools. It ships full TypeScript types, throws a typed CompetLabError on failure, has zero runtime dependencies, and runs on Node.js 20 or newer.
How do I install and authenticate?
Run npm install @competlab/sdk, then construct the client with your API key: new CompetLab({ apiKey: 'cl_live_...' }). The key is the one required option. The SDK sends it on every request as a CL-API-Key header — the same header the REST API expects. Keys start with cl_live_ and are 40 characters; you create them in your CompetLab organization settings. The SDK does not read environment variables for you, so if your key lives in COMPETLAB_API_KEY you pass it in explicitly.
How is the SDK different from the REST API and the MCP server?
Same data, different caller. The REST API is plain HTTP for any language. The MCP server is for AI agents that discover and call tools mid-conversation. The SDK is for TypeScript and Node.js code that wants types and autocomplete instead of raw HTTP — it's a thin typed layer over the same REST API, one method per endpoint, so anything you can do over plain HTTP you can do in the SDK. Pick the surface that matches who's calling: HTTP client, AI agent, or TypeScript code.
What does a method return?
Each method returns the parsed response, which you destructure as data. Responses follow the REST API's envelopes: a single resource is { item }, a list is { items }, and a paginated list adds { items, pagination } with page, limit, total, totalPages, and hasMore. The Strategic Briefing returns { meta, item, coverage, contains, tickets }, and you check meta.status before reading item. Six dimension reads answer in a compact view by default: each long list inside them is one page, with its own offset, limit, total, and hasMore — pass view: 'full' for every row. All response types are exported from the package, so you can import and reference them directly.
Do I need a non-null assertion on data?
No. data is always present on a returned result, and the type says so. The client throws on any non-2xx response, so a call that returns has succeeded — there is no success path where data is missing. Earlier versions declared an error branch the runtime never produced, which forced a ! assertion or a dead if (result.error) check; both are gone as of v3. Handle failures with try/catch and a typed CompetLabError. Note this is separate from null field values inside data: null there means CompetLab did not measure that field, never that the value is zero or false, and the isMeasured guards are the safe way to branch on it.
How do errors work?
Every failure throws a single typed error, CompetLabError, which you catch and inspect. It carries the HTTP status as a number, a snake_case code string that matches the REST API's error codes (like project_not_found or api_key_invalid), and a human-readable message. A failure that never reached the API throws the same class with code "network_error", and a gateway or proxy answering in the API's place gives "http_error". There's no error hierarchy — one class — so you branch on status or code.
Does it retry, time out, or auto-paginate?
No — the SDK is a typed request layer, not a framework. Each call is a single request with no automatic retries and no built-in timeout, so if you need those you wrap the call yourself. Pagination is manual too: pass { page, limit } and loop on the pagination object's hasMore flag. There's no auto-paging iterator, no streaming, no webhooks, and no bundled CLI. Keeping it thin is deliberate — it's the same behavior as calling the REST API directly, just typed.
Which runtimes and module systems does it support?
It requires Node.js 20 or newer, because it uses the native fetch built into that version rather than shipping a polyfill. The package publishes both ESM and CommonJS builds with matching TypeScript declarations, so both import CompetLab from '@competlab/sdk' and const CompetLab = require('@competlab/sdk').default work, with full types either way. The client is the default export, so CommonJS reaches it through .default. It has zero runtime dependencies and is tree-shakeable.