Skip to Content
DocsDevelopersSDKOverview

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 install @competlab/sdk

It’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:

ResourceMethodsWhat you reach
health1Service health check (the one call that needs no key).
projects2List your projects; get one project’s setup and freshness.
competitors2List a project’s competitors; get one competitor’s detail.
aiVisibility4AI Visibility dashboard, history, one check’s detail, and the trend digest.
aiSources3AI Sources dashboard, history, and one check’s detail.
positioning3Positioning dashboard, history, and one run’s detail.
pricing3Pricing dashboard, history, and one run’s detail.
content4Content dashboard, history, run detail, and the content changelog.
techTrust3Tech & Trust dashboard, history, and one run’s detail.
alerts1List a project’s alerts, filtered by dimension and severity.
schedules1List a project’s monitoring schedules.
strategicBriefing3Pull the Strategic Briefing, list past editions, and read one edition in full.
tickets15The 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.
tools9The 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 apiKey option is required and has no fallback — if you keep your key in COMPETLAB_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 to https://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 }, where pagination is { page, limit, total, totalPages, hasMore }. Paging is manual — pass { page, limit } and loop on hasMore; there’s no auto-paging iterator. tickets.list adds byStatus, the matches per column.
  • Two answers carry more beside item. The Strategic Briefing returns { meta, item, coverage, contains, tickets }, and you branch on meta.status before reading item; tickets.move returns { 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.pages and summary.brands — with a …Page object 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 its competitorRankings plus every tracked competitor’s and your own. An AI Sources core host lists its pages as pageUrls, and Tech & Trust states each crawler’s facts once, in crawlerCatalog.
  • 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 pages

A 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, crawlerPurpose and honoursRobotsTxt are typed string, not unions — the API publishes them without an enum, so a switch over them gets no exhaustiveness check and a mistyped case fails silently at runtime. Narrow them yourself with an as const array 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.list returns { items, pagination, byStatus } in place of { items, total, hasMore }; summary is optional on both check details; rankByPresence is number | null; and a ticket list row carries no description unless you pass include: ['description']. (The release before, 6.0.0, added cl.tickets and took 'actions' out of BriefingSectionName — 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.brands or AI Sources’ summary.pages reads one page of it, a core host lists pageUrls in place of pages, and Tech & Trust states each crawler’s facts once, in crawlerCatalog. 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 the pagination object, 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.tickets creates, updates, moves, and deletes tickets, and writes ticket comments and the project’s labels. Every write needs a read_write key — a read key gets 403 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_required without 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.

Last updated on