# Server SDK

> The TypeScript client for your backend — install, errors, paging, waiting on jobs, uploads and streams, with your organisation's API key.

`@lumavox/server` is the API from your server, with your organisation's API key: every operation a key may call, as one method each, typed from the [API reference](https://docs.lumavox.ai/reference.md). It has **no dependencies** — `fetch` and the web platform only — and runs on Node 20.3 or later, Cloudflare Workers, Deno and Bun.

```bash title="npm"
npm install @lumavox/server
```

Make a client with a key from the [console](https://app.lumavox.ai), and call:

```ts title="TypeScript"
import { createClient } from "@lumavox/server";

const api = createClient({
  apiKey: process.env.API_KEY!,
  baseUrl: "https://api.lumavox.ai",
});

const image = await api.images.generate({ prompt: "A paper boat on a rain-soaked street" });
console.log(image.status, image.image?.url);
```

## Server only

The client holds your API key, which spends your whole balance and reads every end user, so **it refuses to run in a browser or a React Native app**: bundlers that build for a browser resolve it to a module that fails the build and throws when it loads, and `createClient` checks again. There is no way round it. A device talks to agents with an [ephemeral key](https://docs.lumavox.ai/agents/end-users.md) of its own, minted on your server with `ephemeralKeys.create`.

## The client

```ts title="TypeScript"
createClient({
  apiKey,                 // an org API key; nothing is read from the environment
  baseUrl: "https://api.lumavox.ai",
  timeoutMs: 30_000,      // per attempt
  maxRetries: 2,
  fetch,                  // the runtime's own by default
});
```

Bodies and answers are the API's own JSON, field for field and in its snake_case, as the reference shows them; methods are camelCase. Credits are integers of micro-credits (`credit_limit_micros: 5_000_000` is 5 credits) and times ISO strings, as on the wire — see [Conventions](https://docs.lumavox.ai/conventions.md). Every schema is exported as a type — `GenerationInfo`, `AssetDetail`, `EndUserInfo` and the rest, and `Page` is any list's page, `{ data, next_cursor }`.

**Every method takes the SDK's own options last**: `{ timeoutMs, signal, maxRetries, requestId }`. `signal` aborts the call, its retries and its waits, and is thrown as its own reason.

| Resource | Methods |
|---|---|
| `endUsers` | `create`, `get`, `update`, `delete`, `list`, `page`, `export`; `facts.list`, `facts.add`, `facts.update`, `facts.delete` |
| `ephemeralKeys` | `limits`, `create`, `get`, `revoke`, `inspect`, `list`, `page` |
| `images`, `videos`, `audio`, `skybox`, `transcriptions` | `create`, `get`, `wait`, `generate`; `skybox.styles` |
| `generations` | `get`, `wait`, `list`, `page` |
| `templates` | `list`, `page`, `get`, `create`, `generate`, `file` |
| `tts`, `stt` | `create` |
| `voices` | `list`, `page`, `get`, `create`, `uploadUrl`, `upload`, `update`, `delete`, `resample`, `design`, `saveDesign`, `wait` |
| `assets` | `list`, `page`, `get`, `uploadUrl`, `confirm`, `upload`, `update`, `delete`, `deleteMany`, `move`, `wait`; `assets.folders`: `list`, `page`, `get`, `create`, `update`, `delete` |
| `agents` | `list`, `page`, `get`, `create`, `update`, `delete`; `agents.settings`: `get`, `update`; `agents.knowledge`, `agents.tools`: `list`, `set` |
| `tools` | `list`, `page`, `get`, `create`, `update`, `delete`, `rotateSecret`, `test`, `calls` |
| `knowledge` | `list`, `page`, `get`, `create`, `bulk`, `fromUrl`, `uploadUrl`, `fromFile`, `upload`, `update`, `delete`, `reindex`, `refresh`, `setAgents`, `search`, `categories`, `insights`, `wait` |
| `usage` | `get`, `plan` |
| the client | `health()` and `limits()`, which send no key |

**A `delete` of something already gone is done**, not an error. A delete of one thing answers `true`, or `false` when there was nothing by that id; one that reports what went with it — an end user's sessions and facts, the assets a folder moved up — answers those counts, or `null` when there was nothing. Neither throws a `NotFoundError`.

**`limits()` reads the platform's limits** as they stand now — the tools an agent may have, the size of an upload, the ceilings on an ephemeral key — each by the `key` the reference names it by. The platform's staff may change them, so read one rather than writing its number down. See [Limits](https://docs.lumavox.ai/limits.md).

```ts title="TypeScript"
const limits = await api.limits();
const perAgent = limits.find((limit) => limit.key === "limits.agent.tools");
console.log(`${perAgent?.label}: ${perAgent?.value}`);
```

## Errors

Everything thrown is a `PlatformError`, with the API's `code` to switch on — see [Errors](https://docs.lumavox.ai/errors.md):

```ts title="TypeScript"
import { InsufficientCreditsError, PlatformError, RateLimitError } from "@lumavox/server";

try {
  await api.ephemeralKeys.create({ end_user_id: "user_8842", credit_limit_micros: 5_000_000 });
} catch (error) {
  if (error instanceof InsufficientCreditsError) {
    // top up, or mint a smaller key
  } else if (error instanceof PlatformError) {
    console.error(error.status, error.code, error.message, error.requestId);
  }
  throw error;
}
```

| Class | When |
|---|---|
| `ValidationError` | 400, 422; `issues` says which fields |
| `AuthenticationError` | 401 |
| `InsufficientCreditsError` | 402 |
| `PermissionError` | 403: a scope the key lacks |
| `NotFoundError` | 404 |
| `ConflictError` | 409: a plan's maximum, an id already taken, or a change since it was read |
| `RateLimitError` | 429; `retryAfter` is in seconds |
| `ServerError` | 5xx |
| `ConnectionError` | no response; `cause` is what `fetch` threw |
| `TimeoutError` | no response within `timeoutMs`, on the last attempt |

`code` is the API's — `validation_failed`, `insufficient_credits`, `rate_limited`, … — or, when no answer of the API's came back, the SDK's own: `connection_error`, `timeout`, `cloudflare_error` (refused before it reached the API), `http_error` (an error body that was not the API's) or `invalid_response` (a success that was not JSON).

**Quote `requestId` when you report a problem**: it is the `X-Request-Id` every attempt of the call carried.

**Only what cannot happen twice is retried**, up to `maxRetries`: a GET, PUT, PATCH or DELETE after a failed connection, a timeout, 429, 502, 503 or 504; a POST after a 429 only, which is refused before any work is done — and `endUsers.create` with an `external_id`, which is idempotent. The wait doubles from 0.5 s to 8 s with jitter; `Retry-After` wins, up to a minute, and a longer one is thrown at once as a `RateLimitError`.

## Paging

**`list()` is an async iterator over every item**, reading the next page as you reach it; **`page()` is one page**, as the route answers it: `{ data, next_cursor }`. Stop early and nothing more is read.

```ts title="TypeScript"
for await (const asset of api.assets.list({ media_type: "audio" })) {
  console.log(asset.id, asset.name);
}

const first = await api.endUsers.page({ limit: 50 });
if (first.next_cursor) {
  const second = await api.endUsers.page({ limit: 50, cursor: first.next_cursor });
  console.log(second.data.length);
}
```

Every list pages the same way — by the `next_cursor` each page names, `null` on the last — so nothing is seen twice, and no list counts its rows. `list()` reads the most a route allows per page unless you set `limit`. Voices, agents, templates and a folder's level answer everything in one page; their `list()` reads that page, and would read on the day the route pages. `skybox.styles()` and `knowledge.categories()` answer their one page as the route does.

## Waiting on jobs

**`generate(body)` creates a [queued job](https://docs.lumavox.ai/jobs.md) and waits for it to finish**; `wait(id)` waits for one you made earlier. Both answer the finished job — `succeeded`, `failed` or `moderated` — and never throw for a failure: read `status` as you would from the API. A failed or moderated job is refunded.

```ts title="TypeScript"
const job = await api.videos.generate(
  { prompt: "Waves breaking at dusk", duration: 5 },
  { signal: AbortSignal.timeout(10 * 60_000) },
);
if (job.status !== "succeeded") throw new Error(job.error ?? job.status);
```

The same goes for voices, assets and knowledge entries, which are `pending` until they are `ready` or `failed`. The SDK reads the job again after 1 s, then each time half as long again, at most 10 s apart. **A wait has no deadline of its own**: pass a `signal`, or the job's own deadline on the platform ends it. Polls are not counted against your [rate limits](https://docs.lumavox.ai/errors.md#rate-limits).

`templates.create(id, body)` is the template's own submit, answering at once; `templates.generate(id, body)` waits, as `images.generate` does.

## Uploads

**`upload()` is the whole upload in one call**: it asks for a signed URL, sends the bytes there — straight to object storage, never through the API — and tells the API they arrived.

```ts title="TypeScript"
import { readFile } from "node:fs/promises";

const file = await readFile("greeting.wav");
const asset = await api.assets.upload(
  file,
  { content_type: "audio/wav", name: "Greeting" },
  { wait: true },
);

const voice = await api.voices.upload(recording, { name: "Narrator", consent: true });
const entry = await api.knowledge.upload(pdf, { filename: "returns.pdf" });
```

- **A `Blob`, an `ArrayBuffer` or a typed array**; a `Blob` says its own type. **A stream only with its size** — `{ size }` in the last argument — since a signed upload needs its length, and a stream is sent once, never retried.
- **`{ wait: true }`** answers once processing is over — the asset converted, the voice cloned, the file read and indexed — instead of at once.
- The steps are there on their own too: `uploadUrl`, then `confirm` (an asset), `create` (a voice) or `fromFile` (knowledge). See [Assets](https://docs.lumavox.ai/library/assets.md).

## Streams

**Speech, streamed**: `tts.create` with `stream: true` answers as soon as the first audio is made, with the body as a `ReadableStream` of raw PCM — 16-bit, mono, at `sampleRate`. Without `stream` it answers the whole file as an `ArrayBuffer`. Both carry what the response's headers say. See [Text to speech](https://docs.lumavox.ai/speech/text-to-speech.md).

```ts title="TypeScript"
const speech = await api.tts.create({ text: "Hello there", format: "pcm", stream: true });
console.log(speech.sampleRate, speech.model);
for await (const chunk of speech.body) {
  player.write(chunk);
}
```

**A transcript, streamed**: `stt.create(audio, { stream: true })` is an async iterator of the transcript's events as they arrive — a `segment` as each piece is cut, its `transcript` (or `error`) in order, and a `done` with the whole text. WAV or raw PCM only; without `stream`, any audio, answered whole. Up to 25 MB — for longer audio, upload it and use `transcriptions`. See [Transcription](https://docs.lumavox.ai/speech/transcription.md).

```ts title="TypeScript"
for await (const event of api.stt.create(wav, { stream: true })) {
  if (event.type === "transcript") console.log(event.index, event.text);
  if (event.type === "done") console.log("whole:", event.text);
}
```

**A stream's timeout ends at its headers**: a transcript read for minutes is not a call that timed out. Your `signal` still ends it, and leaving the loop early hangs up.

## End users and ephemeral keys

`endUsers.create({ external_id })` makes a person once and finds them every time after — `created` says which. `ephemeralKeys.create` mints their device a key with a credit limit set aside from your balance, clamping its lifetime to the tighter of the platform's maximum and your plan's — the API itself never clamps: without the SDK, or with `{ clampTtl: false }`, a lifetime past a cap is refused with 422. `ephemeralKeys.revoke(id, { settled: true })` answers once the key's final figures are in, its `settled_at` set; without it, at once. `endUsers.export(id)` reads every conversation and fact the platform holds about them — replaced facts too, until they are deleted — with a five-minute key of their own. `endUsers.facts.add(id, { text })` tells the agents what your records hold — `created` says whether it was new — and `facts.update`, `facts.list` and `facts.delete` correct, read and forget, with no key minted. See [End users](https://docs.lumavox.ai/agents/end-users.md).

## Tools

`tools.create` adds a tool to your organisation's library — your server, called with a signed `POST`, or the person's app — and answers a webhook's signing secret **once**, as `secret`; `rotateSecret` answers a new one. `agents.tools.set` turns tools on for an agent, up to the platform's limit (see [Limits](https://docs.lumavox.ai/limits.md)), and `test` sends your server one call exactly as a conversation would. `calls` reads a tool's most recent calls in your agents' conversations, and each tool carries `recent_failures`. See [Tools](https://docs.lumavox.ai/agents/tools.md).

```ts title="TypeScript"
const tool = await api.tools.create({
  name: "lookup_order",
  description: "Look an order up by the number on the customer's receipt.",
  kind: "webhook",
  url: "https://shop.example.com/tools/lookup-order",
  parameters: {
    type: "object",
    properties: { order_number: { type: "string" } },
    required: ["order_number"],
  },
  headers: { Authorization: `Bearer ${process.env.SHOP_TOKEN}` },
});
// `tool.secret` is shown this once: keep it with your server.

await api.agents.tools.set("0193b1e2-6c1d-7a4e-9f20-5d8a3c2b1e07", {
  tool_ids: [tool.id],
});

const answer = await api.tools.test(tool.id, {
  arguments: { order_number: "A-1042" },
});
console.log(answer.ok, answer.status, answer.result);

const { data } = await api.tools.calls(tool.id, { limit: 20 });
for (const call of data) console.log(call.at, call.ok, call.duration_ms, call.error);
```
