> For the complete documentation index, see [llms.txt](https://docs.talus.network/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.talus.network/guides/nexus-api/typescript-client.md).

# Nexus API TypeScript Client

{% hint style="info" %}
**Audience:** TypeScript developers consuming the hosted Nexus API from a server, relay, or browser-adjacent service.

**Goal:** Build a source-independent consumer with typed public errors, unlimited healthy pagination, bounded per-page retries, SSE-compatible events, and no credential leakage.
{% endhint %}

This page keeps the historical TypeScript client entry point while defining a source-independent consumer contract. No unpublished or private package is required: standard fetch, an SSE-compatible stream reader, and page-local public types are enough for an external application. The executable core below is JavaScript-compatible so the behavior can be tested with the Node runtime already required by the tutorial; TypeScript consumers can add the declared types without changing the request, cursor, or retry state machine.

### Page-local public types

Keep response types beside the consumer so a deployment can add fields. Cursor values are opaque strings or non-negative numbers, and status and event-kind strings remain open sets:

```ts
export type Cursor = string | number;

export type NexusApiErrorFields = {
  status: number;
  errorCode?: string;
  description: string;
  contentType: string;
  requestId: string | null;
  retryAfter: string | null;
  rateLimitReset: string | null;
  retryAfterMs?: number;
  rateLimitResetMs?: number;
  retryable: boolean;
};

export declare class NexusApiError extends Error {
  readonly status: number;
  readonly errorCode?: string;
  readonly description: string;
  readonly contentType: string;
  readonly requestId: string | null;
  readonly retryAfter: string | null;
  readonly rateLimitReset: string | null;
  readonly retryAfterMs?: number;
  readonly rateLimitResetMs?: number;
  readonly retryable: boolean;
}

export type ExecutionSummary = {
  object_id: string;
  dag_object_id?: string;
  status: string;
  invoker?: string;
  created_at: string;
};

export type EventFrame = {
  id: number;
  kind: string;
  tx_digest?: string;
  sender_address: string;
  payload: unknown;
};

export type ReplayCursor = string & { readonly __replayCursor: unique symbol };

export function canonicalReplayCursor(value: string | number): ReplayCursor {
  if (typeof value === "number") {
    if (!Number.isSafeInteger(value) || value < 0) throw new Error("replay cursor number is invalid");
    return String(value) as ReplayCursor;
  }
  if (!/^(0|[1-9]\d*)$/.test(value)) throw new Error("replay cursor string is not canonical");
  return value as ReplayCursor;
}

export function transportReplayCursor(raw: string): ReplayCursor | undefined {
  if (raw === "") return undefined;
  const cursor = canonicalReplayCursor(raw);
  if (String(cursor) !== raw) throw new Error("transport replay cursor was normalized");
  return cursor;
}

export function replayCursorIsAfter(previous: ReplayCursor | null, current: ReplayCursor): boolean {
  return previous === null || BigInt(current) > BigInt(previous);
}

export type HostedApiInput = {
  relayUrl: string;
  pageOrigin: string | URL;
};

export function relayBaseUrl(input: HostedApiInput): URL {
  if (!input.relayUrl) throw new Error("relayUrl is required");
  if (!input.pageOrigin) throw new Error("pageOrigin is required");
  const pageOrigin = new URL(input.pageOrigin.toString());
  const relay = new URL(input.relayUrl, pageOrigin);
  if (relay.origin !== pageOrigin.origin) throw new Error("relayUrl must be same-origin with pageOrigin");
  return relay;
}

export function relayResourceUrl(relayBase: string | URL, resourcePath: string): URL {
  const base = new URL(relayBase.toString());
  const prefix = base.pathname.replace(/\/+$/, "");
  const resource = resourcePath.replace(/^\/+/, "");
  base.pathname = `${prefix}/${resource}`;
  return base;
}

export type ListEnvelope<T> = {
  items: T[];
  metadata: { total_items: number; next_token: Cursor | null };
};
```

The server relay configuration owns the provider's provisional HTTPS URL and API key; those values never enter browser inputs. `HostedApiInput` contains only the dApp's same-origin relay prefix (often a relative `/api/nexus` path) and mandatory `pageOrigin`, which is the only URL base used for resolution. Browser requests and event frames use that relay URL, never the provider URL or API key directly. `EventFrame.tx_digest` is optional because a provider may project an event without a transaction digest; consumers must validate it when present and must not reject the frame solely because it is absent.

Browser setup passes the actual page origin explicitly and may use either a relative prefix or an absolute same-origin subpath:

```ts
const relativeRelay = relayBaseUrl({ relayUrl: "/api/nexus", pageOrigin: window.location.origin });
const absoluteRelay = relayBaseUrl({ relayUrl: `${window.location.origin}/api/nexus/`, pageOrigin: window.location.origin });
```

#### Fetch one page without leaking the key

The key must be available only to the server-side process. Do not put it in a VITE-prefixed variable, a browser bundle, a URL, a log line, or a command argument. A typed error preserves the public status, envelope fields, content type, request id, and server timing headers so callers can decide whether a retry is safe.

#### Executable pagination and retry core

The following browser/relay block is the source of truth exercised by the repository behavior test. It accepts only a same-origin relay URL and page origin, never a provider URL or API key. It uses standard fetch and an injected sleep function, so tests can prove timing without waiting or depending on a private package. If a server needs to call the provider directly, keep that as a separate server-only adapter that owns the provider URL and key; do not add those inputs to this shared core.

```js
// executable hosted consumer core
const TRANSIENT_STATUSES = (status) => status === 408 || status === 429 || (status >= 500 && status <= 599);

function canonicalReplayCursor(value) {
  if (typeof value === "number") {
    if (!Number.isSafeInteger(value) || value < 0) throw new Error("replay cursor number is invalid");
    return String(value);
  }
  if (!/^(0|[1-9]\d*)$/.test(value)) throw new Error("replay cursor string is not canonical");
  return value;
}

function transportReplayCursor(raw) {
  if (raw === "") return undefined;
  const cursor = canonicalReplayCursor(raw);
  if (String(cursor) !== raw) throw new Error("transport replay cursor was normalized");
  return cursor;
}

function replayCursorIsAfter(previous, current) {
  return previous === null || BigInt(current) > BigInt(previous);
}

function relayBaseUrl(input) {
  if (!input.relayUrl) throw new Error("relayUrl is required");
  if (!input.pageOrigin) throw new Error("pageOrigin is required");
  const pageOrigin = new URL(input.pageOrigin.toString());
  const relay = new URL(input.relayUrl, pageOrigin);
  if (relay.origin !== pageOrigin.origin) throw new Error("relayUrl must be same-origin with pageOrigin");
  return relay;
}

function relayResourceUrl(relayBase, resourcePath) {
  const base = new URL(relayBase.toString());
  const prefix = base.pathname.replace(/\/+$/, "");
  const resource = resourcePath.replace(/^\/+/, "");
  base.pathname = `${prefix}/${resource}`;
  return base;
}

function eventUrl(relayUrl, kinds, lastEventId) {
  const url = relayResourceUrl(relayUrl, "events/stream");
  url.searchParams.set("kinds", kinds.join(","));
  if (lastEventId !== undefined) url.searchParams.set("last_event_id", canonicalReplayCursor(lastEventId));
  return url.toString();
}

export class NexusApiError extends Error {
  constructor(fields) {
    super(fields.description || "Nexus API request failed");
    this.name = "NexusApiError";
    Object.assign(this, fields);
  }
}

function header(response, name) {
  return response.headers && response.headers.get(name);
}

export function parseServerDelay(raw, now = Date.now()) {
  if (typeof raw !== "string" || raw.trim() === "") return undefined;
  const value = raw.trim();
  if (/^[0-9]+(?:\.[0-9]+)?$/.test(value)) {
    const seconds = Number(value);
    return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
  }
  const date = Date.parse(value);
  return Number.isFinite(date) ? Math.max(0, date - now) : undefined;
}

function cursorIsValid(cursor) {
  return (typeof cursor === "string" && cursor.length > 0) || (Number.isSafeInteger(cursor) && cursor >= 0);
}

function cursorKey(cursor) {
  return typeof cursor + ":" + String(cursor);
}

function executionItemIsValid(item) {
  if (!item || typeof item !== "object") return false;
  if (typeof item.object_id !== "string" || item.object_id.length === 0) return false;
  if (typeof item.status !== "string" || item.status.length === 0) return false;
  if (typeof item.created_at !== "string" || item.created_at.length === 0) return false;
  if (item.dag_object_id !== undefined && typeof item.dag_object_id !== "string") return false;
  if (item.invoker !== undefined && typeof item.invoker !== "string") return false;
  return true;
}

function responseError(response, body, contentType, now) {
  const object = body && typeof body === "object" ? body : {};
  const retryAfter = header(response, "retry-after");
  const rateLimitReset = header(response, "ratelimit-reset");
  return new NexusApiError({
    status: response.status,
    errorCode: typeof object.error_code === "string" ? object.error_code : undefined,
    description: typeof object.description === "string" ? object.description : "Nexus API request failed",
    contentType,
    requestId: header(response, "x-request-id") || null,
    retryAfter: retryAfter || null,
    rateLimitReset: rateLimitReset || null,
    retryAfterMs: parseServerDelay(retryAfter, now),
    rateLimitResetMs: parseServerDelay(rateLimitReset, now),
    retryable: TRANSIENT_STATUSES(response.status),
  });
}

function invalidResponse(message, response, contentType) {
  return new NexusApiError({
    status: response.status,
    errorCode: "INVALID_RESPONSE",
    description: message,
    contentType,
    requestId: header(response, "x-request-id") || null,
    retryAfter: header(response, "retry-after") || null,
    rateLimitReset: header(response, "ratelimit-reset") || null,
    retryable: false,
  });
}

export async function readExecutionsPage(fetchImpl, options, cursor) {
  const relay = relayBaseUrl({ relayUrl: options.relayUrl, pageOrigin: options.pageOrigin });
  const url = relayResourceUrl(relay, "executions");
  url.searchParams.set("page_size", String(options.pageSize || 20));
  if (cursor !== undefined) url.searchParams.set("page_token", String(cursor));
  const headers = { accept: "application/json" };
  const response = await fetchImpl(url, { headers });
  const contentType = header(response, "content-type") || "";
  const text = await response.text();
  let body = text;
  let jsonValid = true;
  if (contentType.toLowerCase().includes("json") && text !== "") {
    try {
      body = JSON.parse(text);
    } catch {
      jsonValid = false;
    }
  }
  const now = options.now ? options.now() : Date.now();
  if (!response.ok) throw responseError(response, jsonValid ? body : undefined, contentType, now);
  if (!jsonValid || !contentType.toLowerCase().includes("json")) {
    throw invalidResponse("execution response is not a JSON envelope", response, contentType);
  }
  const metadata = body && typeof body === "object" ? body.metadata : undefined;
  if (!body || typeof body !== "object" || !Array.isArray(body.items) || !metadata || typeof metadata !== "object") {
    throw invalidResponse("execution response is missing items or metadata", response, contentType);
  }
  if (!body.items.every(executionItemIsValid)) {
    throw invalidResponse("execution response contains an invalid execution item", response, contentType);
  }
  if (!Number.isSafeInteger(metadata.total_items) || metadata.total_items < 0) {
    throw invalidResponse("execution metadata.total_items is invalid", response, contentType);
  }
  if (metadata.next_token !== null && !cursorIsValid(metadata.next_token)) {
    throw invalidResponse("execution metadata.next_token is invalid", response, contentType);
  }
  return { items: body.items, totalItems: metadata.total_items, nextToken: metadata.next_token };
}

export async function* readAllExecutions(fetchImpl, options = {}) {
  const config = {
    relayUrl: options.relayUrl,
    pageOrigin: options.pageOrigin,
    pageSize: options.pageSize || 20,
    maxAttempts: options.maxAttempts || 3,
    baseDelayMs: options.baseDelayMs || 250,
    maxDelayMs: options.maxDelayMs || 10000,
    now: options.now,
    sleep: options.sleep || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))),
  };
  let cursor;
  const seenCursors = new Set();
  while (true) {
    let page;
    for (let attempt = 0; attempt < config.maxAttempts; attempt += 1) {
      try {
        page = await readExecutionsPage(fetchImpl, config, cursor);
        break;
      } catch (error) {
        const typed = error instanceof NexusApiError ? error : new NexusApiError({
          status: 0,
          errorCode: "TRANSPORT_ERROR",
          description: error instanceof Error ? error.message : "transport failed",
          contentType: "",
          requestId: null,
          retryable: true,
        });
        if (!typed.retryable || attempt + 1 >= config.maxAttempts) throw typed;
        const serverDelay = typed.retryAfterMs ?? typed.rateLimitResetMs;
        const fallbackDelay = config.baseDelayMs * 2 ** attempt;
        const delay = Math.min(serverDelay ?? fallbackDelay, config.maxDelayMs);
        await config.sleep(delay);
      }
    }
    if (page.nextToken !== null) {
      const key = cursorKey(page.nextToken);
      if (seenCursors.has(key)) {
        throw new NexusApiError({
          status: 0,
          errorCode: "INVALID_CURSOR",
          description: "the API repeated a pagination cursor",
          contentType: "application/json",
          requestId: null,
          retryable: false,
        });
      }
      seenCursors.add(key);
      cursor = page.nextToken;
    }
    for (const item of page.items) yield item;
    if (page.nextToken === null) return;
  }
}
```

The cursor loop has no page-count cap. Each page gets a fresh attempt counter, so seven healthy pages and a million healthy pages follow the same path. Before a page yields any items, its non-null cursor is validated against the previously seen cursors; a transient 429 or 5xx retries only the current page, and a valid page then yields its items before the next cursor is requested.

Retry-After numeric values are interpreted as delta-seconds, and valid HTTP-date values are interpreted relative to the current clock. ratelimit-reset is treated as a delta-seconds value when it is numeric. Invalid or absent timing metadata uses bounded exponential fallback. The delay is capped by maxDelayMs, and 401/403, malformed envelopes, invalid content types, repeated cursors, and exhausted attempts are terminal typed errors.

#### Behavior tests

The repository test extracts the executable core above and runs it with standard Node fetch and deterministic responses. It proves at least seven healthy pages with preserved order and completeness, 429 then success honoring Retry-After, 5xx fallback, terminal 401 metadata, exhausted retry, repeated-cursor rejection, malformed envelope and timing-header behavior, and per-page retry reset. Run the public Docs lint gate after changing this page:

```bash
just md-lint
```

#### Consume the event stream

In a backend, use an SSE parser that preserves the last numeric id and reconnects with Last-Event-ID. In a browser, use EventSource only against your same-origin relay; EventSource cannot carry the provider key itself:

```ts
export function eventUrl(relayUrl: string | URL, kinds: string[], lastEventId?: ReplayCursor): string {
  const url = relayResourceUrl(relayUrl, "events/stream");
  url.searchParams.set("kinds", kinds.join(","));
  if (lastEventId !== undefined) url.searchParams.set("last_event_id", String(lastEventId));
  return url.toString();
}

const copiedStringCursor: ReplayCursor = canonicalReplayCursor("41");
const copiedNumberCursor: ReplayCursor = canonicalReplayCursor(42);
const copiedRelay = relayBaseUrl({ relayUrl: "/api/nexus", pageOrigin: window.location.origin });
eventUrl(copiedRelay, ["ExecutionFinished"], copiedStringCursor);
eventUrl(copiedRelay, ["ExecutionFinished"], copiedNumberCursor);

export function validateEvent(value: unknown): EventFrame {
  if (!value || typeof value !== "object") throw new Error("event is not an object");
  const event = value as Partial<EventFrame>;
  const id = event.id;
  const hasTransactionDigest = Object.prototype.hasOwnProperty.call(event, "tx_digest");
  const hasPayload = Object.prototype.hasOwnProperty.call(event, "payload");
  if (typeof id !== "number" || !Number.isSafeInteger(id) || id < 0 || typeof event.kind !== "string" || typeof event.sender_address !== "string" || !hasPayload || (hasTransactionDigest && (typeof event.tx_digest !== "string" || event.tx_digest.length === 0))) throw new Error("event schema is invalid");
  return { id, kind: event.kind, tx_digest: event.tx_digest, sender_address: event.sender_address, payload: event.payload };
}
```

Store the cursor only after `validateEvent` succeeds: when `MessageEvent.lastEventId` is present, require it to be canonical and exactly equal to the validated numeric frame `id`; use the payload ID only when the transport field is empty. Require each agreed ID to be strictly greater than the previous one, so duplicates and backwards frames never render or advance replay state. A fresh stream omits `last_event_id`; an explicit retry calls `eventUrl(relayUrl, kinds, lastValidatedEventId)` so the relay forwards the encoded cursor and the provider may replay retained events under its published retention contract. Invalid, mismatched, duplicate, and 401/403 frames never advance the stored cursor. If the provider rejects a stale cursor, clear the stored value and restart without `last_event_id` only when `/openapi.json` or the provider's reset policy documents that current-tip recovery; do not assume clamping or a gap-free replay.

The frame is self-contained enough to parse and display an activity indicator, but SSE remains a wake/invalidation signal rather than an authoritative database. Re-read the affected REST resource through the relay after a valid frame, especially after replay or projection lag. On malformed data, wrong MIME, 401/403, or a non-retryable response, close the stream and expose a visible recovery action.

Validate the requested `kinds` before opening the stream; an unknown kind is an HTTP 400 `INVALID_FILTER_VALUE`, not a successful empty stream.

#### React boundary

The [React tutorial](/guides/nexus-api/tutorial/05-port-to-react.md) copies the same public types into an app and calls the relay with relative paths. Its checked App source uses ordinary React state and fetch/EventSource-compatible behavior, while the Vite relay fixture retains the response-header and cache policy tests. Run the consumer checks in their own application workspace and run the public Docs lint gate here:

```bash
just md-lint
```

The maintenance gates typecheck/build the documented consumer, run the relay policy suite, audit the temporary dependency graph, and scan the real bundle for the test key. They do not require a private source tree or an unpublished package.

#### Error and recovery policy

* 401 or 403 is fatal for the current credential or scope; stop reconnecting until the provider rotates or broadens access.
* 408, 429, and 5xx are retryable only for idempotent reads, with bounded exponential backoff and Retry-After handling.
* 413 or malformed JSON is a request/response-shape failure; reduce the page or fix the consumer before retrying.
* A stream disconnect is recoverable when the provider's published retention/reset contract accepts the last validated id; reconnect with that cursor, validate/deduplicate frames, and re-read REST state. If the provider rejects the cursor, discard it and restart at the current tip only when the provider documents that action.
* In a browser, a fatal `EventSource` error must close the source and mark the stream stopped; recreate it only from an explicit retry or generation change, and refresh REST once for that new generation.
* A missing REST row after an event is projection lag, not proof of deletion; show degraded state and retry within the documented cache/lag boundary.

#### Next

Use [Connect a Nexus API dApp](/guides/nexus-api/connect-a-dapp.md) for the relay and production boundary, or return to the [tutorial](/guides/nexus-api/tutorial.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.talus.network/guides/nexus-api/typescript-client.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `build a script that syncs our docs to a CMS` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
