ligaxai 0.1.0.dev13__tar.gz → 0.1.0.dev15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/CHANGELOG.md +14 -0
  2. ligaxai-0.1.0.dev15/PKG-INFO +605 -0
  3. ligaxai-0.1.0.dev15/README.md +571 -0
  4. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/examples/fastapi-app/README.md +1 -1
  5. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/pyproject.toml +4 -2
  6. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/__init__.py +2 -2
  7. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_client.py +29 -8
  8. ligaxai-0.1.0.dev15/src/ligaxai/_version.py +1 -0
  9. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_client.py +39 -7
  10. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_client_tokens.py +2 -0
  11. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_end_user_connections.py +1 -1
  12. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_fastapi.py +2 -1
  13. ligaxai-0.1.0.dev15/tests/unit/test_no_deployment_hosts.py +71 -0
  14. ligaxai-0.1.0.dev15/tests/unit/test_readme.py +56 -0
  15. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_resources.py +3 -2
  16. ligaxai-0.1.0.dev13/PKG-INFO +0 -675
  17. ligaxai-0.1.0.dev13/README.md +0 -643
  18. ligaxai-0.1.0.dev13/src/ligaxai/_version.py +0 -1
  19. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/.gitignore +0 -0
  20. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/LICENSE +0 -0
  21. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/conformance/README.md +0 -0
  22. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/conformance/cases/runs-create-payment-required.json +0 -0
  23. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/conformance/cases/runs-create.json +0 -0
  24. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/conformance/cases/runs-get.json +0 -0
  25. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_base_client.py +0 -0
  26. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_exceptions.py +0 -0
  27. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_generated/__init__.py +0 -0
  28. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_generated/models.py +0 -0
  29. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_logging.py +0 -0
  30. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_models.py +0 -0
  31. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_pagination.py +0 -0
  32. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_run_stream.py +0 -0
  33. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_streaming.py +0 -0
  34. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_types.py +0 -0
  35. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/_webhooks.py +0 -0
  36. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/fastapi.py +0 -0
  37. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/py.typed +0 -0
  38. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/__init__.py +0 -0
  39. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/_base.py +0 -0
  40. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/client_tokens.py +0 -0
  41. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/connect_sessions.py +0 -0
  42. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/conversations.py +0 -0
  43. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/end_users.py +0 -0
  44. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/files.py +0 -0
  45. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/keys.py +0 -0
  46. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/profiles.py +0 -0
  47. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/runs.py +0 -0
  48. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/usage.py +0 -0
  49. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/resources/webhooks.py +0 -0
  50. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/types.py +0 -0
  51. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/src/ligaxai/webhooks.py +0 -0
  52. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/__init__.py +0 -0
  53. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/conformance/__init__.py +0 -0
  54. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/conformance/test_cases.py +0 -0
  55. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/conftest.py +0 -0
  56. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/fixtures/chat-turn-long.json +0 -0
  57. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/__init__.py +0 -0
  58. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_errors.py +0 -0
  59. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_files.py +0 -0
  60. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_logging.py +0 -0
  61. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_pagination.py +0 -0
  62. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_retries.py +0 -0
  63. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_run_stream.py +0 -0
  64. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_streaming.py +0 -0
  65. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_text_assembler.py +0 -0
  66. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_webhook_endpoints.py +0 -0
  67. {ligaxai-0.1.0.dev13 → ligaxai-0.1.0.dev15}/tests/unit/test_webhooks.py +0 -0
@@ -8,6 +8,20 @@ All notable changes to this project are documented here. The format follows
8
8
 
9
9
  ### Changed
10
10
 
11
+ - **Docs: the README is rewritten as the user guide and PyPI page.** What you need and the
12
+ words it uses come first, then install, configure (both required variables), a complete
13
+ quickstart, common tasks, web apps, webhooks, reference tables and a section for AI coding
14
+ assistants. Every link is absolute (relative links break on PyPI), the CI badge points at
15
+ this repository, and contributor material moved to `CONTRIBUTING.md`.
16
+ `tests/unit/test_readme.py` checks that the examples parse and import only real names.
17
+ PyPI now lists Documentation and Issues links.
18
+ - **Breaking: no built-in base URL.** `DEFAULT_BASE_URL` is removed. The API root comes from
19
+ `base_url=` or `LIGAX_BASE_URL` for every environment, production included; without one the
20
+ constructor raises the new `MissingBaseURLError` (`APIError` + `ValueError`) before any
21
+ request. The vendored spec's `servers` entry is the relative `/api/public/v1`, and
22
+ `scripts/sync_spec.py` rewrites it that way on each sync, so no deployment host ships in
23
+ the package or its docs.
24
+
11
25
  - CI runs on pushes to `main` and `dev` and ends in one `ci-gate` job for branch protection.
12
26
  Pull requests from logins outside `.github/CONTRIBUTORS` are closed; issues stay open.
13
27
  - Spec pinned to `public-api-spec-v0.7.0`: a profile reports `tool_access` (`read_only`,
@@ -0,0 +1,605 @@
1
+ Metadata-Version: 2.5
2
+ Name: ligaxai
3
+ Version: 0.1.0.dev15
4
+ Summary: Official Python client for the LigaX Agent API: run your LigaX AI agents from Python.
5
+ Project-URL: Homepage, https://github.com/ligaxai/ligaxai-python
6
+ Project-URL: Documentation, https://github.com/ligaxai/ligaxai-python#readme
7
+ Project-URL: Repository, https://github.com/ligaxai/ligaxai-python
8
+ Project-URL: Issues, https://github.com/ligaxai/ligaxai-python/issues
9
+ Project-URL: Changelog, https://github.com/ligaxai/ligaxai-python/blob/main/CHANGELOG.md
10
+ Project-URL: Security, https://github.com/ligaxai/ligaxai-python/blob/main/SECURITY.md
11
+ Author: LigaX AI authors
12
+ License-Expression: Apache-2.0
13
+ License-File: LICENSE
14
+ Keywords: LigaX AI,agent api,ai agents,client,ligax,sdk
15
+ Classifier: Development Status :: 3 - Alpha
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: Apache Software License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: anyio<5,>=4
27
+ Requires-Dist: httpx2<3,>=2.16
28
+ Requires-Dist: pydantic<3,>=2.7
29
+ Requires-Dist: standardwebhooks<2,>=1.0
30
+ Requires-Dist: typing-extensions>=4.10
31
+ Provides-Extra: fastapi
32
+ Requires-Dist: fastapi<1,>=0.115; extra == 'fastapi'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # LigaX Python SDK
36
+
37
+ [![CI](https://github.com/ligaxai/ligaxai-python/actions/workflows/ci.yml/badge.svg)](https://github.com/ligaxai/ligaxai-python/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/ligaxai.svg)](https://pypi.org/project/ligaxai/)
39
+ [![Python](https://img.shields.io/pypi/pyversions/ligaxai.svg)](https://pypi.org/project/ligaxai/)
40
+
41
+ Run the AI agents you build in LigaX from your own Python code.
42
+
43
+ `ligaxai` is the official Python client for the LigaX Agent API. It has a sync client
44
+ (`LigaX`) and an async client (`AsyncLigaX`) with the same methods, typed results, safe
45
+ automatic retries, and streaming that can resume after a dropped connection.
46
+
47
+ **Contents:**
48
+ [What you need](https://github.com/ligaxai/ligaxai-python#what-you-need) ·
49
+ [Install](https://github.com/ligaxai/ligaxai-python#install) ·
50
+ [Configure](https://github.com/ligaxai/ligaxai-python#configure) ·
51
+ [Quickstart](https://github.com/ligaxai/ligaxai-python#quickstart) ·
52
+ [Common tasks](https://github.com/ligaxai/ligaxai-python#common-tasks) ·
53
+ [Web apps](https://github.com/ligaxai/ligaxai-python#web-apps) ·
54
+ [Webhooks](https://github.com/ligaxai/ligaxai-python#webhooks) ·
55
+ [Configuration reference](https://github.com/ligaxai/ligaxai-python#configuration-reference) ·
56
+ [Errors](https://github.com/ligaxai/ligaxai-python#errors) ·
57
+ [Retries and timeouts](https://github.com/ligaxai/ligaxai-python#retries-and-timeouts) ·
58
+ [Security](https://github.com/ligaxai/ligaxai-python#security) ·
59
+ [API reference](https://github.com/ligaxai/ligaxai-python#api-reference) ·
60
+ [For AI coding assistants](https://github.com/ligaxai/ligaxai-python#for-ai-coding-assistants)
61
+
62
+ ## What you need
63
+
64
+ Get these four things from your LigaX workspace before you write any code:
65
+
66
+ | You need | Looks like | Where to get it |
67
+ | --- | --- | --- |
68
+ | **API base URL** | `https://<your-ligax-host>/api/public/v1` | Your LigaX administrator. It is the address of *your* LigaX platform. |
69
+ | **Secret API key** | `lgx_sk_…` | LigaX → **Settings → Developers → API keys**. Keep it on your server. |
70
+ | **Agent profile ID** | `prf_…` | LigaX → **Settings → Developers**. A profile is a published agent your key may run. |
71
+ | **Python** | 3.11 or newer | |
72
+
73
+ Words used on this page:
74
+
75
+ - **Agent profile** (`prf_…`): an agent you built in LigaX and published for API use.
76
+ - **Run**: one request to an agent. You send `input` text and get back `output.text`.
77
+ - **Conversation** (`conv_…`): a thread of runs. Pass its id to continue where you left off.
78
+ - **End user**: a person in *your* app that a run acts for, named by your own user id.
79
+ - **Approval**: a run can stop with status `requires_action` to ask before it acts. You
80
+ approve or reject it.
81
+
82
+ ## Install
83
+
84
+ ```bash
85
+ pip install ligaxai
86
+ ```
87
+
88
+ Add the FastAPI helpers with `pip install "ligaxai[fastapi]"`. With uv: `uv add ligaxai`.
89
+
90
+ ## Configure
91
+
92
+ Set two environment variables. Both are required.
93
+
94
+ | Variable | Required | Value |
95
+ | --- | --- | --- |
96
+ | `LIGAX_BASE_URL` | yes | Your platform's API root, for example `https://<your-ligax-host>/api/public/v1` |
97
+ | `LIGAX_API_KEY` | yes | Your secret API key, `lgx_sk_…` |
98
+
99
+ ```bash
100
+ export LIGAX_BASE_URL="https://<your-ligax-host>/api/public/v1"
101
+ export LIGAX_API_KEY="lgx_sk_..."
102
+ ```
103
+
104
+ `<your-ligax-host>` is a placeholder: replace it with your platform's host. The SDK has **no
105
+ built-in address**, for any environment. If the base URL is missing, creating the client
106
+ raises `MissingBaseURLError`. If the key is missing, it raises `MissingAPIKeyError`. Both
107
+ happen before any network call.
108
+
109
+ You can also pass both values in code: `LigaX(base_url="...", api_key="...")`. An argument
110
+ wins over the environment variable.
111
+
112
+ ## Quickstart
113
+
114
+ Save this as `hello.py` and run `python hello.py`:
115
+
116
+ ```python
117
+ from ligaxai import LigaX
118
+
119
+ client = LigaX() # reads LIGAX_BASE_URL and LIGAX_API_KEY
120
+
121
+ run = client.runs.wait(
122
+ profile_id="prf_...", # your agent profile ID
123
+ input="Summarise yesterday's P1 incidents.",
124
+ )
125
+
126
+ if run.status == "completed":
127
+ print(run.output.text)
128
+ else:
129
+ print("Run ended with status:", run.status, run.error)
130
+ ```
131
+
132
+ It prints the agent's answer. `runs.wait` sends the request and waits until the run
133
+ finishes or needs your approval.
134
+
135
+ To check your setup first, call `client.keys.self()`. It returns your key's name, scopes
136
+ and spend limit. `client.profiles.list()` returns the profiles your key may run.
137
+
138
+ ## Common tasks
139
+
140
+ ### Stream the answer as it is written
141
+
142
+ ```python
143
+ from ligaxai import LigaX
144
+
145
+ client = LigaX()
146
+
147
+ with client.runs.stream(profile_id="prf_...", input="Draft a reply to ticket 4411.") as stream:
148
+ for text in stream.text_stream():
149
+ print(text, end="", flush=True)
150
+
151
+ print()
152
+ print(stream.status, stream.run_id, stream.conversation_id)
153
+ ```
154
+
155
+ `text_stream()` yields only the new text each time. To see every event (tool calls,
156
+ sources, artifacts), iterate `stream` itself: each item is a `RunEvent` with `type`, `seq`,
157
+ `payload` and `run_id`. `stream.final_text()` returns the whole answer.
158
+
159
+ If the connection drops, resume from the last event you received. Nothing is repeated:
160
+
161
+ ```python
162
+ resumed = client.runs.follow_events(stream.run_id, after=stream.last_seq)
163
+ ```
164
+
165
+ ### Use async
166
+
167
+ `AsyncLigaX` has the same methods. Await them, and use `async with` for streams:
168
+
169
+ ```python
170
+ import asyncio
171
+
172
+ from ligaxai import AsyncLigaX
173
+
174
+
175
+ async def main() -> None:
176
+ async with AsyncLigaX() as client:
177
+ run = await client.runs.wait(profile_id="prf_...", input="Hello")
178
+ print(run.output.text)
179
+
180
+ async with client.runs.stream(profile_id="prf_...", input="And again") as stream:
181
+ async for text in stream.text_stream():
182
+ print(text, end="", flush=True)
183
+
184
+
185
+ asyncio.run(main())
186
+ ```
187
+
188
+ ### Continue a conversation
189
+
190
+ Every run belongs to a conversation. Pass `conversation_id` to continue it:
191
+
192
+ ```python
193
+ first = client.runs.wait(profile_id="prf_...", input="Open a ticket for the VPN outage")
194
+ follow_up = client.runs.wait(
195
+ profile_id="prf_...",
196
+ input="Make it priority 2",
197
+ conversation_id=first.conversation_id,
198
+ )
199
+ ```
200
+
201
+ ### Act for one of your users
202
+
203
+ Pass `end_user` with your own user id. The run then uses that user's connected accounts
204
+ and conversations, and only that user can continue them:
205
+
206
+ ```python
207
+ run = client.runs.wait(profile_id="prf_...", input="What is on my calendar?", end_user="user-42")
208
+ ```
209
+
210
+ Take the user id from your own login session, never from the browser's request.
211
+
212
+ ### Approve or reject an action
213
+
214
+ An agent can stop and ask before it acts, for example before it sends an email. The run
215
+ then has status `requires_action`:
216
+
217
+ ```python
218
+ run = client.runs.wait(profile_id="prf_...", input="Email the team about the outage")
219
+
220
+ if run.status == "requires_action" and run.required_action:
221
+ print("The agent asks:", run.required_action.prompt)
222
+ run = client.runs.resolve_interrupt(
223
+ run.id, run.required_action.interrupt_id, decision="approve" # or "reject"
224
+ )
225
+ ```
226
+
227
+ ### Send a file
228
+
229
+ Upload a file, then attach its id to a run. The key needs the `files:write` scope:
230
+
231
+ ```python
232
+ f = client.files.create(file="contract.pdf") # a path, bytes, or a file opened in "rb" mode
233
+ run = client.runs.wait(
234
+ profile_id="prf_...",
235
+ input="Summarise the termination clause",
236
+ attachments=[f.id],
237
+ )
238
+ ```
239
+
240
+ - Up to 25 MB per file and 10 files per run. A larger file raises `ValueError` before upload.
241
+ - Files are kept for 30 days. `client.files.delete(f.id)` removes one early.
242
+ - `files.get(id)` returns metadata only. There is no download.
243
+ - Whether a file type can be read depends on the agent's model.
244
+
245
+ ### Check a run later
246
+
247
+ Start a run without waiting, then read it:
248
+
249
+ ```python
250
+ run = client.runs.create(profile_id="prf_...", input="Prepare the weekly report", mode="async")
251
+ run = client.runs.get(run.id) # status: queued, running, completed, ...
252
+ client.runs.cancel(run.id) # stop it if you no longer need it
253
+ ```
254
+
255
+ A run's `status` is one of `queued`, `running`, `requires_action`, `completed`, `failed`,
256
+ `canceled` or `expired`.
257
+
258
+ ## Web apps
259
+
260
+ ### Stream to a browser with FastAPI
261
+
262
+ Keep the secret key on your server. The browser talks to your route, and your route talks
263
+ to LigaX:
264
+
265
+ ```python
266
+ from fastapi import FastAPI, Request
267
+ from ligaxai.fastapi import client_dependency, run_stream_route
268
+
269
+ app = FastAPI()
270
+ get_client = client_dependency() # one AsyncLigaX per process, from the environment
271
+
272
+
273
+ def current_user(request: Request) -> str:
274
+ return request.state.user_id # from YOUR login session
275
+
276
+
277
+ app.post("/chat")(run_stream_route(get_client, "prf_...", current_user))
278
+ ```
279
+
280
+ The browser sends `POST /chat` with `{"input": "…", "conversation_id": "conv_…"}` and reads
281
+ a `text/event-stream`. The profile is fixed on the server and the user comes from your
282
+ session, so one person cannot read or continue another's conversation. Without a signed-in
283
+ user the route answers `401`. A complete app is in
284
+ [examples/fastapi-app](https://github.com/ligaxai/ligaxai-python/tree/main/examples/fastapi-app).
285
+
286
+ ### Let a browser call LigaX directly (client tokens)
287
+
288
+ Your server can mint a short-lived **client token** (`lgx_ct_…`) for the signed-in user.
289
+ The browser uses it instead of your secret key:
290
+
291
+ ```python
292
+ token = client.client_tokens.create(
293
+ profile_id="prf_...",
294
+ end_user={"id": "user-42", "name": "Ada"}, # or just "user-42"
295
+ expires_in=900, # seconds, 60 to 3600; default 600
296
+ )
297
+ # Send token.token and token.expires_at to that user's browser. Never log the token.
298
+ ```
299
+
300
+ A client token runs one profile, as one end user, for a few minutes. It cannot manage
301
+ keys, webhooks or usage. Two settings must be in place:
302
+
303
+ - the secret key that mints it needs the `client_tokens:write` scope;
304
+ - the profile must list your website's origin (for example `https://app.example.com`)
305
+ under **allowed origins** in Settings → Developers.
306
+
307
+ With FastAPI, `client_token_route` is that endpoint:
308
+
309
+ ```python
310
+ from fastapi import FastAPI, Request
311
+ from ligaxai.fastapi import client_dependency, client_token_route
312
+
313
+ app = FastAPI()
314
+ get_client = client_dependency()
315
+
316
+
317
+ def current_user(request: Request) -> tuple[str, str] | None:
318
+ user = request.state.user # from YOUR login session
319
+ return (user.id, user.name) if user else None # None answers 401
320
+
321
+
322
+ app.post("/ligax/token")(client_token_route(get_client, "prf_...", current_user))
323
+ ```
324
+
325
+ It answers `{"token": "lgx_ct_…", "expires_at": "…"}` with `Cache-Control: no-store`. In
326
+ Django or Flask, write the same three steps in your own view: require a signed-in user,
327
+ call `client.client_tokens.create(...)`, return `token` and `expires_at`.
328
+
329
+ ### Let your users connect their own accounts
330
+
331
+ By default an agent uses the workspace's shared connections. If the profile allows it
332
+ (*End users can connect their own accounts*, on the agent's **API & Integrations** tab),
333
+ each of your users can connect their own Gmail, Outlook and so on. Runs with that
334
+ `end_user` then use the user's own account.
335
+
336
+ Your server starts a **connect session** (scope `connections:write`) and gives the
337
+ browser its `connect_url`:
338
+
339
+ ```python
340
+ session = client.connect_sessions.create(
341
+ connector_key="gmail",
342
+ profile_id="prf_...",
343
+ end_user="user-42",
344
+ )
345
+ # In the browser, open session.connect_url in a popup from a click handler.
346
+ ```
347
+
348
+ Then wait until the user finishes. `denied`, `failed` and `expired` are results, not
349
+ errors. `wait` raises `TimeoutError` if the session is still pending after `timeout`
350
+ seconds:
351
+
352
+ ```python
353
+ session = client.connect_sessions.wait(session.id, end_user_id="user-42", timeout=300)
354
+ if session.status == "succeeded":
355
+ print("Connected:", session.connection_id)
356
+ ```
357
+
358
+ List or remove a user's own accounts (never the shared ones):
359
+
360
+ ```python
361
+ for conn in client.end_users.connections.list("user-42").data:
362
+ print(conn.id, conn.connector_key, conn.status)
363
+ client.end_users.connections.delete("user-42", conn.id)
364
+ ```
365
+
366
+ ## Webhooks
367
+
368
+ LigaX can call your server when something happens, so you don't have to poll. Managing
369
+ endpoints needs the `webhooks:manage` scope.
370
+
371
+ | Event | Sent when |
372
+ | --- | --- |
373
+ | `run.completed`, `run.failed`, `run.canceled` | a run ends |
374
+ | `run.requires_action` | a run waits for your approval |
375
+ | `api_key.limit_reached` | a key reaches its spend limit |
376
+ | `connection.created`, `connection.deleted` | an end user connects or removes an account |
377
+ | `webhook.test` | you call `webhooks.test` |
378
+
379
+ **1. Create an endpoint and store its secret.** The secret (`whsec_…`) is returned only
380
+ once:
381
+
382
+ ```python
383
+ endpoint = client.webhooks.create(
384
+ url="https://example.com/hooks/ligax",
385
+ event_types=["run.completed", "run.failed", "run.requires_action"],
386
+ profile_ids=["prf_..."], # optional: only these profiles' runs
387
+ )
388
+ print(endpoint.id, endpoint.secret) # store the secret in your secret manager
389
+ client.webhooks.test(endpoint.id) # sends a webhook.test event now
390
+ ```
391
+
392
+ **2. Verify every delivery** against the raw request body. `webhooks.verify` checks the
393
+ signature and the timestamp, then returns a typed event:
394
+
395
+ ```python
396
+ from ligaxai import WebhookVerificationError, webhooks
397
+ from ligaxai.types import RunWebhookEvent
398
+
399
+
400
+ def handle(raw_body: bytes, headers: dict[str, str], secret: str) -> int:
401
+ try:
402
+ event = webhooks.verify(raw_body, headers, secret=secret)
403
+ except WebhookVerificationError:
404
+ return 400
405
+ if isinstance(event, RunWebhookEvent) and event.type == "run.completed":
406
+ run = client.runs.get(event.data.id) # the event holds ids, not the answer
407
+ print(run.output.text)
408
+ return 204
409
+ ```
410
+
411
+ In FastAPI, `ligaxai.fastapi.webhook_dependency(get_secret)` does the reading and
412
+ verifying for you and answers `400` on a bad signature.
413
+
414
+ **3. Follow these rules:**
415
+
416
+ - **Use the raw bytes.** Re-encoding parsed JSON changes what was signed.
417
+ - **Answer 2xx within 15 seconds.** Do slow work in the background. Failed deliveries are
418
+ retried for about 27 hours; an endpoint that keeps failing is disabled.
419
+ - **Skip duplicates.** A retry has the same `webhook-id` header. Remember the ids you handled.
420
+ - **Fetch the content.** Events carry ids and a status only. Read the run with `runs.get`.
421
+
422
+ Deliveries follow [Standard Webhooks](https://www.standardwebhooks.com). For Zapier, Make
423
+ or n8n, map *subscribe* to `webhooks.create` and *unsubscribe* to `webhooks.delete`.
424
+
425
+ ## Use an agent from MCP or A2A clients
426
+
427
+ Every published agent profile is also an MCP server and an A2A agent, so Claude Code,
428
+ Cursor or another agent can call it without this SDK. Use a secret key with the **Start
429
+ runs** and **Read runs** scopes:
430
+
431
+ ```bash
432
+ claude mcp add --transport http ligax-support \
433
+ https://<your-ligax-host>/api/public/v1/mcp/prf_... \
434
+ --header "Authorization: Bearer $LIGAX_API_KEY"
435
+ ```
436
+
437
+ - MCP endpoint: `https://<your-ligax-host>/api/public/v1/mcp/prf_...` (tools `ask`,
438
+ `get_run`, `respond`).
439
+ - A2A agent card: `https://<your-ligax-host>/api/public/v1/a2a/prf_.../.well-known/agent-card.json`
440
+ (A2A 1.0 and 0.3; send the same `Authorization` header).
441
+ - Add the header `X-LigaX-End-User: <your user id>` to act for one of your users.
442
+
443
+ ## Configuration reference
444
+
445
+ | Setting | Argument | Environment variable | Default |
446
+ | --- | --- | --- | --- |
447
+ | API base URL | `base_url=` | `LIGAX_BASE_URL` | none, **required** |
448
+ | API key | `api_key=` | `LIGAX_API_KEY` | none, **required** |
449
+ | Timeout | `timeout=` | | 600 s per request, 5 s to connect |
450
+ | Retries | `max_retries=` | | `2` |
451
+ | Extra headers | `default_headers=` | | none |
452
+ | HTTP client | `http_client=` | | the SDK's own `httpx2.Client` |
453
+ | Logging | | `LIGAX_LOG` | `off` |
454
+
455
+ Change options for a few calls with `client.with_options(max_retries=0, timeout=10)`. It
456
+ returns a copy that shares the connection pool. Any single call also accepts
457
+ `options=RequestOptions(timeout=..., max_retries=..., headers=..., idempotency_key=...)`.
458
+
459
+ `LIGAX_LOG` takes `debug`, `info`, `warn`, `error` or `off`. Logs never show your key.
460
+
461
+ ## Errors
462
+
463
+ Every error the SDK raises is a subclass of `ligaxai.APIError`:
464
+
465
+ ```python
466
+ from ligaxai import APIConnectionError, APIStatusError, LigaX, PaymentRequiredError, RateLimitError
467
+
468
+ client = LigaX()
469
+
470
+ try:
471
+ client.runs.create(profile_id="prf_...", input="Hi")
472
+ except PaymentRequiredError as e: # the key or workspace reached its spend limit
473
+ print(e.code, e.response.headers.get("x-limit-resets-at"))
474
+ except RateLimitError as e: # still limited after the SDK's retries
475
+ print(e.status_code, e.request_id)
476
+ except APIStatusError as e: # any other 4xx or 5xx answer
477
+ print(e.status_code, e.code, e.message, e.request_id)
478
+ except APIConnectionError: # network problem or timeout (APITimeoutError)
479
+ raise
480
+ ```
481
+
482
+ | HTTP status | Exception |
483
+ | --- | --- |
484
+ | 400 | `BadRequestError` |
485
+ | 401 | `AuthenticationError` |
486
+ | 402 | `PaymentRequiredError` |
487
+ | 403 | `PermissionDeniedError` |
488
+ | 404 | `NotFoundError` |
489
+ | 409 | `ConflictError` |
490
+ | 422 | `UnprocessableEntityError` |
491
+ | 429 | `RateLimitError` |
492
+ | 500 and above | `InternalServerError` |
493
+ | no response | `APIConnectionError`, `APITimeoutError` |
494
+ | missing setup | `MissingBaseURLError`, `MissingAPIKeyError` (also `ValueError`) |
495
+
496
+ Each status error has `status_code`, `code`, `message`, `param` and `request_id`. Every
497
+ successful result has `request_id` too. Quote it when you contact support.
498
+
499
+ ## Retries and timeouts
500
+
501
+ - **Retried, 2 times by default:** connection errors, timeouts, and answers 408, 409, 429
502
+ and 5xx. The wait grows from 0.5 s to 8 s, and the SDK honours the server's `Retry-After`.
503
+ - **Never retried:** 402, 403 and 422, and a stream that has already started.
504
+ - **No duplicate runs:** every `POST` carries an `Idempotency-Key` that stays the same
505
+ across retries. Pass your own `idempotency_key=` to make a retry safe across processes.
506
+ - **Timeout:** 600 seconds per request, so `runs.wait` has room. The server answers a long
507
+ run after up to 240 seconds, and `runs.wait` then keeps polling for you.
508
+
509
+ ## Security
510
+
511
+ An agent run with your key acts with your workspace's connected accounts (mail, files,
512
+ tickets) and spends your credit.
513
+
514
+ - **Keep secret keys (`lgx_sk_…`) on your server.** Never in a browser, a mobile app, a
515
+ repository or a log. Give each key only the profiles and scopes it needs, and a spend limit.
516
+ - **Treat website keys (`lgx_pk_…`) as public.** Anyone can copy one from your page.
517
+ Visitors on a website key can only chat. Give it a small spend limit.
518
+ - **Take the user's identity from your own session**, never from the request body or a
519
+ header the browser sets.
520
+ - **Keep approvals on** for sending, writing and deleting. Anything the agent reads (an
521
+ email, a web page) can try to give it instructions.
522
+ - **Treat the agent's answer as untrusted input.** Never `eval` it or insert it into HTML
523
+ unescaped.
524
+ - **Verify every webhook** and skip duplicates.
525
+ - **Pin the version** in your lockfile. Releases carry PyPI provenance attestations.
526
+
527
+ Report a vulnerability to security@ligaments.ai (see
528
+ [SECURITY.md](https://github.com/ligaxai/ligaxai-python/blob/main/SECURITY.md)).
529
+
530
+ ## API reference
531
+
532
+ Every method exists on `LigaX` and `AsyncLigaX` with the same name and parameters.
533
+ Results are typed models from `ligaxai.types`, with field names exactly as in the API
534
+ (snake_case). Fields the server adds later are kept in `model.model_extra`.
535
+
536
+ | Method | API call | Returns |
537
+ | --- | --- | --- |
538
+ | `runs.create(profile_id=, input=, conversation_id=, end_user=, model=, mode="wait"\|"async", metadata=, attachments=, idempotency_key=)` | `POST /runs` | `Run` |
539
+ | `runs.wait(...)`: create, then poll until the run ends or needs approval | `POST /runs`, `GET /runs/{id}` | `Run` |
540
+ | `runs.stream(...)` | `POST /runs` (streamed) | `RunStream` |
541
+ | `runs.get(run_id)` | `GET /runs/{id}` | `Run` |
542
+ | `runs.events(run_id, after=0)` | `GET /runs/{id}/events` | `RunEventList` |
543
+ | `runs.follow_events(run_id, after=0)` | `GET /runs/{id}/events?stream=true` | `RunStream` |
544
+ | `runs.cancel(run_id)` | `POST /runs/{id}/cancel` | `Run` |
545
+ | `runs.resolve_interrupt(run_id, interrupt_id, decision="approve"\|"reject"\|"answer", payload=)` | `POST /runs/{id}/interrupts/{iid}` | `Run` |
546
+ | `conversations.get(conversation_id, end_user_id=None)` | `GET /conversations/{id}` | `Conversation` |
547
+ | `profiles.list()`, `profiles.get(profile_id)` | `GET /profiles[/{id}]` | `ProfileList`, `Profile` |
548
+ | `keys.self()` | `GET /keys/self` | `ApiKey` |
549
+ | `usage.get(start=, end=, group_by=)` | `GET /usage` | `UsageReport` |
550
+ | `files.create(file=, end_user_id=None)` | `POST /files` | `FileObject` |
551
+ | `files.get(file_id)`, `files.delete(file_id)` | `GET`, `DELETE /files/{id}` | `FileObject`, `FileDeleted` |
552
+ | `webhooks.create(url=, event_types=, description=, profile_ids=, idempotency_key=)` | `POST /webhooks` | `WebhookEndpointWithSecret` |
553
+ | `webhooks.list()`, `webhooks.get(webhook_id)` | `GET /webhooks[/{id}]` | `WebhookEndpointList`, `WebhookEndpoint` |
554
+ | `webhooks.update(webhook_id, url=, description=, event_types=, profile_ids=, status=)` | `PATCH /webhooks/{id}` | `WebhookEndpoint` |
555
+ | `webhooks.delete(webhook_id)` | `DELETE /webhooks/{id}` | `WebhookEndpointDeleted` |
556
+ | `webhooks.test(webhook_id)` | `POST /webhooks/{id}/test` | `WebhookDelivery` |
557
+ | `client_tokens.create(profile_id=, end_user=, conversation_id=, expires_in=)` | `POST /client_tokens` | `ClientToken` |
558
+ | `connect_sessions.create(connector_key=, profile_id=, end_user=)` | `POST /connect_sessions` | `ConnectSession` |
559
+ | `connect_sessions.get(session_id, end_user_id=None)` | `GET /connect_sessions/{id}` | `ConnectSession` |
560
+ | `connect_sessions.wait(session_id, end_user_id=None, poll_interval=2, timeout=600)` | `GET /connect_sessions/{id}` | `ConnectSession` |
561
+ | `end_users.connections.list(end_user_id)` | `GET /end_users/{id}/connections` | `EndUserConnectionList` |
562
+ | `end_users.connections.delete(end_user_id, connection_id)` | `DELETE /end_users/{id}/connections/{cid}` | `EndUserConnectionDeleted` |
563
+
564
+ Module-level helpers: `ligaxai.webhooks.verify` (with `verify_signature` and `parse_event`),
565
+ `ligaxai.TextAssembler` (rebuilds answer text from recorded events), and
566
+ `ligaxai.fastapi` (`client_dependency`, `run_stream_route`, `client_token_route`,
567
+ `webhook_dependency`).
568
+
569
+ ## For AI coding assistants
570
+
571
+ If you are an AI assistant writing code with this package, follow these facts exactly:
572
+
573
+ 1. Install `ligaxai`. Import from the top-level package: `from ligaxai import LigaX, AsyncLigaX`.
574
+ 2. There is **no default base URL**. The code must read `LIGAX_BASE_URL` from the
575
+ environment or pass `base_url=`. Never invent or hard-code a host. Without it the
576
+ client raises `MissingBaseURLError`.
577
+ 3. The key comes from `LIGAX_API_KEY` or `api_key=`. A secret key (`lgx_sk_…`) belongs in
578
+ server code only. For a browser, mint a client token with `client.client_tokens.create`.
579
+ 4. Create the client once and reuse it: `client = LigaX()`.
580
+ 5. The main call is `client.runs.wait(profile_id="prf_...", input="...")`. The answer is
581
+ `run.output.text` when `run.status == "completed"`. Handle `"requires_action"` with
582
+ `runs.resolve_interrupt`.
583
+ 6. Parameter and field names are snake_case, exactly as in the API (`profile_id`,
584
+ `conversation_id`, `end_user`). The async client has the same names; `await` each call.
585
+ 7. Catch `ligaxai.APIStatusError` (or a subclass from the table in
586
+ [Errors](https://github.com/ligaxai/ligaxai-python#errors)). Do not add your own retry
587
+ loop: the client already retries safely.
588
+ 8. Take `end_user` from the app's login session, never from user input.
589
+
590
+ ## Versions and support
591
+
592
+ - **Python:** 3.11, 3.12, 3.13 and 3.14, on Linux, macOS and Windows.
593
+ - **Release channels:** `pip install ligaxai` installs the latest stable release. Preview
594
+ builds from the `dev` branch are versioned like `0.1.0.dev14`; install one with
595
+ `pip install --pre ligaxai` or an exact pin. Every channel needs `LIGAX_BASE_URL`.
596
+ - **Transport:** [`httpx2`](https://pypi.org/project/httpx2/). If your app imports plain
597
+ `httpx` and you want one shared stack, call `httpx2.alias_httpx()` before any `httpx`
598
+ import.
599
+ - **Changes:** [CHANGELOG.md](https://github.com/ligaxai/ligaxai-python/blob/main/CHANGELOG.md).
600
+ - **Bugs and questions:** [GitHub issues](https://github.com/ligaxai/ligaxai-python/issues).
601
+ - **Contributing:** [CONTRIBUTING.md](https://github.com/ligaxai/ligaxai-python/blob/main/CONTRIBUTING.md).
602
+
603
+ ## License
604
+
605
+ Apache-2.0. See [LICENSE](https://github.com/ligaxai/ligaxai-python/blob/main/LICENSE).