> Source: https://wexa.ai/docs/sdks-and-apis/typescript-sdk

# Using the TypeScript SDK

`@wexa-fabric/sdk` is the TypeScript client for the Wexa gateway. It has no runtime dependencies, ships ESM and CommonJS, and also runs in browsers and edge runtimes. You need a workspace URL and a credential the gateway accepts, such as an API key.

1. Install the package.

   ```bash
   npm install @wexa-fabric/sdk
   ```

2. Set your workspace URL and credential, or pass them to the constructor. Get an API key as the [Authentication](/docs/get-started/authentication#create-an-api-key) page shows, or follow the [Quickstart](/docs/get-started/quickstart).

   ```bash
   export WEXA_WORKSPACE=https://fabric.wexa.ai    # your workspace URL
   export WEXA_API_KEY=fab_sk_…                    # any bearer credential the gateway accepts
   ```

3. Construct one client. It discovers the API base on the first call.

   ```ts
   import { Fabric } from '@wexa-fabric/sdk'

   const fabric = new Fabric()   // reads the two environment variables
   ```

   Or pass them explicitly:

   ```ts
   const fabric = new Fabric({
     workspace: 'https://fabric.wexa.ai',
     apiKey: process.env.MY_KEY,
   })
   ```

4. Verify the client. A reply means the workspace URL resolves and the credential is accepted.

   ```ts
   console.log(await fabric.whoami())   // your user, role, project and grants
   ```

5. Call a tool as a typed method with an object payload, or by name with `call()`.

   ```ts
   const rows = await fabric.queryContext({
     query: 'MATCH (c:Customer) WHERE c.project_id = $project_id RETURN c.name AS name LIMIT 5',
   })
   console.log(rows.lifecycleId, rows.traceId)
   ```

6. Catch Wexa error classes for failures, and call `fabric.close()` when you are done.

## Limits

| Limit | Value |
| --- | --- |
| Node version | 18 or later. |
| `timeout` | 70 seconds. Governed tool routes cap near 60s upstream, so the server's own deadline fires first. |
| `retries` | 3 attempts in total when a call passes `retry: true`. Off by default. |
| `approvalTimeout` | 600 seconds to wait for a decision, polled every `poll` (5) seconds. |
| Server-bound arguments | `project_id`, `projectID`, `organization_id` and `executed_by` are refused before the request is built. |
| Cancellation | An abort rejects with `AbortError`, not a Wexa error class. A timeout is a `TransportError`. |

**Install, configure and construct**

### Install

Install the latest release (see the [changelog](/docs/get-started/changelog) for what each one changed). The package exports its own version as `VERSION`, asserted equal to the one in `package.json` at build time, so what the client reports is what you installed:

```ts
import { VERSION } from '@wexa-fabric/sdk'
console.log(VERSION)   // prints the installed version
```

### Configure

The client reads both values from the environment when you do not pass them.

`WEXA_WORKSPACE` is the workspace URL, not the API base. The client discovers the API base itself
from `GET /v1/connection-info`, which means the gateway decides its own address and moving it needs
no change on your side. Trailing slashes are stripped for you.

`WEXA_API_KEY` takes any bearer credential — a `fab_sk_…` API key or an OAuth 2.1 access token. See
[authentication](/docs/get-started/authentication#how-it-works) for which kind you want.

### Construct the client

The constructor takes `workspace` and `apiKey`, as in the step above. An explicitly-passed empty string falls through to the environment variable, and an environment
variable set to the empty string counts as absent — so a half-configured deployment fails the same
way as an unconfigured one rather than silently pointing at nothing.

### Constructor options

| Option | Default | What it does |
|---|---|---|
| `workspace` | `WEXA_WORKSPACE` | Workspace URL. |
| `apiKey` | `WEXA_API_KEY` | Bearer credential. |
| `timeout` | `70` | Seconds. Governed tool routes cap near 60s upstream, so 70 leaves the server's own deadline to fire first. |
| `retries` | `3` | Attempts when a call passes `retry: true`. **Total**, not extra. |
| `fetch` | global `fetch` | An injected `fetch`, for tests, a proxy agent, or connection pooling. |

### Discovery is deferred

`fabric.base` is `undefined` until the first request. A TypeScript constructor cannot `await`, so
the `/v1/connection-info` round trip is deferred to the first call and memoised — including its
rejection, so a bad workspace URL does not re-fetch on every call.

```ts
const fabric = new Fabric()
console.log(fabric.base)          // undefined
await fabric.whoami()
console.log(fabric.base)          // https://fabric.wexa.ai/v1
console.log(fabric.issuer)        // https://fabric.wexa.ai
```

This is the one structural difference from the Python client, which performs discovery inside its
constructor. Credential validation happens synchronously in both.

### Verify the install

```ts
import { Fabric } from '@wexa-fabric/sdk'

const fabric = new Fabric()
console.log(await fabric.whoami())
fabric.close()
```

That prints:

```json
{
  "user_id": "u_c10", "role": "OWNER",
  "org_id": "org_c10", "dept_id": "dept_c10", "project_id": "proj_c10",
  "grants": ["fabric:query.read", "fabric:docs.read", "fabric:ontology.write",
             "fabric:orchestrate.read", "fabric:orchestrate.write", "fabric:agent.run",
             "fabric:skill.write", "fabric:model.write"]
}
```

If that works, the workspace URL resolves, discovery succeeded, and your credential is accepted.

### Releasing the client

`close()` aborts any in-flight request and any approval poll. Calling it twice is a no-op, and the
client cannot be reused afterwards. It is also wired to `Symbol.asyncDispose`:

```ts
await using fabric = new Fabric()
// released at the end of the scope
```

Python has no equivalent, because `urlopen` closes per request and there is nothing to release.

**Call a tool**

### Calling a tool

Every tool has a typed method. Field names in the payload are the gateway's own wire names,
verbatim — there is no case conversion, because the gateway's own format is mixed.

```ts
const rows = await fabric.queryContext({
  query: 'MATCH (c:Customer) WHERE c.project_id = $project_id RETURN c.name AS name LIMIT 5',
})
```

Every result is the uniform envelope, with the correlation ids attached to the object rather than
buried in it:

```ts
console.log(rows)              // { columns: …, rows: …, row_count: 0, stats: {…}, truncated: false }
console.log(rows.lifecycleId)  // qlc_000013
console.log(rows.traceId)      // 73d1ab052c9e3371ae3b38098c4b34ac
```

`$project_id` is bound for you from the credential. Never pass it yourself — see
[below](#server-bound-arguments).

### Or call by name

`call()` takes the tool's name, and accepts either spelling. These are the same call:

```ts
await fabric.call('run_process_flow', { goal: 'reconcile' })
await fabric.call('run-process-flow', { goal: 'reconcile' })
```

An unrecognised name is sent to the wire verbatim rather than rejected, so a tool the gateway gained
after your SDK was published is still reachable without an upgrade.

### The same call on every surface

**REST API**

```bash
curl -s -X POST "$BASE/v1/query-context" \
  -H "Authorization: Bearer $WEXA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"query":"MATCH (c:Customer) WHERE c.project_id = $project_id RETURN c.name AS name LIMIT 5"}'
```

**TypeScript SDK**

```ts
const rows = await fabric.queryContext({
  query: 'MATCH (c:Customer) WHERE c.project_id = $project_id RETURN c.name AS name LIMIT 5',
})
```

**Python SDK**

```python
rows = fabric.query_context(
    query="MATCH (c:Customer) WHERE c.project_id = $project_id RETURN c.name AS name LIMIT 5"
)
```

All three produced the identical envelope against a live gateway. Per-tool arguments are on each tool's page, and
[how every tool call works](/docs/sdks-and-apis/how-every-tool-call-works) covers what every call shares.

The SDK exports every tool as a method, named as the tool's page shows it.

### Server-bound arguments

Four argument names are never yours to send: `project_id`, `projectID`, `organization_id` and
`executed_by`. The platform fills them in from your credential's scope.

The SDK refuses them **client-side**, before the request is built:

```ts
await fabric.queryContext({ query: 'RETURN 1', project_id: 'proj_other', organization_id: 'o' })
```

```text
ValidationError: ['organization_id', 'project_id'] are bound server-side from your token scope; sending them is rejected
```

The reason it is refused rather than quietly dropped is that **the gateway does not refuse it for you**. Sending a body `project_id` of another project alongside a valid `query-context` request returned `HTTP 200` and a normal envelope when it was tried against a live gateway: the handler reads only `query` and `parameters`, and binds `$project_id` from your credential's scope.

Your value was neither honoured nor rejected — it was ignored, and the call succeeded against the scope you were always pinned to rather than the one you asked for.

That is the failure the client-side guard exists to prevent, and it is the worse of the two possible
ones: a refusal is visible, a silently ignored argument is not. For a REST caller with no SDK, this
guard is the only thing standing between you and that outcome.

The Python SDK applies the same guard over the same four names. See
[server-bound arguments](/docs/get-started/core-ideas#know-what-scope-a-call-carries) for the concept.

### The non-tool methods

Five methods are not tools and are spelled out explicitly, because they map onto conventional REST
routes rather than the `POST /v1/<tool>` convention.

| Method | Route | Returns |
|---|---|---|
| `whoami()` | `GET /v1/whoami` | Your user id, role, organization, department, project and grants. |
| `approvals(status?)` | `GET /v1/approvals` | Pending or decided approvals. `status` is URL-encoded for you. |
| `approve(approvalId)` | `POST /v1/approvals/{id}/approve` | The decision result. |
| `reject(approvalId, reason)` | `POST /v1/approvals/{id}/reject` | The decision result. `reason` is required; without one the call throws `ValidationError` before any request. |
| `lifecycle(lifecycleId)` | `GET /v1/lifecycles/{id}` | The stage-by-stage record of one governed call. |

```ts
const pending = await fabric.approvals('pending')
for (const a of pending) console.log(a.id, a.tool, a.what, a.why, a.expires_at)
await fabric.approve(pending[0].id)

const record = await fabric.lifecycle(rows.lifecycleId)
```

There is a sixth explicit method, `resume(tool, resumeToken, options?)`, which exists because a
typed payload cannot simply be omitted for tools with required fields. It is covered on
[error handling](/docs/get-started/errors-and-retries#resume-an-approved-call) alongside the rest of the approval path.

### A typed result

`Result<T>` is the tool payload with `lifecycleId` and `traceId` attached. Pass a type argument when
you know the shape:

```ts
type Row = { name: string }
const rows = await fabric.queryContext<{ rows: Row[]; row_count: number }>({ query })
```

Payload types are exported with the methods — `QueryContextPayload`, `SaveContextPayload`,
`CreateOntologyPayload` and the rest — as are `WhoAmI`, `Approval`, `FabricOptions`, `CallOptions`
and `ToolCallOptions`.

**Call options, approvals and cancellation**

### Call options

Every tool method takes a second argument.

| Option | Default | Effect |
|---|---|---|
| `retry` | `false` | Retry `UpstreamError` and `QuotaExceeded`, up to the client's `retries`. |
| `waitForApproval` | `false` | Block and poll until a person decides, then resume automatically. |
| `approvalTimeout` | `600` | Seconds to wait for that decision. |
| `poll` | `5` | Seconds between approval checks. |
| `resumeToken` | — | Resume an approved call. Takes no payload. |
| `timeout` | client's `70` | Seconds, for this call only. |
| `signal` | — | An `AbortSignal` that cancels this call and any approval poll it is waiting on. |

```ts
await fabric.queryContext({ query }, { retry: true })              // safe: it is a read
await fabric.saveContext({ nodes }, { waitForApproval: true })     // blocks on a person
```

`retry` is off by default on purpose: without an idempotency key a retried `runAgent`
starts a second real run, and this SDK does not yet send one.

`waitForApproval` ends on the first final status of the approval. `approved` resumes the call. `rejected`,
`expired`, `overdue` and `consumed` throw `ApprovalError` with the status in the message (`approval rejected`)
and the approval in `raw`. An `overdue` approval can still be approved in the console; to keep waiting for it,
catch `ApprovalRequired`, keep the `resumeToken` and resume later.

### Consequential writes wait by default

**Breaking change.** Every project now holds consequential writes made with an API key or an SDK session until a person approves them, admin API keys included. Table row inserts and updates (`insertTableRows`, `updateTableRow`), table create and rename (`createTable`, `renameTable`), trigger changes (`setTriggers`) and connector actions (`runConnectorAction`) are consequential now, next to the tools that already were, such as `deleteTableRows` and `saveContext`.

They throw `ApprovalRequired` unless the gate is off. A project admin turns the gate off, or back on, in the project's settings. Actions taken by a person in the console are not held.

### Cancellation

```ts
const controller = new AbortController()
setTimeout(() => controller.abort(), 2_000)
await fabric.queryContext({ query }, { signal: controller.signal })
```

A caller-triggered abort rejects with the standard `AbortError`, **not** with a Wexa error class —
an abort is your own action rather than a failure of the call. A timeout, by contrast, is wrapped as
a `TransportError`, so the two stay distinguishable in one handler.

`close()` aborts everything in flight at once, which is what you want on shutdown.

**Python and TypeScript, and what to read next**

### One SDK ported to the other

The TypeScript and Python clients are deliberate ports of one another, not two independent libraries and not a stack — neither wraps the other, and the TypeScript package has no dependency on Python.

They share one tool table, one set of server-bound arguments, one error taxonomy, one retry policy and one response envelope, so an integration written against either reads the same way.

They are not byte-identical, and pretending otherwise would cost you a handler. The documented
differences are listed on [error handling](/docs/get-started/errors-and-retries#handle-errors-by-class) and
[Python SDK](/docs/sdks-and-apis/python-sdk).

### What's next

- [Quickstart](/docs/get-started/quickstart): get an API key and make your first call.
- [Error handling](/docs/get-started/errors-and-retries#handle-errors-by-class): the full hierarchy, and plain retry versus resume retry.
- [How every tool call works](/docs/sdks-and-apis/how-every-tool-call-works): tool names on each surface, the return shape, governance and errors.
- [Python SDK](/docs/sdks-and-apis/python-sdk): the sibling package, and where it differs.
