@@ -113,43 +113,46 @@ result = await client.executions.run(
113113 workflow_id = " workflow-uuid" ,
114114 input = {" messages" : [{" role" : " user" , " content" : " Hello!" }]},
115115)
116+ print (result.run_id) # typed attribute access (responses are Pydantic models)
116117
117- # Direct LLM call
118+ # Safely retry a run without double-execution
118119result = await client.executions.run(
119- llm = {
120- " integration_name" : " openai" ,
121- " provider_id" : " openai" ,
122- " model_id" : " gpt-4o-mini" ,
123- " temperature" : 0.4 ,
124- },
125- input = {" messages" : [{" role" : " user" , " content" : " Hello!" }]},
120+ workflow_id = " workflow-uuid" ,
121+ input = {" messages" : [... ]},
122+ idempotency_key = " order-4823" , # stable key across retries
126123)
127124
128- # Get execution state
125+ # Get execution state / resume after interrupt / cancel
129126state = await client.executions.get_state(thread_id = " thread-uuid" )
130-
131- # Resume after interrupt
132- await client.executions.resume(
133- thread_id = " thread-uuid" ,
134- run_id = " run-uuid" ,
135- resume_value = " user input" ,
136- )
137-
138- # Cancel execution
127+ await client.executions.resume(thread_id = " thread-uuid" , run_id = " run-uuid" , resume_value = " user input" )
139128await client.executions.cancel(run_id = " run-uuid" , reason = " No longer needed" )
129+
130+ # Run history (workflow-runs)
131+ runs = await client.executions.list_runs(workflow_id = " workflow-uuid" , limit = 50 )
132+ async for run in client.executions.iter_runs(status = " succeeded" ): # auto-paginates
133+ print (run.run_id, run.status)
134+ detail = await client.executions.get_run(run_pk = " run-row-id" )
140135```
141136
137+ > Agentic ("direct LLM") chat moved off ` /workflows/run ` — use ` client.assistant.chat(...) ` instead.
138+
142139### SSE Streaming
143140
141+ The backend carries the event type in the SSE ` event: ` field for ` /chats/stream ` , and in the JSON
142+ ` data["type"] ` for workflow/composer/assistant streams. The SDK normalizes both, so ` event.event `
143+ always holds the logical type. Streams stop after a terminal event (` done ` /` error ` /` cancelled ` /
144+ ` interrupted ` ) and heartbeats are filtered by default.
145+
144146``` python
145147# Listen to workflow execution events
146148async for event in client.executions.listen(run_id = " run-uuid" ):
147149 if event.event == " node_update" :
148- print (f " Node { event.data[' node_id' ]} : { event.data[' status' ]} " )
149- elif event.event == " done" :
150- print (f " Completed in { event.data[' total_execution_time_ms' ]} ms " )
151- elif event.event == " error" :
152- print (f " Error: { event.data[' error_message' ]} " )
150+ print (f " Node { event.data[' node' ]} : { event.data.get(' output' )} " )
151+ elif event.event == " interrupt" :
152+ payload = event.data[" data" ] # InterruptEventData is nested under data["data"]
153+ print (f " Needs input: { payload.get(' message' )} " )
154+ elif event.is_terminal:
155+ print (f " Stream ended: { event.event} " )
153156
154157# Listen to chat list updates
155158async for event in client.chats.stream():
@@ -237,20 +240,6 @@ runs = await client.schedules.list_runs(schedule["id"])
237240stats = await client.schedules.run_stats(schedule[" id" ], days = 30 )
238241```
239242
240- ### Templates
241-
242- ``` python
243- # Browse templates
244- templates = await client.templates.list()
245-
246- # Use a template
247- result = await client.templates.use(" template-id" )
248- print (f " Created workflow: { result[' workflow' ][' id' ]} " )
249-
250- # Like a template
251- await client.templates.like(" template-id" )
252- ```
253-
254243### Deployments
255244
256245``` python
@@ -269,23 +258,48 @@ await client.deployments.deactivate("workflow-uuid")
269258
270259### Composer
271260
261+ ` llm ` is a provider config dict ({integration_name, provider_id, model_id, credential_id?}) — pass a
262+ ` ComposerLLMConfig ` or an equivalent dict.
263+
272264``` python
273- # Start a composer session
265+ from modulex.types import ComposerLLMConfig, YesNoResponse, user_input_request_from_event
266+
274267result = await client.composer.chat(
275268 message = " Add an LLM node that summarizes the input" ,
276269 workflow_id = " workflow-uuid" ,
277- llm = { " integration_name" : " anthropic" , " model_id" : " claude-sonnet-4-20250514" } ,
270+ llm = ComposerLLMConfig( integration_name = " anthropic " , provider_id = " anthropic" , model_id = " claude-sonnet-4-20250514" ) ,
278271)
279272
280- # Listen to composer events
281- async for event in client.composer.listen(result[" composer_chat_id" ], result[" run_id" ]):
282- if event.event == " workflow_change" :
283- print (f " Workflow modified: { event.data} " )
284- elif event.event == " done" :
273+ # Listen, and answer a human-in-the-loop question (HITL) when the run pauses
274+ async for event in client.composer.listen(result.composer_chat_id, result.run_id):
275+ if event.event == " user_input_request" :
276+ question = user_input_request_from_event(event.data) # typed UserInputRequest
277+ await client.composer.resume(
278+ result.composer_chat_id,
279+ request_id = question.request_id,
280+ response = YesNoResponse(answer = True ),
281+ llm = {" integration_name" : " anthropic" , " provider_id" : " anthropic" , " model_id" : " claude-sonnet-4-20250514" },
282+ ) # returns a NEW run_id — re-subscribe with listen() on it
283+ elif event.is_terminal:
285284 break
286285
287- # Save or revert changes
288- await client.composer.save(result[" composer_chat_id" ])
286+ chats = await client.composer.list(limit = 20 ) # cursor-paginated
287+ await client.composer.save(result.composer_chat_id) # or .revert(...)
288+ ```
289+
290+ ### Assistant (agentic chat)
291+
292+ Shares the HITL contract with the composer. All endpoints are available to any org member.
293+
294+ ``` python
295+ result = await client.assistant.chat(" Summarize my latest runs" , llm = ComposerLLMConfig(
296+ integration_name = " openai" , provider_id = " openai" , model_id = " gpt-4o-mini" ,
297+ ))
298+ async for event in client.assistant.listen(result.chat_id, result.run_id):
299+ if event.event == " response_chunk" :
300+ print (event.data.get(" data" , {}).get(" text" , " " ), end = " " )
301+ elif event.is_terminal:
302+ break
289303```
290304
291305### Other Resources
338352except NotFoundError:
339353 print (" Workflow not found" )
340354except RateLimitError as e:
341- print (f " Rate limited. Retry after { e.retry_after} s " )
355+ print (f " Rate limited. Retry after { e.retry_after} s (limit= { e.limit } , remaining= { e.remaining } ) " )
342356except AuthenticationError:
343357 print (" Invalid API key" )
344358except ValidationError as e:
@@ -347,13 +361,28 @@ except ModulexError as e:
347361 print (f " API error ( { e.status_code} ): { e.message} " )
348362```
349363
364+ Usage/billing denials (quota, credit, wallet) are surfaced structurally via ` BillingError ` and its
365+ subclasses, which expose ` code ` , ` layer ` , ` key ` , ` current ` , ` limit ` , and ` reason ` :
366+
367+ ``` python
368+ from modulex import BillingError, CreditExhaustedError
369+
370+ try :
371+ await client.executions.run(workflow_id = " wf" )
372+ except CreditExhaustedError as e: # 402, layer="credit"
373+ print (f " Out of credits: { e.current} / { e.limit} " )
374+ except BillingError as e: # any quota/credit/wallet denial
375+ print (f " Denied ( { e.layer} / { e.code} ): { e.reason} " )
376+ ```
377+
350378### Exception Hierarchy
351379
352380| Exception | HTTP Status | Description |
353381| -----------| -------------| -------------|
354382| ` ModulexError ` | — | Base exception |
355383| ` BadRequestError ` | 400 | Malformed request |
356384| ` AuthenticationError ` | 401 | Invalid/missing auth |
385+ | ` PaymentRequiredError ` | 402 | Payment required (billing) |
357386| ` PermissionError ` | 403 | Insufficient permissions |
358387| ` NotFoundError ` | 404 | Resource not found |
359388| ` ConflictError ` | 409 | Resource conflict |
@@ -362,12 +391,18 @@ except ModulexError as e:
362391| ` InternalError ` | 500 | Server error |
363392| ` ExternalServiceError ` | 502 | External service failure |
364393| ` ServiceUnavailableError ` | 503 | Service unavailable |
394+ | ` BillingError ` | 402/403/429 | Usage denial (base) — ` code ` /` layer ` /` reason ` |
395+ | ` QuotaExceededError ` | 403 | Quota exceeded (` layer="quota" ` ) |
396+ | ` CreditExhaustedError ` | 402 | Credit plan exhausted (` layer="credit" ` ) |
397+ | ` WalletError ` | 402 | Wallet overage denied (` layer="wallet" ` ) |
365398| ` StreamError ` | — | SSE stream error |
366399| ` TimeoutError ` | — | Request timeout |
367400
368401## Type Hints
369402
370- All types are available for import:
403+ Responses are ** Pydantic v2 models** — use typed attribute access (` result.id ` ) or, for
404+ compatibility, dict-style access (` result["id"] ` ). Unknown fields the backend may add are preserved.
405+ All models are importable:
371406
372407``` python
373408from modulex import SSEEvent
@@ -377,6 +412,8 @@ from modulex.types import (
377412 EdgeDefinition,
378413 LLMConfig,
379414 RunResponse,
415+ AsyncPage, # typed auto-pagination (e.g. executions.iter_runs)
416+ ModulexModel, # base class for all response models
380417)
381418```
382419
0 commit comments