# Configuration & auth

> Environment variables, client options, API key permissions, worker identity, timeouts and transport retries for the TypeScript and Python SDKs.

Source: https://docs.getrunstate.com/configuration/

## Environment variables

Both SDKs read the same three variables. Explicit constructor options take precedence.

| Variable | Required | Default | Purpose |
| --- | --- | --- | --- |
| `RUNSTATE_API_KEY` | yes | none | API key, sent as `Authorization: Bearer <key>` on every request. |
| `RUNSTATE_SPACE_ID` | yes | none | The space (project or environment) every call is bound to. |
| `RUNSTATE_BASE_URL` | no | `http://localhost:8080` | API origin. Set it to `https://api.getrunstate.com` for the hosted service. |

If the API key or space id is missing, constructing the client throws `ConfigError` immediately, before any network call. There is no environment variable for the holder name; pass it as an option.

> **Caution**
>
> The default base URL is `http://localhost:8080`. Always set `RUNSTATE_BASE_URL` (or the `baseURL` / `base_url` option) when you use the hosted service.

## Creating a client

**TypeScript**

```ts
import { Runstate } from 'runstate-sdk';

const rs = new Runstate({
  baseURL: 'https://api.getrunstate.com',
  apiKey: process.env.RUNSTATE_API_KEY,
  spaceId: process.env.RUNSTATE_SPACE_ID,
  holder: 'research-worker-1', // stable name for this logical worker
  requestTimeoutMs: 10_000, // per attempt
  retries: 2, // transport retries after the first attempt
});
```

**Python**

```python
import os

from runstate import AsyncRunstate

rs = AsyncRunstate(
    base_url="https://api.getrunstate.com",
    api_key=os.environ["RUNSTATE_API_KEY"],
    space_id=os.environ["RUNSTATE_SPACE_ID"],
    holder="research-worker-1",  # stable name for this logical worker
    request_timeout_ms=10_000,  # per attempt
    retries=2,  # transport retries after the first attempt
)
```

Python has two clients with the same object graph: `AsyncRunstate` (async, the primary client) and `Runstate` (blocking; runs the async client on a dedicated background thread). See the [Python SDK reference](https://docs.getrunstate.com/sdk/python/#blocking-client).

| Option (TS / Python) | Default | Notes |
| --- | --- | --- |
| `apiKey` / `api_key` | `RUNSTATE_API_KEY` | Required. |
| `spaceId` / `space_id` | `RUNSTATE_SPACE_ID` | Required. |
| `baseURL` / `base_url` | `RUNSTATE_BASE_URL`, else `http://localhost:8080` | Trailing slashes are removed. |
| `holder` / `holder` | random `worker-xxxxxx` | Logical worker name, stable across restarts. |
| `requestTimeoutMs` / `request_timeout_ms` | `10000` | Deadline for **each attempt**, not the whole call. |
| `retries` / `retries` | `2` | Extra attempts after a transport failure. |
| `fetchImpl` (TS only) | global `fetch` | Test seam. |

Constructing a client makes no network calls. The TypeScript client holds no connections, and `rs.close()` exists only for symmetry. In Python, close the client when you're done: `await rs.aclose()` or `async with AsyncRunstate(...) as rs:` (blocking client: `rs.close()` or `with Runstate(...) as rs:`).

To use several spaces from one process, `rs.withSpace(spaceId)` (Python: `rs.with_space(space_id)`) returns a client bound to another space that shares the configuration and identity.

## API keys and permissions

Create and revoke keys in the console under **API keys** in a space. The secret is shown once and never stored in readable form. Organization, space and key management is console-only today; there is no public API or SDK method for it.

Each key has a set of permissions. Using coordination while agents run is separate from setting it up, so a worker key doesn't need to be able to create or resize resources:

| Permission | Allows |
| --- | --- |
| `coordination_read` | Reading runs, claims, queues and messages, tasks, task groups, events and watch, and the resources agents use: pools, quotas, budgets, barriers, work limits and timers (`run.status()`, `ticket.result()`, `budget.status()`, name lookups). |
| `coordination_write` | Runtime work: creating, cancelling and completing runs, claims, sending and receiving messages, submitting, admitting, cancelling and joining tasks, task groups, taking and returning pool units, taking quota units and reporting cooldowns, reserving, settling and voiding budget, barrier arrivals, timers. |
| `resource_config` | Setting up the space: creating queues, pools, quotas, budgets, barriers, work limits and webhook destinations (every `ensure()` that has to create), and changing space limits (`PUT /limits`). |
| `usage_read` | Usage, space limits, diagnostics (`rs.diagnostics`, census, backlog, waits, run and task views) and webhook delivery history. |

Keys created in the console start with all four. A typical least-privilege split is an admin key with all four that runs the `ensure()` calls once, and worker keys with `coordination_read` and `coordination_write`. With a worker key, `ensure()` still works for resources that already exist with the same settings: the SDK verifies the existing resource instead of creating it, and throws `FORBIDDEN` only when the resource is missing. Every operation's permission is listed in the [HTTP API reference](https://docs.getrunstate.com/api/).

A key without the permission a call needs gets `FORBIDDEN` with the message `credential lacks <permission>`.

> **Note: Keys with `resource_config` can also take quota and reserve budget**
>
> Earlier, taking quota units and reserving, settling or voiding budget required `resource_config`. Keys that have `resource_config` keep those calls, so nothing that worked before breaks; new worker keys should use `coordination_write` for them. Keys that existed when usage and diagnostics moved to `usage_read` and had `coordination_read` were given `usage_read` too.

A key can also be limited to specific spaces; using it for another space returns `FORBIDDEN`. An id from another organization returns `NOT_FOUND`, so resource ids don't leak between tenants.

## Worker identity

Every claim, delivery and pool lease records a **holder** and a **session**:

- `holder` names the logical worker. Keep it stable across restarts (for example `crawler-3` or the pod name) so the console and diagnostics show who owns what.
- The session is generated fresh every time you construct a client. Two live processes never present the same holder and session pair, even if you give them the same holder name.

## Timeouts, retries and idempotency

- **Per-attempt timeout.** `requestTimeoutMs` applies to each HTTP attempt. With the defaults (10 s, 2 retries) a call to an unreachable API can take about 30 seconds before it throws `UnavailableError`.
- **Transport retries only.** The SDKs retry network failures and timeouts, with 25 to 100 ms of jitter. They never retry a well-formed error response such as `CLAIM_HELD` or `INSUFFICIENT_BUDGET`; those surface as typed errors immediately. Waiting helpers like `claim.acquire({ wait: true })` handle specific codes on top of this, see [Errors & retries](https://docs.getrunstate.com/errors/#what-the-sdks-retry-for-you).
- **Idempotency keys.** Every non-`GET` request carries a fresh `Idempotency-Key` header, reused across the transport retries of that one call, so a retried request is never applied twice. Separate calls get separate keys: calling `rs.scopes.create()` twice creates two runs.
- **Client-side waits end locally.** Methods like `ticket.result()`, `group.wait()` and `timer.wait()` poll until their `timeoutMs` and then throw `WaitTimeoutError`. The task, group or timer keeps going on the server.
