Skip to main content

SDK: TypeScript

Copy the file below into your repository (for example src/constellation-client.ts). It has zero dependencies and runs anywhere fetch exists: Node 18+, Deno, Bun, Cloudflare Workers, and browsers (note that browser origins must be on the API's CORS allowlist; server-side use is the default today).

What the client handles for you:

  • Auth via the x-api-key header on every request.
  • Telemetry batching: the server caps POST /telemetry at 1000 records per request, so ingestTelemetry splits any input array into batches of at most 1000, posts them in order, and returns one aggregated result. Partial rejections are surfaced with indices mapped back into your original array.
  • The 100-id cap on link_ids for GET /predictions, enforced client-side before the request is sent.
  • Typed errors: every non-2xx response throws ConstellationApiError carrying status, code (the machine-readable error field from the response envelope, such as auth_lockout, rate_limited, batch_too_large, too_many_link_ids, telemetry_unavailable), retryAfterSeconds when the server provides one, and the parsed body.
  • One automatic retry for 429 and 503 responses, honoring Retry-After. The retry only happens when the server's wait is within maxRetryWaitSeconds (default 30), so an auth_lockout telling you to wait 900 seconds fails fast instead of hanging your process.
// constellation-client.ts
// Vendorable single-file client for the ConstellationOS API.
// Zero dependencies; requires a fetch-capable runtime (Node 18+, Deno, Bun, browsers).

export type FieldValue = number | string;

export interface TelemetryRecord {
measurement: string; // non-empty
time: string; // ISO 8601 timestamp, e.g. "2026-07-09T12:00:00Z"
tags?: Record<string, string>;
fields?: Record<string, FieldValue>;
}

export interface TelemetryWriteAck {
write_id: string;
accepted_count: number;
rejected_count: number;
rejected_indices: number[] | null;
}

export interface IngestResult {
batches: TelemetryWriteAck[];
acceptedCount: number;
rejectedCount: number;
/** Indices into the original records array that the server rejected. */
rejectedIndices: number[];
}

export type TopologyMeasurement =
| 'link'
| 'satellite'
| 'ground_station'
| 'weather'
| 'telemetry';

export interface TopologyEntity {
measurement: string;
entity_id: string;
observed_at: string;
tags: Record<string, string>;
fields: Record<string, FieldValue>;
}

export interface Topology {
as_of_utc: string;
tenant_key: string;
entities: TopologyEntity[];
}

export interface Prediction {
link_id: string;
horizon_minutes: number;
predicted_at: string;
assignment_id: string;
model_version: string;
lease_id: string;
value: Record<string, number>; // SNR family: p50 / p10 / p90
feature_view_id: string;
}

export interface PredictionSet {
model_family: string;
captured_at: string;
items: Prediction[];
}

export interface LivePrediction {
schema_version: string;
link_id: string;
horizon_minutes: number;
value: Record<string, number>;
}

export interface InferenceProvenance {
served_from: 'sagemaker' | 'local' | 'registry';
model_version: string;
feature_source: 'npz' | 'influx';
entity_id: string;
as_of_utc: string;
feature_schema_version: string;
input_window_start: string;
input_window_end: string;
telemetry_points_used: number;
fill_policy: string;
horizons_minutes: number[];
}

export interface InferencePayload {
schema_version: 'v1';
tenant_key: string;
served_at: string;
predictions: LivePrediction[];
provenance: InferenceProvenance;
}

export type HealthSubsystem = 'telemetry' | 'topology' | 'predictions';

export interface SubsystemHealth {
subsystem: HealthSubsystem;
healthy: boolean;
status: number;
body: unknown;
}

export class ConstellationApiError extends Error {
readonly status: number;
readonly code: string;
readonly retryAfterSeconds?: number;
readonly body: unknown;

constructor(status: number, code: string, message: string, body: unknown, retryAfterSeconds?: number) {
super(message);
this.name = 'ConstellationApiError';
this.status = status;
this.code = code;
this.body = body;
this.retryAfterSeconds = retryAfterSeconds;
}
}

export interface ConstellationClientOptions {
baseUrl: string;
apiKey: string;
/** Records per POST /telemetry request. Server cap is 1000; values above are clamped. */
batchSize?: number;
/** Longest Retry-After (seconds) the automatic single retry will sleep through. */
maxRetryWaitSeconds?: number;
}

const MAX_BATCH = 1000;
const MAX_LINK_IDS = 100;

interface Attempt {
ok: boolean;
status: number;
body: unknown;
retryAfterSeconds?: number;
}

export class ConstellationClient {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly batchSize: number;
private readonly maxRetryWaitSeconds: number;

constructor(options: ConstellationClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.apiKey = options.apiKey;
this.batchSize = Math.min(Math.max(options.batchSize ?? MAX_BATCH, 1), MAX_BATCH);
this.maxRetryWaitSeconds = options.maxRetryWaitSeconds ?? 30;
}

/** GET /health. Open endpoint; returns 200 whenever the API is reachable. */
async health(): Promise<unknown> {
return this.request('GET', '/health');
}

/**
* GET /health/{subsystem}. Returns 503 when the subsystem is degraded, which is
* an answer rather than an error, so this never throws on 503.
*/
async subsystemHealth(name: HealthSubsystem): Promise<SubsystemHealth> {
const res = await fetch(`${this.baseUrl}/health/${name}`);
return { subsystem: name, healthy: res.ok, status: res.status, body: await parseBody(res) };
}

/** GET /topology: latest-sample-per-entity projection over your telemetry. */
async topology(
params: { asOfUtc?: string; measurement?: TopologyMeasurement; freshnessSeconds?: number } = {},
): Promise<Topology> {
const query = new URLSearchParams();
if (params.asOfUtc !== undefined) query.set('as_of_utc', params.asOfUtc);
if (params.measurement !== undefined) query.set('measurement', params.measurement);
if (params.freshnessSeconds !== undefined) query.set('freshness_seconds', String(params.freshnessSeconds));
return (await this.request('GET', '/topology', { query })) as Topology;
}

/**
* POST /telemetry with automatic batching (server cap: 1000 records/request).
* Returns one aggregated result; rejectedIndices refer to the input array.
*/
async ingestTelemetry(records: TelemetryRecord[]): Promise<IngestResult> {
const batches: TelemetryWriteAck[] = [];
const rejectedIndices: number[] = [];
for (let offset = 0; offset < records.length; offset += this.batchSize) {
const chunk = records.slice(offset, offset + this.batchSize);
const ack = (await this.request('POST', '/telemetry', { body: chunk })) as TelemetryWriteAck;
batches.push(ack);
for (const i of ack.rejected_indices ?? []) rejectedIndices.push(offset + i);
}
return {
batches,
acceptedCount: batches.reduce((n, a) => n + a.accepted_count, 0),
rejectedCount: batches.reduce((n, a) => n + a.rejected_count, 0),
rejectedIndices,
};
}

/**
* GET /predictions. Cached reads return a PredictionSet; live=true returns an
* InferencePayload with full provenance. The 100-id cap is enforced here so a
* too-large request never leaves the process.
*/
async predictions(params: {
modelFamily?: string;
linkIds: string[];
horizonMinutes?: number;
asOfUtc?: string;
live?: boolean;
}): Promise<PredictionSet | InferencePayload> {
if (params.linkIds.length > MAX_LINK_IDS) {
throw new ConstellationApiError(
400,
'too_many_link_ids',
`link_ids cap is ${MAX_LINK_IDS}, received ${params.linkIds.length}`,
{ error: 'too_many_link_ids', max: MAX_LINK_IDS, received: params.linkIds.length },
);
}
const query = new URLSearchParams();
query.set('model_family', params.modelFamily ?? 'snr');
for (const id of params.linkIds) query.append('link_ids', id);
if (params.horizonMinutes !== undefined) query.set('horizon_minutes', String(params.horizonMinutes));
if (params.asOfUtc !== undefined) query.set('as_of_utc', params.asOfUtc);
if (params.live) query.set('live', 'true');
return (await this.request('GET', '/predictions', { query })) as PredictionSet | InferencePayload;
}

private async request(
method: string,
path: string,
opts: { query?: URLSearchParams; body?: unknown } = {},
): Promise<unknown> {
const qs = opts.query?.toString() ?? '';
const url = `${this.baseUrl}${path}${qs ? `?${qs}` : ''}`;
const payload = opts.body !== undefined ? JSON.stringify(opts.body) : undefined;

let attempt = await this.send(method, url, payload);
if (attempt.status === 429 || attempt.status === 503) {
const wait = attempt.retryAfterSeconds ?? 1;
if (wait <= this.maxRetryWaitSeconds) {
await sleep(wait * 1000);
attempt = await this.send(method, url, payload);
}
}
if (attempt.ok) return attempt.body;
throw toError(attempt);
}

private async send(method: string, url: string, payload?: string): Promise<Attempt> {
const headers: Record<string, string> = { 'x-api-key': this.apiKey };
if (payload !== undefined) headers['content-type'] = 'application/json';
const res = await fetch(url, { method, headers, body: payload });
const body = await parseBody(res);
return { ok: res.ok, status: res.status, body, retryAfterSeconds: retryAfterOf(res, body) };
}
}

async function parseBody(res: Response): Promise<unknown> {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}

function retryAfterOf(res: Response, body: unknown): number | undefined {
if (body && typeof body === 'object' && 'retry_after_seconds' in body) {
const v = (body as { retry_after_seconds?: unknown }).retry_after_seconds;
if (typeof v === 'number') return v;
}
const header = res.headers.get('retry-after');
if (header !== null) {
const parsed = Number(header);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}

function toError(attempt: Attempt): ConstellationApiError {
const envelope = attempt.body as { error?: unknown } | null;
const code = envelope && typeof envelope.error === 'string' ? envelope.error : `http_${attempt.status}`;
return new ConstellationApiError(
attempt.status,
code,
`ConstellationOS API error ${code} (HTTP ${attempt.status})`,
attempt.body,
attempt.retryAfterSeconds,
);
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

Usage

Construct a client

import { ConstellationClient, ConstellationApiError } from './constellation-client';

const client = new ConstellationClient({
baseUrl: 'https://api.constellation.space',
apiKey: process.env.CONSTELLATION_API_KEY!,
});

Ingest telemetry (auto-batched)

const now = new Date().toISOString();
const result = await client.ingestTelemetry([
{
measurement: 'link',
time: now,
tags: { entity_id: 'gs-madrid--sat-041' },
fields: { snr_db: 12.4, elevation_deg: 34.1 },
},
// ...any number of records; the client splits into batches of at most 1000
]);

console.log(`accepted=${result.acceptedCount} rejected=${result.rejectedCount}`);
if (result.rejectedIndices.length > 0) {
console.warn('rejected input indices:', result.rejectedIndices);
}

Read topology

const topo = await client.topology({ measurement: 'link', freshnessSeconds: 3600 });
for (const entity of topo.entities) {
console.log(entity.entity_id, entity.observed_at, entity.fields);
}

Predictions, cached and live

// Cached prediction set
const cached = await client.predictions({ linkIds: ['gs-madrid--sat-041'], horizonMinutes: 15 });

// Live inference with provenance
const live = await client.predictions({ linkIds: ['gs-madrid--sat-041'], live: true });
if ('provenance' in live) {
console.log(live.provenance.served_from, live.provenance.model_version);
for (const p of live.predictions) {
console.log(p.link_id, p.horizon_minutes, p.value.p50);
}
}

Handle errors

try {
await client.ingestTelemetry(records);
} catch (err) {
if (err instanceof ConstellationApiError) {
// err.code: auth_failed, auth_lockout, rate_limited, batch_too_large,
// request_too_large, telemetry_unavailable, tenant_disabled, ...
if (err.code === 'auth_lockout') {
console.error(`locked out; retry after ${err.retryAfterSeconds}s`);
}
}
throw err;
}

Notes

  • The request body cap is 1 MB. If your records are large enough that 1000 of them exceed 1 MB, the server returns 413 request_too_large; lower batchSize in the constructor options.
  • tenant_key is stamped server-side from your API key. Do not include it in records; there is no way to write into another tenant.
  • 429 responses carry either rate_limited (per-IP 30/min, per-tenant 60/min with a 10/s burst) or auth_lockout (5 auth failures within 300 s locks the client IP for 900 s). The client retries the former automatically and fails fast on the latter because its wait exceeds the default retry cap.