> Source: https://wexa.ai/docs/surfaces/python/usage

# Using the Python SDK

The Python client has one calling convention: **tool arguments are keyword arguments**. That is the
single biggest difference from the TypeScript port, where the payload is an object and the control
options are a separate argument.

## 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, which is what lets the
published 0.1.2 reach the tools it has no named method for.

### 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/scope-and-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=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

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

`retry` is off by default because the gateway has no idempotency key — a retried `run_agent` starts
a second real run. `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/surfaces/typescript/error-handling) — 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.

## Related

  <Card title="Install" href="/docs/surfaces/python/install">
    Package, the published-versus-source version boundary, and the client constructor.
  </Card>
  <Card title="Error handling" href="/docs/surfaces/typescript/error-handling">
    The shared hierarchy, and plain retry versus resume retry.
  </Card>
  <Card title="Tool reference" href="/docs/tools/overview">
    Arguments and returns for every tool.
  </Card>
  <Card title="Policy and approvals" href="/docs/concepts/policy-and-approvals">
    What makes a call gate in the first place.
  </Card>
