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.
-
Install the package.
npm install @wexa-fabric/sdk -
Set your workspace URL and credential, or pass them to the constructor. Get an API key as the Authentication page shows, or follow the Quickstart.
export WEXA_WORKSPACE=https://fabric.wexa.ai # your workspace URL export WEXA_API_KEY=fab_sk_… # any bearer credential the gateway accepts -
Construct one client. It discovers the API base on the first call.
import { Fabric } from '@wexa-fabric/sdk' const fabric = new Fabric() // reads the two environment variablesOr pass them explicitly:
const fabric = new Fabric({ workspace: 'https://fabric.wexa.ai', apiKey: process.env.MY_KEY, }) -
Verify the client. A reply means the workspace URL resolves and the credential is accepted.
console.log(await fabric.whoami()) // your user, role, project and grants -
Call a tool as a typed method with an object payload, or by name with
call().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) -
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 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:
import { VERSION } from '@wexa-fabric/sdk'
console.log(VERSION) // prints the installed versionConfigure
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 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.
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.aiThis is the one structural difference from the Python client, which performs discovery inside its constructor. Credential validation happens synchronously in both.
Verify the install
import { Fabric } from '@wexa-fabric/sdk'
const fabric = new Fabric()
console.log(await fabric.whoami())
fabric.close()That prints:
{
"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:
await using fabric = new Fabric()
// released at the end of the scopePython 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.
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:
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.
Or call by name
call() takes the tool's name, and accepts either spelling. These are the same call:
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
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"}'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 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:
await fabric.queryContext({ query: 'RETURN 1', project_id: 'proj_other', organization_id: 'o' })ValidationError: ['organization_id', 'project_id'] are bound server-side from your token scope; sending them is rejectedThe 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 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. |
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 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:
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. |
await fabric.queryContext({ query }, { retry: true }) // safe: it is a read
await fabric.saveContext({ nodes }, { waitForApproval: true }) // blocks on a personretry 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
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 and Python SDK.
What's next
- Quickstart: get an API key and make your first call.
- Error handling: the full hierarchy, and plain retry versus resume retry.
- How every tool call works: tool names on each surface, the return shape, governance and errors.
- Python SDK: the sibling package, and where it differs.