wexa
Python client for the Wexa Fabric api-gateway. One module, stdlib only, no dependencies to conflict with yours.
Every call goes through the gateway's ten-stage governed lifecycle, so the same
things happen to a graph query and to an agent run: the credential is checked,
the grant is checked, quota is counted, arguments are validated, policy runs,
a human approves if the data is sensitive, then it executes and is recorded.
When something refuses, the error names the stage that refused it —
S2:resolve-scope, S5:policy, S8:execute — so you know whether to fix your
token, your payload, or your retry loop.
pip install wexa
pip install -e . # from a clone, for development
Requires Python 3.9+.
60-second quickstart
1. Get a key. In the console: Simple mode → Generate API key. Keys are project-scoped and shown once.
2. Put it in the environment.
export WEXA_WORKSPACE=https://fabric.wexa.ai
export WEXA_API_KEY=fab_sk_...
3. Make a call.
from wexa import Fabric
fabric = Fabric() # reads WEXA_WORKSPACE / WEXA_API_KEY
print(fabric.whoami())
{'user_id': 'usr_311', 'role': 'DEVELOPER', 'org_id': 'org_7f2a',
'dept_id': '', 'project_id': 'prj_1c94',
'grants': ['fabric:query.read', 'fabric:docs.read']}
That is the whole setup. The client discovers its endpoints from
/v1/connection-info, so the workspace URL is the only address you supply. You
can pass credentials inline instead, which is handy in a notebook:
fabric = Fabric(workspace="https://fabric.wexa.ai", api_key="fab_sk_...")
A client is bound to one project. Scope is fixed into the key when it is minted and never widens. To work across projects, make one client per key.
What to do next
WEXA_WORKSPACE=... WEXA_API_KEY=... python3 examples/status.py
status.py prints your role, which tools your grants actually cover, how close
you are to the rate limit, and any approvals waiting on you. It is the fastest
answer to "why did that call fail".
Authentication: what exactly do you send?
One HTTP header, on every call:
Authorization: Bearer <your credential>
That is all the client ever sends. Fabric(api_key=...) puts whatever string
you give it behind Bearer , so the parameter name is narrower than the truth —
any bearer credential the gateway accepts works there. The gateway names its
two options itself, at the unauthenticated /v1/connection-info endpoint:
"auth": "OAuth 2.1 (PKCE) or Bearer API key (fab_sk_…)"
| Credential | Looks like | Where it comes from | Use it for |
|---|---|---|---|
| API key | fab_sk_… |
Console → Simple mode → Generate API key | scripts, backends, CI — anything long-running |
| Access token | a JWT, three dot-separated blobs | the OAuth 2.1 (PKCE) flow, or your identity provider | apps acting on behalf of a signed-in person |
Both go in the same header and behave identically once issued. Start with an API key; it needs no flow.
A credential is a key card, not a password
It does not just prove who you are. It carries where you may go and what you may do, stamped in at the moment it was minted:
- Scope — one org, one department, one project. Printed on the card.
- Grants — the specific doors it opens, like
fabric:query.read.
Two consequences that surprise people:
- Scope never widens. A client is bound to one project for its whole life. To reach a second project, mint a second credential and build a second client. There is no "switch project" call, by design.
- Being authenticated is not being authorized. A perfectly valid credential still gets refused if it lacks the grant for the tool you called.
The failure you will actually hit
Missing grants are the most common auth error, and the message says exactly what is missing:
ForbiddenError: S2:resolve-scope — token missing required grant "fabric:ontology.write"
S2 is the scope stage, so you know the credential was accepted (S1 passed) and
the permission is what's wrong. Fix the key, not the payload.
Which grant does each call need?
Eight grants cover the twenty-five tools in this client:
| Grant | Opens |
|---|---|
fabric:query.read |
query_context, search_code, fetch_code, connector_read |
fabric:ontology.write |
save_context, create_ontology, delete_context |
fabric:docs.read |
docs |
fabric:orchestrate.read |
list_skills, list_models, get_process_flow, get_execution, knowledge_base_retrieve, agent_versions, flow_versions |
fabric:orchestrate.write |
create_process_flow, update_process_flow, knowledge_base_add, promote_flow, rollback_flow_version, rollback_agent_version |
fabric:agent.run |
run_agent, run_process_flow |
fabric:skill.write |
provision_connector |
fabric:model.write |
set_model |
fabric:model.run |
run_model |
Reads and writes are separate grants, and running something is separate from authoring it — a credential can be allowed to run a process flow without being allowed to change it.
list_models sits with list_skills because they are the same job: both tell
you the ids an agent needs before you can author a valid one. The first three
are defaulted to every role; the rest are admin defaults.
fabric:skill.write and fabric:model.write are deliberately not folded into
orchestrate.write: provisioning a connector spends credits, and changing the
organization default model re-points every agent in every project.
Three other grants exist (fabric:catalog.read, fabric:catalog.write,
fabric:codesync.write) but belong to endpoint families this client does not
wrap. You will not need them here.
To see your own, ask:
print(fabric.whoami()["grants"])
# ['fabric:query.read', 'fabric:ontology.write']
Your first real call
Reads go through query_context, which runs read-only Cypher. One rule: the
query must constrain itself to your project, or the gateway's Cypher guard
rejects it before it reaches the graph.
rows = fabric.query_context(
query="MATCH (c:Customer) WHERE c.project_id = $project_id RETURN c LIMIT 50",
)
$project_id is bound for you from the token. Do not pass project_id
yourself — see Arguments the server owns.
Needs fabric:query.read.
What goes in a payload?
Every call is fabric.<tool>(**keyword_arguments). There is no envelope to build
and no request object to construct — the keywords are the payload.
fabric.query_context(query="MATCH (n) WHERE n.project_id = $project_id RETURN n")
# └──────────── this is the whole payload ────────────┘
You supply the intent; the server supplies the identity
Some arguments are server-owned. They describe who is calling, and the gateway fills them in from your credential. Sending them yourself is refused — not ignored — because a payload that could name its own caller is a payload that could impersonate one.
| You always send | The server always fills in |
|---|---|
query, nodes, relationships, goal, topic … |
project_id, organization_id, executed_by |
# refused before it leaves your process
fabric.query_context(query="…", project_id="prj_other")
# correct — reference it as a bound parameter instead
fabric.query_context(query="MATCH (n) WHERE n.project_id = $project_id RETURN n")
$project_id is already bound for you. Think of it as a blank the server fills
in after you hand the form over. The complete list, and why the client refuses
these rather than letting the server quietly overwrite them, is in
Arguments the server owns.
Nodes: key names the identity field, it is not the identity value
This is the one shape worth reading twice, because key sounds like it holds an
id and it does not — it holds the name of the property that holds the id.
{"label": "Refund", "key": "id", "properties": {"id": "rfnd_1001", "amount": 4200}}
# │ └── the identity value
# └── the property to identify by
Read it as: "identify this Refund by its id property." That is what makes
writes idempotent — send the same node twice and Fabric updates it rather than
creating a duplicate, because it knows which field to match on. Point key at a
property that is genuinely unique, and never at something like amount.
Relationships: endpoints are objects, not strings
A relationship has to say which node it means, and a bare "rfnd_1001" is
ambiguous — two labels could each have a node with that id. So each endpoint
repeats the address in full: {label, key, value}.
{"type": "ISSUED_TO",
"from": {"label": "Refund", "key": "id", "value": "rfnd_1001"},
"to": {"label": "Customer", "key": "id", "value": "cust_8841"}}
Note value here, versus properties on a node: a node carries its data, an
endpoint only points at it.
When a payload is wrong, S4 tells you
Validation is stage 4, so a malformed payload never reaches the graph:
ValidationError: S4:validate-input … cannot unmarshal string into Go struct
field Rel.relationships.from of type contextsvc.Ref
Ref is that {label, key, value} object. This exact error means an endpoint
was passed as a string.
Common tasks
Every sample below is one continuous scenario: a refund issued to a customer. Copy any of them as-is.
Write to the context graph
fabric.save_context(
nodes=[
{"label": "Refund", "key": "id",
"properties": {"id": "rfnd_1001", "amount": 4200, "currency": "usd"}},
],
)
A node is {"label", "key", "properties"}, where key names the property that
is the node's identity — key: "id" means properties.id is the identity.
Relationship endpoints are objects, not strings. This is the single most common payload error:
fabric.save_context(
nodes=[
{"label": "Refund", "key": "id", "properties": {"id": "rfnd_1001"}},
{"label": "Customer", "key": "id", "properties": {"id": "cust_8841"}},
],
relationships=[
{"type": "ISSUED_TO",
"from": {"label": "Refund", "key": "id", "value": "rfnd_1001"},
"to": {"label": "Customer", "key": "id", "value": "cust_8841"}},
],
)
Passing "from": "rfnd_1001" gets you:
ValidationError: S4:validate-input ... cannot unmarshal string into Go struct
field Rel.relationships.from of type contextsvc.Ref
save_context needs a project in Simple/auto mode, which is the default. On
an Advanced/manual project it fails validation outright and create_ontology is
the ingestion path instead — see Project mode.
Needs fabric:ontology.write.
Run an agent or a process flow
These start real work and return without waiting for it.
run = fabric.run_agent(agentflow_id="af_1", goal="summarize last week's refunds")
flow = fabric.create_process_flow(name="nightly-reconcile")
run = fabric.run_process_flow(
process_flow_id=flow["process_flow_id"], goal="reconcile refunds",
)
status = fabric.get_execution(execution_id=...) # id from run, see below
create_process_flow returns {"process_flow_id": ..., "flow": {...}}.
run_process_flow passes the data-service response through unchanged, so read
the execution id off run rather than assuming a field name.
run_agent and run_process_flow both need fabric:agent.run — including
run_process_flow, which does not accept fabric:orchestrate.write in its
place.
Choose which model your agents use
Every agent has an llm.model, and it defaults to system_model. That is not
a model — it is a pointer to whatever the organization has chosen. If nothing
has been chosen, an agent using it fails the instant it needs to think, on zero
tokens, with No model config found.
list_models shows what you can point at.
models = fabric.list_models()
{'registry': [{'id': 'mdl_bx_nova', 'name': 'Nova Pro', 'provider': 'bedrock',
'health': 'healthy', 'default': True},
{'id': 'mdl_ol_qwen', 'name': 'Qwen 2.5', 'provider': 'ollama',
'health': 'healthy', 'default': False}],
'available': [{'model_name': 'system_model', 'label_name': 'WEXA System Model'},
{'model_name': 'My-Azure#azure#org_7f2a#gpt-4o',
'label_name': 'My Azure gpt 4o'}],
'selected': {'organization_id': 'org_7f2a',
'default_model_to_use': 'system_model',
'fallback_model_to_use': None, 'embedding_model_to_use': None}}
Check registry_error before reading this as the whole truth. When the
model registry cannot be reached the listing still succeeds — a registry blip is
not a total outage — but registry and settable are absent and only the
available composites come back. Without the check, "this organization has
three models" and "we could not reach the registry" look identical.
models = fabric.list_models()
if not wexa.is_complete_model_listing(models):
print(f"model list is incomplete: {models['registry_error']}")
run_model degrades the same way rather than failing closed: a composite keeps
working with the registry down, and a registry id fails with a reason that says
the registry could not be read — not that your id does not exist.
Two id families come back. Both are valid in llm.model:
| Family | Looks like | Use it |
|---|---|---|
registry |
mdl_bx_nova |
Prefer these. They are the AI Models registry entries, and the id resolves directly. |
available |
system_model, instance#provider#org#model |
When the organization has no registry entries. The composites exist only where the legacy import ran. |
To change one agent, edit its llm:
fabric.update_process_flow(
process_flow_id="pf_1", agent_id="agt_4",
agent={"llm": {"model": "mdl_bx_nova"}},
)
To change the organization default — every project in it:
fabric.set_model(model="mdl_bx_nova")
set_model needs an admin role and fabric:model.write. It rejects an id this
organization does not have rather than storing one that would break every agent
on its next run.
One trap if both families are in play: set_model writes the data-service
default, and a registry entry marked default outranks it for the calls that
resolve the system_model sentinel. So read registry before assuming
set_model is the whole story.
Call a model directly
run_model sends a prompt to one of the organization's models and returns the
answer. It takes any id list_models returns — a registry id or an
available composite — and model is optional: omit it to run the
organization's default.
answer = fabric.run_model(model="mdl_bx_nova", prompt="Summarize Q3 in one line.")
{'output': 'Revenue grew 12% on flat headcount.',
'model': 'mdl_bx_nova', 'source': 'requested',
'tokens': 84, 'cost_usd': 0.000252, 'latency_ms': 940}
model in the result is the model that actually ran, and source says why
it was chosen — requested, organization default or platform default. Read
them before attributing a charge: they are what makes an unexpected bill
traceable.
answer = fabric.run_model(prompt="Summarize Q3 in one line.")
if answer["source"] != "requested":
print(f"ran on {answer['model']} ({answer['source']})")
run_model needs fabric:model.run — not fabric:model.write. Calling a model
and administering the registry are different privileges, so a key that can spend
cannot reconfigure.
This is not run_agent, and it is not the OpenAI-compatible chat completions
route. That route accepts a model field and ignores it, always running the
agent's own configured model. run_model runs the model you name. Reach for
run_agent when you want an agent's skills, tools and memory; reach for
run_model when you want the model itself.
A free-text provider name such as gpt-4o is refused — a model has to be one
this organization registered, so that every call resolves to a real credential
and a permitted host. Streaming is not supported; stream=True is refused
rather than served the whole answer framed as one chunk.
A conversation, and the controls you can set
messages carries a system message and as many turns as you like, and wins over
prompt when both are set. A bare prompt is the simple form and becomes one
user message, so the two produce the same call.
answer = fabric.run_model(
model="mdl_bx_nova",
messages=[
{"role": "system", "content": "Answer in one sentence."},
{"role": "user", "content": "What changed in Q3?"},
{"role": "assistant", "content": "Revenue grew 12%."},
{"role": "user", "content": "On what headcount?"},
],
temperature=0.2,
max_tokens=256,
)
These are the controls you may set. They are an allow-list, not a pass-through:
| Field | What it does |
|---|---|
max_tokens |
Ceiling on the tokens generated. Defaults to 512. |
temperature |
0 to 2. Defaults to 0.2. |
top_p |
Nucleus sampling, 0 to 1. |
stop |
Up to four sequences that end generation. |
seed |
Best-effort determinism, where the provider supports it. |
response_format |
e.g. {"type": "json_object"}. |
An out-of-range value is refused, not clamped — you never silently get a call you did not ask for.
You may not set an endpoint, api_base, api_version, secret_ref,
api_key, provider or extra_params. Fabric fills those from the model's own
registry entry, and run_model raises ValidationError before any request is
sent. Provider pass-through in particular is administered per model on the AI
Models page and allow-listed before it reaches execution; a per-request one would
be an unvalidated path into provider routing and credential placement.
There is no tool role and no tool calling here. Call run_agent when you want
an agent's tools.
Search code
hits = fabric.search_code(query="def issue_refund", limit=20)
# fetch_code needs either a line span or a row_pk from a search hit
body = fabric.fetch_code(path="billing/refunds.py", start_line=1, end_line=80)
path alone fails validation: without row_pk you must give positive
start_line and end_line with end_line >= start_line. search_code also
takes path_prefix and lang filters.
These two return their body unwrapped, so result.lifecycle_id is None for
them and only for them. Needs fabric:query.read.
Check what you can do, and where you stand on quota
gov = fabric.docs(topic="governance")
gov["mode"] # "auto" | "manual"
gov["grants"]
gov["quota"] # project_window_used / _max, org_window_used / _max
gov["rules"] # the live governance rules, in words
docs is the only place the gateway exposes live quota. Reading it up front
lets you pace writes against the real cap instead of discovering it as a
QuotaExceeded. Needs fabric:docs.read.
Approvals
This is the part of the SDK worth reading twice.
Some calls do not return a result. They return a pending approval, because a
human has to look at them first. The SDK raises ApprovalRequired carrying
everything you need to finish the call later.
What gates
Gating is driven by the data you touch, not by the size or kind of the operation. There is no amount threshold. A call gates when:
- any graph label or argument it touches contains
pii,sensitive,confidentialorpersonal— substring match, case-insensitive; or - the asset it touches is classified restricted in OpenMetadata.
So save_context with a node label of Refund runs straight through, and the
same call with a label of CustomerPersonalData comes back as an approval. On
an auto-mode project this gates everyone, admins included.
The second gate site is narrow: a non-admin create_ontology(mode="commit"),
and only on an Advanced/manual project.
Writes are not gated for being writes. Reads are not exempt for being reads —
a query_context whose Cypher mentions a Personal label gates too.
Because the trigger is the data, handle ApprovalRequired on every governed
call, not on the one step you expect to gate. Classification is populated at
runtime, so a call that sailed through yesterday can start gating today with no
change on your side.
The complete round trip
Three actors, and they are genuinely three: your code requests, an admin decides, your code resumes. The credential that requested is the only one that can resume.
from wexa import Fabric, ApprovalRequired
fabric = Fabric()
# 1. The call gates. HTTP 202, no result — an ApprovalRequired instead.
try:
fabric.save_context(nodes=[
{"label": "CustomerPersonalData", "key": "id",
"properties": {"id": "cust_8841", "email": "ada@example.com"}},
])
except ApprovalRequired as e:
print(e.approval_id) # apr_000012
print(e.resume_token) # rtok_000012_17... single use, keep it safe
print(e.lifecycle_id) # the lifecycle that parked
token = e.resume_token
# 2. A human decides. The deciding credential must hold an admin role —
# OWNER / ORG_ADMIN / PROJECT_ADMIN. Usually it happens in the console.
admin = Fabric(api_key="fab_sk_admin_...")
[a for a in admin.approvals("pending")]
# [{'id': 'apr_000012', 'tool': 'save-context', 'what': '...',
# 'requested_by': 'usr_311', 'status': 'pending', ...}]
admin.approve("apr_000012")
A non-admin credential calling approve() gets
ForbiddenError: approver must be an admin role (HTTP 403).
An admin cannot approve their own request either. approve() compares
decided_by to requested_by and refuses a match with
ConflictError: an approval cannot be decided by the user who requested it.
Since an API key carries its creator's user id, the key that triggered a gated
call can never clear it — a second admin must. In a project with only one
admin that makes a gated call unsatisfiable, so the deployment can set
ALLOW_SELF_APPROVAL=true, which permits it and still records the requester in
decided_by so the audit ledger shows it was self-approved.
# 3. Your code resumes. Send ONLY the token — the engine substitutes the
# approved arguments, so anything else you pass is ignored.
result = fabric.save_context(resume_token=token)
print(result) # the write landed
print(result.lifecycle_id)
The resume must come from the same user and project that requested it. An admin cannot resume on your behalf, and the approved arguments win — you cannot amend a call at approval time.
Approvals expire. The default TTL is 24 hours; after that the request goes to
expired and the token is dead.
Blocking instead of handling it yourself
If your process can afford to sit and wait, opt in and the client polls for you:
result = fabric.save_context(
nodes=[{"label": "CustomerPersonalData", "key": "id", "properties": {...}}],
wait_for_approval=True,
approval_timeout=600, # seconds; raises ApprovalError past this
poll=5, # seconds between checks
)
This does the whole round trip: gate, poll approvals(), resume with the token.
A rejection or expiry raises ApprovalError.
Resuming from a durable pipeline
wait_for_approval=True blocks in-process, so a crash or a deploy while a human
is deliberating loses an approval that was actually granted. If that matters,
checkpoint the token before you block, and resume later — possibly from another
process:
try:
fabric.save_context(nodes=[...])
except ApprovalRequired as e:
checkpoint(e.approval_id, e.resume_token) # durable, before you wait
# ... minutes or hours later, a different process ...
fabric.save_context(resume_token=load_token(), retry=True)
Passing resume_token= takes exactly the same code path as
wait_for_approval, so everything below behaves identically either way.
A failed resume is not always safe to retry
The token is single-use, and the gateway redeems it partway through the lifecycle:
S2 scope → S3 quota → REDEEM TOKEN → S4 validate → S5 policy → S8 execute
A refusal before redemption leaves the token intact. A refusal after redemption
has burned it: the write did not land, and re-running needs a new human
approval. Every exception carries resume_spent to tell the two apart.
from wexa import WexaError
try:
fabric.save_context(resume_token=token, retry=True)
except WexaError as e:
if e.resume_spent:
alert_human(e.lifecycle_id) # approval consumed, write did NOT land
else:
pass # nothing consumed, safe to re-run as-is
| Refusal on resume | Stage | resume_spent |
What to do |
|---|---|---|---|
QuotaExceeded (429) |
S3, before redemption | False |
Re-send the same token |
ValidationError (400/422) |
S4, after redemption | True |
Needs a new approval |
PolicyDenied (403) |
S5, after redemption | True |
Needs a new approval |
UpstreamError (502) |
S8, after redemption | True |
Needs a new approval |
retry=True covers the resume, but only for QuotaExceeded — the one stage
that refuses before redemption. Retrying anything later would re-send a spent
token and earn a misleading ApprovalError: resume rejected: approval not found, burying the real error. Replaying a token you already spent gets you
that same message.
Reference
Methods
| Area | Methods | Grant |
|---|---|---|
| Graph read | query_context |
fabric:query.read |
| Graph write | save_context, create_ontology |
fabric:ontology.write |
| Code | search_code, fetch_code |
fabric:query.read |
| Docs | docs |
fabric:docs.read |
| Connectors | connector_read |
fabric:query.read |
| Execution | run_agent, run_process_flow |
fabric:agent.run |
| Orchestrate read | get_process_flow, get_execution, list_skills, knowledge_base_retrieve |
fabric:orchestrate.read |
| Orchestrate write | create_process_flow, update_process_flow |
fabric:orchestrate.write |
| Governance | whoami, approvals, approve, reject, lifecycle |
— |
Two more grants exist for planes this client does not wrap:
fabric:catalog.read / fabric:catalog.write (catalog endpoints) and
fabric:codesync.write (the /v1/codesync/* ingest plane).
Grants live in the token's scope claim. Missing one fails at S2 with the exact
string you need:
ForbiddenError: token missing required grant "fabric:ontology.write"
fabric.docs(topic="governance")["grants"] lists what you actually hold.
call() reaches any tool by name and accepts the gateway's own hyphenated
spelling, so these three are the same call:
fabric.run_process_flow(process_flow_id="pf_1", goal="reconcile")
fabric.call("run_process_flow", process_flow_id="pf_1", goal="reconcile")
fabric.call("run-process-flow", process_flow_id="pf_1", goal="reconcile")
Errors
Every failure is typed by the stage that refused it and carries trace_id, and
usually lifecycle_id. Quote both in a support request.
e.stage is set only when the gateway returned an S… code — S2:resolve-scope
through S8:execute. Refusals that happen outside the lifecycle (401, the
forbidden 403 from approve(), 404, 409, unconfigured) carry
stage=None, and a 401 predates the lifecycle entirely so there is no
lifecycle_id to quote either. The stage column below is the conceptual stage,
not a promise about e.stage.
from wexa import PolicyDenied, ForbiddenError, ValidationError, QuotaExceeded
try:
fabric.run_agent(agentflow_id="af_1", goal="summarize last week")
except PolicyDenied as e: # S5 — a policy rule refused it
print(e.stage, e.lifecycle_id)
except ForbiddenError: # S2 — the token lacks the grant
...
except ValidationError: # S4/S7 — the request is malformed
...
except QuotaExceeded: # S3 — over the window
...
| Exception | HTTP | Stage | Means | Do |
|---|---|---|---|---|
AuthError |
401 | S1 | invalid_token, unauthorized — key wrong, revoked or expired |
Refresh the key, then one retry |
ForbiddenError |
403 | S2 | Token lacks the grant, or scope is invalid | Mint a key with the named grant. Terminal |
PolicyDenied |
403 | S5 | A policy rule refused the call outright | Terminal. Change what you're asking for |
ValidationError |
400/422 | S4, S7 | Malformed body, bad Cypher, failed dry-run | Fix the payload. Terminal |
ApprovalRequired |
202 | S6 | A human must decide first | See Approvals |
ApprovalError |
403 | S6 | Resume token bad, expired, rejected or already spent | Needs a new approval |
QuotaExceeded |
429 | S3 | Over 120/min project or 600/min org | Back off and retry |
NotFound |
404 | — | No such approval, flow or execution in your org | Terminal |
ConflictError |
409 | — | Approval not pending, agent not ready | Re-read state, then decide |
UpstreamError |
502 | S8 | A downstream service refused or was unreachable | Retry with backoff |
TimeoutError_ |
504 | S8 | Upstream deadline exceeded | Retry with backoff |
ConfigurationError |
502/503 | — | unconfigured, unavailable — a deployment problem, not load |
Retrying will not help. Tell an operator |
Only UpstreamError (so TimeoutError_ too) and QuotaExceeded are retried by
retry=True. Everything else refuses identically forever.
Three inheritance traps, because except order decides what you catch:
PolicyDeniedis aForbiddenError. CatchPolicyDeniedfirst or a policy denial looks like a missing grant.TimeoutError_is anUpstreamError, and it is not Python's builtinTimeoutError— hence the underscore.except TimeoutError:compiles, runs and silently never matches; catchTimeoutError_(orUpstreamError).ApprovalRequiredis aWexaError. In a shared helper, catch it beforeexcept WexaErroror pending approvals get logged as failures.
WexaError only covers responses the gateway actually sent. An unreachable
workspace, DNS that does not resolve or a TLS failure never reaches the gateway
and surfaces as urllib's own OSError (URLError is a subclass). A top-level
handler needs both:
try:
fabric.query_context(query=...)
except WexaError: # the gateway refused
...
except OSError: # never got there
...
Results
A tool call returns a dict subclass with the correlation ids attached
rather than nested, so you can treat it as the payload and still trace it. The
governance methods (whoami, approvals, approve, reject, lifecycle)
return plain dicts and lists — no .lifecycle_id on those.
r = fabric.docs(topic="governance")
r["mode"]
r.lifecycle_id # None for search_code and fetch_code only
r.trace_id
The client sends a W3C traceparent on every request and reads X-Trace-Id
back, so correlation works even where telemetry is switched off.
fabric.lifecycle(lifecycle_id) returns the full per-stage record for a call:
what each of the ten stages saw and decided.
Retries
Nothing retries unless you ask. The gateway has no idempotency key, so a
retried run_agent starts a second real run.
fabric.query_context(query=..., retry=True) # safe: it's a read
retry=True uses full jitter, capped at the 60-second quota window, because
tool routes send no Retry-After and no X-RateLimit-* headers and the limiter
is per-pod — the client cannot know the real ceiling. Attempt count comes from
the constructor.
Constructor
Fabric(
workspace=None, # or WEXA_WORKSPACE
api_key=None, # or WEXA_API_KEY. Bearer JWT or API key
timeout=70, # seconds. Governed tool routes cap near 60s upstream
retries=3, # attempts when retry=True; ignored otherwise
)
Missing workspace or key raises ConfigurationError immediately, before any
network call.
The ten stages
Useful when you are reading an error, not before.
| Stage | Name | Refuses with |
|---|---|---|
| S1 | authenticate | AuthError |
| S2 | resolve-scope | ForbiddenError |
| S3 | rate-quota | QuotaExceeded |
| S4 | validate-input | ValidationError |
| S5 | policy | PolicyDenied |
| S6 | approval | ApprovalRequired, ApprovalError |
| S7 | dry-run | ValidationError |
| S8 | execute | UpstreamError, TimeoutError_ |
| S9 | post-process | — |
| S10 | record | — |
Two things about S7 that surprise people. It runs after approval, not before — so a dry-run failure on a gated call has already spent the token. And it produces a one-line human-readable summary string, not a structured before/after diff. There is no client-side dry-run switch; the stage is server-driven, and its output shows up in the lifecycle record.
Quota (S3) is a fixed 60-second window: 120 requests per minute per project, 600 per minute per org.
Edge cases
Arguments the server owns
The gateway binds these from your token scope, so the client refuses them before building the request:
project_id projectID organization_id executed_by
query_context rejects a mismatching project_id outright; the others were
silently overwritten, which is worse — your value accepted and ignored. Both
become one clear client-side ValidationError instead.
Unknown argument keys are dropped silently
A misspelled key is a 200 that wrote nothing, not an error. The two shapes worth double-checking:
# create_ontology — domain is required; it's nodes/relationships, not entities
fabric.create_ontology(
domain="risk",
nodes=[{"name": "Vendor", "label": "Vendor", "primaryKey": "vendor_id"}],
relationships=[{"name": "uses", "fromNode": "Vendor", "toNode": "Service"}],
)
# save_context — relationship endpoints are {label, key, value} objects
fabric.save_context(
nodes=[{"label": "Vendor", "key": "vendor_id", "properties": {"vendor_id": "v1"}}],
relationships=[{"type": "USES",
"from": {"label": "Vendor", "key": "vendor_id", "value": "v1"},
"to": {"label": "Service", "key": "svc_id", "value": "s1"}}],
)
Project mode decides your ingestion path
A project is in Simple/auto or Advanced/manual mode, and the mode — not your arguments — decides how ingestion works. The two halves are mutually exclusive:
| Simple / auto | Advanced / manual | |
|---|---|---|
save_context |
works | fails validation outright |
create_ontology(mode="commit") |
applies, never gated | gated for a non-admin |
| PII/sensitive gate | gates everyone, admins included | admins bypass, others gated |
Check the mode before you write anything. A pipeline that commits an
ontology under human approval and then calls save_context will spend a real
person's approval on a run that cannot finish.
Note the diagonal: on the only mode where save_context works, the ontology
gate cannot fire — so the data-classification rule is the sole gate in play,
and it attaches to calls by their data, not by their name. That is the concrete
reason to route every governed call through one shared helper that handles
ApprovalRequired.
fabric.docs(topic="governance")["mode"] # "auto" | "manual"
Why isn't my call gating?
On a bare dev stack, usually because no gate is armed. The classification rule
needs OpenMetadata configured — OM_SYNC_ENABLED=true plus a host and a
project id. The token is not checked when arming, and the host need not be
reachable: the bridge is installed before any sync runs, and the label check is
a plain substring test that needs no synced data, so a dead OM host still gates.
With no OM configured at all, the rule is inert. Combined with the ontology gate
being short-circuited in auto mode, that leaves no gate able to fire and
ApprovalRequired unreachable.
To arm it against a host that does not exist:
OM_SYNC_ENABLED=true OM_HOST=http://127.0.0.1:1 OM_SYNC_PROJECT_ID=…
That is a deployment state, not a bug in your code, and no code change on your side will alter it.
Not covered yet
- OAuth 2.1. API keys and Bearer JWTs only for now.
create-agent. The handler exists in the gateway but is not registered, so it is reachable over neither REST nor MCP. Build agents into a process flow manifest instead.- Chat completions.
/v1/agents/{id}/chat/completionsis OpenAI-shaped — point theopenaipackage at your API base. It has its own per-key rate limit that does sendRetry-After, does not support streaming, and can block for several minutes. - Catalog and code-sync endpoints, an async client, pagination helpers.
What has been verified against a running gateway
All fifteen tools now have a REST route — pr/gateway-orchestrate-rest
landed, so the earlier eight-tool gap is closed and every method on this client
is reachable.
Routed is not the same as exercised. The confirmations below cover the tools
named in them; eight methods (list_skills, the four *_process_flow methods,
get_execution, knowledge_base_retrieve, connector_read) are routed but
have not yet been driven end-to-end over REST, so treat their response shapes
as unconfirmed.
whoamianddocsreturn real scope, grant, mode and quota payloads.- The full approval round trip. A
save_contextwith an ordinary node label runs straight through; one with aCustomerPersonalDatalabel returns 202 with{approval_id, resume_token, lifecycle_id}; the request appears inapprovals();approve()succeeds from an admin credential; the resume with only the token lands the write. - Approving is gated on role AND on being a different person: a non-admin
credential gets
forbidden / "approver must be an admin role", and an admin approving their own request getsconflict / "an approval cannot be decided by the user who requested it".approval.decide()comparesdecided_bytorequested_byand refuses a match, so this IS separation of duties. An API key carries its creator's user id, which means the key that made the gated call cannot clear it. Deployments with a single admin can setALLOW_SELF_APPROVAL=trueto allow it;decided_bystill records the requester, so a self-approval stays visible in the ledger. - Resume tokens are single-use. A resume that fails after redemption reports
resume_spent = True, and replaying that token yieldsApprovalError: resume rejected: approval not found. - Typed error mapping, including
ValidationErrorcarrying a reallifecycle_id, and the S2 missing-grant message.
Still source-derived only:
create_ontology(mode="commit")gating for a non-admin on a manual project.- The 429 boundary in practice. The limits themselves are configuration: 120/min project, 600/min org, fixed 60s window.
- The response bodies of
run_process_flowandget_execution. The argument shapes above come from the handlers' input schemas, not from an observed round trip.
Development
python3 wexa.py # offline self-check of the approval flow
WEXA_WORKSPACE=... WEXA_API_KEY=... python3 examples/status.py
wexa.py's demo() covers the branches that actually have logic: the 202 →
approve → resume round trip, the resume retry boundary, tool routing, and the
server-bound argument guard.
Release files for wexa 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| wexa-0.3.0.tar.gz | 52.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| wexa-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 77.3 kB
Release files / wexa-0.3.0.tar.gz
| Download URL | wexa-0.3.0.tar.gz |
|---|---|
| Size | 52.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
893d9558aac49cefabe7547c2f2aae76f6571901c83d2cf8a94769117f1a6517
|
|
BLAKE2b-256 checksum How to use checksums |
1770cbbebe7d07595c0fbe10349c37b358b7cadd936434d68ffa72d53b82a36f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / wexa-0.3.0-py3-none-any.whl
| Download URL | wexa-0.3.0-py3-none-any.whl |
|---|---|
| Size | 25.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
00086412ddd11e2be2593c0453bb89e9eec1c77d08cc38919d3f2b4b69a69486
|
|
BLAKE2b-256 checksum How to use checksums |
fa13b34e06aecb3d8471c02dd76f3f3d82e0c246e0e7b45ff4c82c4391a3c11a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|