Guides

TypeScript client

Use the typed Talus API client from Node or the browser — auth, pagination, SSE, and React hooks.

Overview#

@talus-network/talus-api-client is a zero-runtime-dependency TypeScript client for this API. It works in Node 18+ and modern browsers, mirrors every public route, and ships an optional React entry point.

The package is currently private (not on npm). Consume it from another project with a local path or git dependency after building:

{
  "dependencies": {
    "@talus-network/talus-api-client": "file:../nexus-api/clients/typescript"
  }
}
just ts-client build   # from the nexus-api repo root

Consumers import from dist/. Full development notes live in the client README.

Quick start#

const client = new TalusClient({
  baseUrl: process.env.TALUS_API_URL!, // https://api-testnet.taluslabs.dev or https://api.taluslabs.dev — same key on both
  apiKey: process.env.TALUS_API_KEY!, // "<key_id>.<secret>"
});

const page = await client.executions.list({ status: "finished", page_size: 50 });
console.log(page.items.length, "of", page.metadata.total_items);

const detail = await client.executions.get(page.items[0].object_id);
console.log(detail.status, detail.walk_counters.successful_walks);

Client options#

Option Default Purpose
baseUrl Required. A trailing slash is stripped.
apiKey <key_id>.<secret>. Optional so tokens.create can mint one.
authScheme "x-api-key" "authorization" sends Authorization: ApiKey … instead.
timeoutMs 30000 Per-attempt client deadline. 0 disables it.
maxRetries 2 Retries on 429 and 5xx for idempotent requests.
retryBaseDelayMs / retryMaxDelayMs 250 / 10000 Backoff bounds.
headers Extra headers on every request.
fetch global Injectable fetch.
onKeyRotationRecommended Fires when the server flags an expiring key.

Authentication#

Every request carries an API key. The way to get one is onboarding.claim, the same call the Get an API Key page makes: a contact email in, a working key out. You pick the scopes; the server stamps the rate limits and a 90-day expiry. One live key per email or Telegram handle — a repeat claim is a 409 until the first key is revoked or lapses.

// No `apiKey`: `onboarding.claim` is not behind API-key auth. Unauthenticated,
// not anonymous — the contact details it records are the gate.
const unauthenticated = new TalusClient({ baseUrl });

const claimed = await unauthenticated.onboarding.claim({
  email: "[email protected]",
  scopes: ["tools:read", "dags:read"], // bare names — the server sets the limits
  telegram: "@dev", // optional, but validated when given
});

// `claimed.token` is returned exactly once — the server stores only a hash.
const client = unauthenticated.withApiKey(claimed.token);

The nine read scopes are executions:read, dags:read, tools:read, tasks:read, events:read, payments:read, agents:read, leaders:read, and priority_fee_vault:read. Note /registry/default-agent requires agents:read, and /registry/leader requires leaders:read.

tokens.create is the other issuance path, for operators: it exchanges an Ed25519-signed payment token (issued from the deployment's signer, just infra-payment-token-issue …) for a key whose limits the caller chooses. Scopes there must carry limits, <scope>:<capacity>,<refill_per_second>; formatScope and allScopesAt build them. Without such a token the call returns 402.

The key can read its own request counters. There is no key id parameter: the server takes it from the credential, so one key cannot read another's usage. Counters are hourly rollups flushed in the background, so the newest bucket lags real time by up to one flush interval.

const usage = await client.tokens.usage({ granularity: "day", group_by: "route" });

Revoking acts on the calling key only and is idempotent:

await client.tokens.revoke(); // revokes the calling key only

Resource namespaces#

Every list method has an *All twin that follows metadata.next_token for you.

Namespace Scope Methods
client.executions executions:read list, get, events, walks, walkEvents, verdicts, failures, gas, submissionFailures, paymentLedger, payment (+ *All)
client.dags dags:read list, get, structure (+ listAll)
client.tools tools:read list, get (+ listAll)
client.tasks tasks:read list, get, occurrences, executions, reserve (+ *All)
client.payments payments:read list, get (+ listAll)
client.agents agents:read list, get, skills, skill, skillRevisions, executions, tasks, default (+ *All)
client.leaders leaders:read list, get, stakeEvents, suspensionSkips, registry (+ *All)
client.priorityFeeVault priority_fee_vault:read events (+ eventsAll)
client.events events:read list, listAll, stream, subscribe
client.tokens payment token / any key create, revoke, usage
client.onboarding none claim
client.openapi() none Live OpenAPI 3.1 document
const page = await client.tasks.list({ controller, page_size: 100 });
page.metadata.next_token; // number | null — null means last page

for await (const task of client.tasks.listAll({ controller })) {
  console.log(task.object_id);
}


const some = await collect(client.tasks.listAll(), { maxItems: 500 });

page_size defaults to 20 and is clamped server-side to 1..=100. Pass maxPages / maxItems (and signal) to bound iterators.

Event history (/events)#

client.events.list() is the durable half of the feed: the same decoded events as the stream, newest first, with the checkpoint timestamp (onchain_at) an SSE frame has nowhere to carry. Use it for the page a feed opens on; use the stream for everything after.

const page = await client.events.list({ kinds: "DAGCreated,WalkAdvanced", page_size: 50 });
page.items.forEach((event) => console.log(event.id, event.kind, event.onchain_at));

Filters: kinds (comma-separated, Event suffix optional — an unknown name is rejected with 400 rather than returning an empty page), sender, tx_digest, and created_after / created_before against onchain_at.

Chaining the two leaves no gap: take the newest id from the first page and open the stream there.

const stop = client.events.subscribe({
  lastEventId: page.items[0]?.id,
  onEvent: (event) => prepend(event),
  onError: (error) => console.error(error),
});

total_items counts rows the catalog models; a row that is in the catalog and still fails to decode is counted and skipped, so a page can be an item or two short of page_size. next_token advances by rows scanned, which is what keeps such a row from being re-served forever.

Event stream (SSE)#

EventSource cannot send x-api-key, so the client drives /events/stream through fetch with its own SSE parser, automatic reconnect, and gap-free resume via last_event_id.

for await (const event of client.events.stream({ kinds: ["WalkAdvanced", "DAGCreated"] })) {
  console.log(event.id, event.kind, event.payload);
}

Callback form, with an unsubscribe function:

const stop = client.events.subscribe({
  kinds: ["WalkAdvanced"],
  onEvent: (event) => console.log(event.kind),
  onError: (error) => console.error(error),
  onReconnect: ({ attempt, delayMs }) => console.warn(`retry ${attempt} in ${delayMs}ms`),
});

stop();

onError is required for subscribe — the callback loop cannot throw to the caller. The trailing Event suffix on kind names is optional. Reconnect uses full-jitter exponential backoff and gives up after maxReconnectAttempts. A 4xx other than 408/429 is fatal — a rejected key will never start working, so the stream throws instead of hammering the server.

For a browser-safe Next.js relay that never exposes the key, see Real-time event stream.

React#

A separate entry point; react is an optional peer dependency and never leaks into the main bundle.

function App() {
  return (
    <TalusApiProvider options={{ baseUrl, apiKey }}>
      <Executions />
    </TalusApiProvider>
  );
}

function Executions() {
  const { data, error, isLoading, refetch } = useExecutions({ status: "running" });
  const { events, status } = useEventStream({ kinds: ["WalkAdvanced"], maxBuffered: 50 });

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Failed: {String(error)}</p>;

  return (
    <>
      <button onClick={refetch}>Refresh</button>
      <p>
        stream: {status}, {events.length} buffered
      </p>
      <ul>
        {data?.items.map((e) => (
          <li key={e.object_id}>{e.object_id}</li>
        ))}
      </ul>
    </>
  );
}

Hooks: useExecutions, useExecution, useDags, useTools, useTasks, usePayments, useAgents, useLeaders, usePriorityFeeVault, useEventStream, plus useTalusQuery for anything else. In-flight requests are aborted on unmount and when params change.

Browser warning. Putting an API key in client-side code exposes it to anyone who opens DevTools. For browser apps, proxy through your own server — see the Next.js Route Handler pattern in Real-time event stream. Do not use NEXT_PUBLIC_* for the key.

Errors#

Every non-2xx response becomes a typed error carrying the server's { error_code, description } envelope.

try {
  await client.executions.get(id);
} catch (error) {
  if (error instanceof TalusRateLimitError) {
    console.warn(`retry in ${error.retryAfterSeconds}s`, error.rateLimit);
  } else if (isTalusApiError(error)) {
    console.error(error.status, error.errorCode, error.description, error.requestId);
  }
}
Class Statuses
TalusAuthError 401, 403
TalusPaymentError 402
TalusRequestError other 4xx
TalusNotFoundError 404
TalusTimeoutError 408
TalusRateLimitError 429 (carries retryAfterSeconds and rateLimit)
TalusServerError 5xx
TalusAbortError client timeout or caller abort (timedOut flag)
TalusNetworkError transport failure

Some responses bypass the server's error envelope (408 from the timeout layer, 413 from the body limit, extractor rejections, router 404/405). Those get a synthesised HTTP_* code so the shape stays uniform.

Wire-format caveats#

  • Timestamps are strings, not Date. They are RFC3339 in practice but the OpenAPI schema does not declare a format.
  • i64 money and gas fields arrive as JSON numbers (max_budget_mist, gas_budget_mist, consumed_total, *_ms, …). Values above Number.MAX_SAFE_INTEGER would lose precision.
  • Detail types are flattened. ExecutionDetail, ToolDetail, AgentDetail, and TaskDetail inline their summary fields at the top level, so they are intersection types in TypeScript.
  • Status-like fields are open unions. They autocomplete known variants but accept new server values without becoming a type error.
  • Successful responses carry no rate-limit headers; ratelimit-* only appears on a 429 rejection.