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

# Using the Python SDK

`wexa` is the Python client for the Wexa gateway. It has no dependencies and one calling convention: tool arguments are keyword arguments. You need a workspace URL and a credential the gateway accepts, such as an API key.

1. Install the package.

   ```bash
   pip install wexa
   ```

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 in its constructor, so build it once and reuse it.

   ```python
   from wexa import Fabric

   fabric = Fabric()                                   # reads the two environment variables
   # or
   fabric = Fabric(workspace="https://fabric.wexa.ai", api_key=my_key)
   ```

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

   ```python
   print(fabric.whoami())   # your user, role, project and grants
   ```

5. Call a tool as a method named after it, with underscores, or by name with `call()`.

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

6. Catch `WexaError` and `OSError` for failures.

## Limits

| Limit | Value |
| --- | --- |
| Python version | 3.9 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. |
| Retried errors | `UpstreamError` (so `TimeoutError_`) and `QuotaExceeded` only. |
| Server-bound arguments | `project_id`, `projectID`, `organization_id` and `executed_by` are refused before the request is built. |
| Failures to catch | `WexaError` and `OSError`: a refused connection is not a `WexaError`. |

**Install, configure and construct**

### Install

Install the latest release (see the [changelog](/docs/get-started/changelog) for what each one changed). Confirm what you actually got:

```python
import wexa
print(wexa.__version__)   # prints the installed version
```

### Configure

`WEXA_WORKSPACE` is the workspace URL, not the API base. The client discovers the API base from
`GET /v1/connection-info` itself. Trailing slashes are stripped for you.

### Construct the client

The constructor takes `workspace` and `api_key`, and reads `WEXA_WORKSPACE` and `WEXA_API_KEY` when you pass none. Trailing slashes are stripped for you.

### Constructor options

| Option | Default | What it does |
|---|---|---|
| `workspace` | `WEXA_WORKSPACE` | Workspace URL. |
| `api_key` | `WEXA_API_KEY` | Bearer credential. |
| `timeout` | `70` | Seconds. Governed tool routes cap near 60s upstream, so 70 lets the server's own deadline fire first. |
| `retries` | `3` | Attempts when a call passes `retry=True`. **Total**, not extra. |

### Discovery happens in the constructor

Unlike the TypeScript client, the Python client performs the `/v1/connection-info` round trip inside
`__init__`, so `fabric.base` is populated the moment the object exists:

```python
fabric = Fabric()
print(fabric.base)      # https://fabric.wexa.ai/v1
print(fabric.issuer)    # https://fabric.wexa.ai
```

That means constructing a client is a network operation. Construct it once and reuse it; do not
build one per request.

### Verify the install

```python
from wexa import Fabric

fabric = Fabric()
print(fabric.whoami())
```

It prints, for example:

```python
{'dept_id': 'dept_c10', 'org_id': 'org_c10', 'project_id': 'proj_c10',
 'role': 'OWNER', 'user_id': 'u_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']}
```

There is nothing to release. `urlopen` closes per request, so the client has no `close()` and needs
none.

**Call a tool**

### Calling a tool

Every tool has a method named after it, with underscores:

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

The result is a `dict` subclass carrying the correlation ids as attributes, so the envelope does not
get in your way:

```python
print(rows)                 # {'columns': None, 'rows': None, 'row_count': 0, 'stats': {...}, 'truncated': False}
print(rows.lifecycle_id)    # qlc_000016
print(rows.trace_id)        # b9d8c07461dc4ae68539dae92e497a1e
```

`$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 name and accepts either spelling. These are the same call:

```python
fabric.call("run_process_flow", goal="reconcile")
fabric.call("run-process-flow", goal="reconcile")
```

An unrecognised name is sent to the wire verbatim rather than rejected, so an older published version still reaches a tool it has no named method for, provided the name has **hyphens**: `fabric.call("connect-whatsapp", ...)`. The underscore form (`"connect_whatsapp"`) reaches `/v1/connect_whatsapp`, which does not exist, and the gateway answers 404.

Upgrading (`pip install -U wexa`) gives you the named method. A 404 on a hyphenated name means the tool is not registered in that deployment, not that the client failed to route it.

```python
fabric.call("delete-context", label="Customer", key="id", value="c-1")
```

### 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.

A method only succeeds if the gateway registered that tool. Calling an unregistered one raises
`NotFound` with `status` 404. Read the live list from `GET /v1/connection-info`.

### 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:

```python
fabric.query_context(query="RETURN 1", project_id="proj_other")
```

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

The refusal exists because **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. A silently ignored argument is worse than a refusal precisely because nothing tells you it happened.

The TypeScript 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=None)` | `GET /v1/approvals` | Pending or decided approvals, as a list. |
| `approve(approval_id)` | `POST /v1/approvals/{id}/approve` | The decision result. |
| `reject(approval_id, reason)` | `POST /v1/approvals/{id}/reject` | The decision result. `reason` is required; without one the call raises `ValidationError` before any request. |
| `lifecycle(lifecycle_id)` | `GET /v1/lifecycles/{id}` | The stage-by-stage record of one governed call. |

```python
pending = fabric.approvals("pending")
for a in pending:
    print(a["id"], a["tool"], a["what"], a["why"], a["expires_at"])
fabric.approve(pending[0]["id"])

record = fabric.lifecycle(rows.lifecycle_id)
```

**Retry, approvals and failures**

### Retry

```python
rows = fabric.query_context(query=q, retry=True)     # safe: it is a read
```

`retry` is off by default because without an idempotency key a retried `run_agent` starts
a second real run; pass `idempotency_key` and retries default on. `retries=3` on the constructor means three attempts in total.

Only `UpstreamError` — and therefore `TimeoutError_` — and `QuotaExceeded` retry:

```python
RETRYABLE = (UpstreamError, QuotaExceeded)
RESUME_RETRYABLE = (QuotaExceeded,)
```

`ConfigurationError` does **not** retry despite carrying a 5xx status: it extends `WexaError` directly rather than `UpstreamError`, because a deployment fault is not load and retrying will never fix it. The full hierarchy, both predicates and the reasoning behind the resume boundary are on [error handling](/docs/get-started/errors-and-retries#resume-an-approved-call) — it is written against TypeScript, but the taxonomy and the retry policy are the same module ported.

### Approvals

A call that gates raises `ApprovalRequired`, carrying a single-use `resume_token`.

### 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 (`insert_table_rows`, `update_table_row`), table create and rename (`create_table`, `rename_table`), trigger changes (`set_triggers`) and connector actions (`run_connector_action`) are consequential now, next to the tools that already were, such as `delete_table_rows` and `save_context`.

They raise `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.

```python
from wexa import ApprovalRequired, WexaError

try:
    fabric.save_context(nodes=nodes)
except ApprovalRequired as pending:
    checkpoint(pending.approval_id, pending.resume_token)   # durable, before you wait
```

Later, possibly from another process:

```python
try:
    fabric.call("save_context", resume_token=load_token(), retry=True)
except WexaError as e:
    if e.resume_spent:
        pass   # the approval is gone; request a fresh one rather than looping
```

The gateway redeems the token between S3 (quota) and S4 (validate), so a `429` leaves it intact and
anything later has burned it. `resume_spent` tells the two apart, and it is why `retry=True` on a
resume only ever helps a `QuotaExceeded`.

`wait_for_approval=True` blocks and polls instead, which is simpler and loses a granted approval if
the process dies mid-wait:

```python
fabric.save_context(nodes=nodes, wait_for_approval=True, approval_timeout=600, poll=5)
```

The wait ends on the first final status of the approval. `approved` resumes the call. `rejected`, `expired`,
`overdue` and `consumed` raise `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, checkpoint
the token and resume later.

### Catching failures

```python
from wexa import WexaError, PolicyDenied, ForbiddenError, QuotaExceeded

try:
    fabric.run_agent(agentflow_id="af_1", goal="summarize last week")
except PolicyDenied as e:
    ...                       # S5 — a rule refused it. Terminal.
except ForbiddenError as e:
    ...                       # S2 — mint a credential that holds the grant.
except QuotaExceeded as e:
    ...                       # S3 — back off.
except WexaError as e:
    print(e.stage, e.status, e.lifecycle_id, e.trace_id)
except OSError as e:
    ...                       # nothing reached the gateway
```

Order matters: `PolicyDenied` is a `ForbiddenError`, and `ApprovalRequired` is a `WexaError`.

The `OSError` arm is not optional. Python leaks urllib's `URLError` for a refused connection, and it
is **not** a `WexaError` — verified live against an unreachable address. The TypeScript port wraps
that case as `TransportError` precisely so one check covers everything.

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

### One SDK ported to the other

`wexa` and `@wexa-fabric/sdk` are deliberate ports of one another, not two independent libraries and not a stack. Neither wraps the other; both are thin HTTP clients for the same gateway. Python is the source of truth and TypeScript is the port, and they share one tool table, one set of server-bound arguments, one error taxonomy, one retry policy and one response envelope.

They are not byte-identical, and a handler moved between them needs four adjustments:

| | Python | TypeScript |
|---|---|---|
| Nothing reached the gateway | urllib's `URLError`, an `OSError` — **not** a `WexaError` | `TransportError`, which *is* a `WexaError` |
| The 504 class | `TimeoutError_`, underscored to dodge the builtin | `TimeoutError` |
| Retry predicates | `isinstance(e, RETRYABLE)` / `RESUME_RETRYABLE` | `isRetryable(e)` / `isResumeRetryable(e)` |
| Discovery | In `__init__` | Deferred to the first call |

Catch `WexaError` **and** `OSError` in Python. A lone `except WexaError` will not see a refused
connection, and that is the failure most likely to happen in production.

### 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 hierarchy both SDKs share, and the resume boundary.
- [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.
- [TypeScript SDK](/docs/sdks-and-apis/typescript-sdk): the sibling package.
