stacklink-sdk 0.4.0__tar.gz → 0.6.2__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 (45) hide show
  1. {stacklink_sdk-0.4.0/stacklink_sdk.egg-info → stacklink_sdk-0.6.2}/PKG-INFO +28 -1
  2. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/README.md +27 -0
  3. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/pyproject.toml +1 -1
  4. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/bots.py +8 -4
  5. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/client.py +4 -1
  6. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/sessions.py +6 -2
  7. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/workbench.py +15 -8
  8. stacklink_sdk-0.6.2/stacklink/workflows.py +129 -0
  9. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2/stacklink_sdk.egg-info}/PKG-INFO +28 -1
  10. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/SOURCES.txt +2 -0
  11. stacklink_sdk-0.6.2/tests/test_amazon_seller_provider.py +38 -0
  12. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_client.py +104 -0
  13. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/LICENSE +0 -0
  14. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/setup.cfg +0 -0
  15. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/__init__.py +0 -0
  16. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/api_keys.py +0 -0
  17. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/approvals.py +0 -0
  18. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/artifacts.py +0 -0
  19. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/auth_configs.py +0 -0
  20. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/billing.py +0 -0
  21. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/claude_agent.py +0 -0
  22. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/connected_accounts.py +0 -0
  23. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/errors.py +0 -0
  24. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/executions.py +0 -0
  25. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/knowledge.py +0 -0
  26. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/logs.py +0 -0
  27. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/mcp.py +0 -0
  28. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/memory.py +0 -0
  29. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/openai_agents.py +0 -0
  30. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/openai_memory.py +0 -0
  31. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/projects.py +0 -0
  32. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/providers.py +0 -0
  33. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/tools.py +0 -0
  34. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/triggers.py +0 -0
  35. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/types.py +0 -0
  36. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/webhooks.py +0 -0
  37. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/x_chat.py +0 -0
  38. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
  39. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/requires.txt +0 -0
  40. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/top_level.txt +0 -0
  41. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_google_docs_provider.py +0 -0
  42. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_google_drive_provider.py +0 -0
  43. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_google_sheets_provider.py +0 -0
  44. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_openai_memory.py +0 -0
  45. {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_zoho_books_provider.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink-sdk
3
- Version: 0.4.0
3
+ Version: 0.6.2
4
4
  Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
5
  Author: Stacklink
6
6
  License-Expression: LicenseRef-Proprietary
@@ -128,6 +128,33 @@ The async client provides the same operations with `await` and `async for`.
128
128
 
129
129
  Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
130
130
 
131
+ ## Workflows (0.6.2)
132
+
133
+ Use `https://api.stacklink.in` for production.
134
+ Workflow trigger changes now follow draft → publish. `workflows.triggers(id)`
135
+ returns the draft `revision`, draft `triggers`, and active `liveTriggers`.
136
+ Pass the required `expected_revision` keyword to `save_trigger` or
137
+ `delete_trigger`, then publish the returned revision to apply the change.
138
+ A stale revision returns 409; saving a trigger no longer activates it immediately.
139
+ The async client uses the same lifecycle.
140
+
141
+ Bot steps, child workflows, For each and Bot attachments accept `"live"` in place
142
+ of a numeric revision/version. New runs resolve live references once and retain
143
+ that setup through waits, retries and every item. Existing numeric pins remain
144
+ supported. Pausing blocks new starts; publishing does not resume a paused workflow.
145
+ The [Workflows guide](https://docs.stacklink.in/docs/workflows) covers definitions,
146
+ permissions, stable job keys and recovery.
147
+
148
+ Your Python backend can issue customer editor access through
149
+ `workflows.issue_embedded_access(customer_id=..., user_id=..., origin=..., role=...)`.
150
+ Derive those values from your authenticated application session. Mount the hosted
151
+ editor in the frontend with the separate `@stacklink/workflow-builder` JavaScript
152
+ package; never expose the project key. `with_embedded_access(project_id, access_token)`
153
+ provides person-scoped lists, approvals and analytics. `for_customer(customer_id)`
154
+ is for trusted backend administration. The async client supports the same methods.
155
+ See the [embedded guide](https://docs.stacklink.in/docs/workflows#embed-the-builder-in-your-application)
156
+ for the complete lifecycle.
157
+
131
158
  ## Shared approvals (0.3.3)
132
159
 
133
160
  `client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
@@ -107,6 +107,33 @@ The async client provides the same operations with `await` and `async for`.
107
107
 
108
108
  Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
109
109
 
110
+ ## Workflows (0.6.2)
111
+
112
+ Use `https://api.stacklink.in` for production.
113
+ Workflow trigger changes now follow draft → publish. `workflows.triggers(id)`
114
+ returns the draft `revision`, draft `triggers`, and active `liveTriggers`.
115
+ Pass the required `expected_revision` keyword to `save_trigger` or
116
+ `delete_trigger`, then publish the returned revision to apply the change.
117
+ A stale revision returns 409; saving a trigger no longer activates it immediately.
118
+ The async client uses the same lifecycle.
119
+
120
+ Bot steps, child workflows, For each and Bot attachments accept `"live"` in place
121
+ of a numeric revision/version. New runs resolve live references once and retain
122
+ that setup through waits, retries and every item. Existing numeric pins remain
123
+ supported. Pausing blocks new starts; publishing does not resume a paused workflow.
124
+ The [Workflows guide](https://docs.stacklink.in/docs/workflows) covers definitions,
125
+ permissions, stable job keys and recovery.
126
+
127
+ Your Python backend can issue customer editor access through
128
+ `workflows.issue_embedded_access(customer_id=..., user_id=..., origin=..., role=...)`.
129
+ Derive those values from your authenticated application session. Mount the hosted
130
+ editor in the frontend with the separate `@stacklink/workflow-builder` JavaScript
131
+ package; never expose the project key. `with_embedded_access(project_id, access_token)`
132
+ provides person-scoped lists, approvals and analytics. `for_customer(customer_id)`
133
+ is for trusted backend administration. The async client supports the same methods.
134
+ See the [embedded guide](https://docs.stacklink.in/docs/workflows#embed-the-builder-in-your-application)
135
+ for the complete lifecycle.
136
+
110
137
  ## Shared approvals (0.3.3)
111
138
 
112
139
  `client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stacklink-sdk"
7
- version = "0.4.0"
7
+ version = "0.6.2"
8
8
  description = "Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing."
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -238,8 +238,8 @@ class BotsResource:
238
238
  def retry_delivery(self, bot_id: str, run_id: str, delivery_id: str):
239
239
  return self.client.request("POST", self._path(bot_id, run_id) + "/deliveries/retry", json_body={"deliveryId": delivery_id})
240
240
 
241
- def list(self):
242
- return self.client.request("GET", "/v1/bots")
241
+ def list(self, *, cursor: str | None = None, search: str | None = None, limit: int = 200):
242
+ return self.client.request("GET", "/v1/bots", query={"limit": limit, **({"cursor": cursor} if cursor else {}), **({"search": search} if search else {})})
243
243
 
244
244
  def delete(self, bot_id: str):
245
245
  return self.client.request("DELETE", self._path(bot_id))
@@ -275,6 +275,10 @@ class BotsResource:
275
275
  def get_run(self, bot_id: str, run_id: str):
276
276
  return self.client.request("GET", self._path(bot_id, run_id))
277
277
 
278
+ def continue_run(self, bot_id: str, run_id: str):
279
+ """Continue the same saved funding pause after its funding is resolved."""
280
+ return self.client.request("POST", self._path(bot_id, run_id) + "/continue", json_body={})
281
+
278
282
  def wait(self, bot_id: str, run_id: str, *, total_timeout_ms: int = 300_000):
279
283
  """Return the finished run, or a run waiting for a person's approval or connection."""
280
284
  if total_timeout_ms <= 0:
@@ -285,7 +289,7 @@ class BotsResource:
285
289
  deadline = time.monotonic() + total_timeout_ms / 1000
286
290
  while time.monotonic() < deadline:
287
291
  run = self.get_run(bot_id, run_id)
288
- if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
292
+ if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection") or (run.get("waitingOperation") or {}).get("kind") == "funding":
289
293
  return run
290
294
  time.sleep(min(1, max(0, deadline - time.monotonic())))
291
295
  raise StacklinkError("Bot observation timed out. Continue observing the same run.", status_code=408, code="sdk_run_timeout", details={"botId": bot_id, "runId": run_id})
@@ -294,7 +298,7 @@ class BotsResource:
294
298
  deadline = time.monotonic() + total_timeout_ms / 1000
295
299
  while time.monotonic() < deadline:
296
300
  run = await self.get_run(bot_id, run_id)
297
- if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
301
+ if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection") or (run.get("waitingOperation") or {}).get("kind") == "funding":
298
302
  return run
299
303
  await asyncio.sleep(min(1, max(0, deadline - time.monotonic())))
300
304
  raise StacklinkError("Bot observation timed out. Continue observing the same run.", status_code=408, code="sdk_run_timeout", details={"botId": bot_id, "runId": run_id})
@@ -20,6 +20,7 @@ from .artifacts import ArtifactsResource
20
20
  from .auth_configs import AuthConfigsResource
21
21
  from .billing import BillingResource
22
22
  from .bots import BotsResource
23
+ from .workflows import WorkflowsResource
23
24
  from .approvals import ApprovalsResource
24
25
  from .connected_accounts import ConnectedAccountsResource
25
26
  from .errors import RateLimitError, StacklinkError
@@ -37,7 +38,7 @@ from .types import Json, Transport
37
38
  from .webhooks import WebhooksResource
38
39
  from .workbench import WorkbenchResource
39
40
 
40
- STACKLINK_PYTHON_SDK_VERSION = "0.4.0"
41
+ STACKLINK_PYTHON_SDK_VERSION = "0.6.2"
41
42
  STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
42
43
 
43
44
 
@@ -54,6 +55,7 @@ class Stacklink:
54
55
  max_attempts: int = 3,
55
56
  http_client: httpx.Client | None = None,
56
57
  ):
58
+ self.workflows = WorkflowsResource(self)
57
59
  self.bots = BotsResource(self)
58
60
  self.approvals = ApprovalsResource(self)
59
61
  self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
@@ -419,6 +421,7 @@ class AsyncStacklink:
419
421
  max_attempts: int = 3,
420
422
  http_client: httpx.AsyncClient | None = None,
421
423
  ):
424
+ self.workflows = WorkflowsResource(self)
422
425
  self.bots = BotsResource(self, async_mode=True)
423
426
  self.approvals = ApprovalsResource(self, async_mode=True)
424
427
  self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
@@ -134,8 +134,8 @@ class Session:
134
134
  def get_contracts(self, contract_refs: list[str]):
135
135
  return self.client.sessions.get_contracts(self.id, contract_refs)
136
136
 
137
- def authorize(self, connection_ref: str, *, metadata: dict[str, Any] | None = None):
138
- return self.client.sessions.authorize(self.id, connection_ref, metadata=metadata)
137
+ def authorize(self, connection_ref: str, *, metadata: dict[str, Any] | None = None, amazon_seller_region: str | None = None):
138
+ return self.client.sessions.authorize(self.id, connection_ref, metadata=metadata, amazon_seller_region=amazon_seller_region)
139
139
 
140
140
  def execute(
141
141
  self,
@@ -256,12 +256,16 @@ class SessionsResource:
256
256
  connection_ref: str,
257
257
  *,
258
258
  metadata: dict[str, Any] | None = None,
259
+ amazon_seller_region: str | None = None,
259
260
  ):
261
+ if amazon_seller_region is not None and amazon_seller_region not in ("NA", "EU", "FE"):
262
+ raise ValueError("amazon_seller_region must be NA, EU or FE")
260
263
  return self.client.request(
261
264
  "POST",
262
265
  f"/v1/sessions/{session_id}/connections/authorize",
263
266
  json_body={
264
267
  "connectionRef": connection_ref,
268
+ **({"amazonSellerRegion": amazon_seller_region} if amazon_seller_region is not None else {}),
265
269
  "metadata": dict(metadata or {}),
266
270
  },
267
271
  )
@@ -549,10 +549,15 @@ class WorkbenchExecutionResponse(TypedDict, total=False):
549
549
  artifact: WorkbenchArtifact
550
550
 
551
551
 
552
+ def _execution_session_id(response: Mapping[str, Any], fallback: str | None) -> str | None:
553
+ workspace = response.get("workbenchSession")
554
+ return workspace["platformSessionId"] if isinstance(workspace, dict) and isinstance(workspace.get("platformSessionId"), str) else fallback
555
+
556
+
552
557
  class WorkbenchRun:
553
558
  def __init__(self, client, platform_session_id: str, response: WorkbenchExecutionResponse):
554
559
  self.client = client
555
- self.platform_session_id = platform_session_id
560
+ self.platform_session_id = _execution_session_id(response, platform_session_id)
556
561
  self.response = response
557
562
  self.execution_id = str(response["executionId"])
558
563
  self.execution_kind = str(response["executionKind"])
@@ -651,7 +656,7 @@ def _wait_for_workbench_response(
651
656
  request: Mapping[str, Any],
652
657
  accepted: WorkbenchExecutionResponse,
653
658
  ) -> WorkbenchExecutionResponse:
654
- session_id = request.get("sessionId")
659
+ session_id = _execution_session_id(accepted, request.get("sessionId"))
655
660
  execution_id = accepted.get("executionId")
656
661
  execution_kind = accepted.get("executionKind")
657
662
  if (
@@ -1140,10 +1145,11 @@ class SessionSandbox:
1140
1145
  "workbenchSessionId": body.get("workbenchSessionId") or self.ensure_session_id(),
1141
1146
  "operation": "run_code",
1142
1147
  }
1143
- response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/run", json_body=request)
1148
+ response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/run", json_body=request, idempotency_key=request.get("idempotencyKey"))
1144
1149
  if body.get("background") is not True:
1145
1150
  response = _wait_for_workbench_response(self.client, request, response)
1146
- self.workbench_session_id = str(response["workbenchSession"]["id"])
1151
+ if _execution_session_id(response, self.platform_session_id) == self.platform_session_id:
1152
+ self.workbench_session_id = str(response["workbenchSession"]["id"])
1147
1153
  return WorkbenchRun(self.client, self.platform_session_id, response)
1148
1154
 
1149
1155
  def bash(self, body: WorkbenchBashInput) -> WorkbenchRun:
@@ -1152,10 +1158,11 @@ class SessionSandbox:
1152
1158
  "sessionId": self.platform_session_id,
1153
1159
  "workbenchSessionId": body.get("workbenchSessionId") or self.ensure_session_id(),
1154
1160
  }
1155
- response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/bash", json_body=request)
1161
+ response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/bash", json_body=request, idempotency_key=request.get("idempotencyKey"))
1156
1162
  if body.get("background") is not True:
1157
1163
  response = _wait_for_workbench_response(self.client, request, response)
1158
- self.workbench_session_id = str(response["workbenchSession"]["id"])
1164
+ if _execution_session_id(response, self.platform_session_id) == self.platform_session_id:
1165
+ self.workbench_session_id = str(response["workbenchSession"]["id"])
1159
1166
  return WorkbenchRun(self.client, self.platform_session_id, response)
1160
1167
 
1161
1168
  def destroy(self) -> WorkbenchSessionDestroyResponse:
@@ -1184,7 +1191,7 @@ class WorkbenchResource:
1184
1191
  return self.client.request("GET", "/v1/workbench/models")
1185
1192
 
1186
1193
  def run(self, body: dict[str, Any]):
1187
- response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/run", json_body=body))
1194
+ response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/run", json_body=body, idempotency_key=body.get("idempotencyKey")))
1188
1195
  if isinstance(response.get("operation"), dict):
1189
1196
  operation = response["operation"]
1190
1197
  completed = self.wait_operation(body["sessionId"], operation["workbenchSessionId"], operation["operationId"])
@@ -1194,7 +1201,7 @@ class WorkbenchResource:
1194
1201
  return response
1195
1202
 
1196
1203
  def bash(self, body: dict[str, Any]):
1197
- response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/bash", json_body=body))
1204
+ response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/bash", json_body=body, idempotency_key=body.get("idempotencyKey")))
1198
1205
  if body.get("background") is not True:
1199
1206
  return _wait_for_workbench_response(self.client, body, response)
1200
1207
  return response
@@ -0,0 +1,129 @@
1
+ """Durable workflow definitions and runs. Reuse job_key after a timeout."""
2
+ from typing import Any
3
+ from urllib.parse import quote
4
+
5
+
6
+ class WorkflowsResource:
7
+ def __init__(self, client, *, customer_id=None, project_id=None, access_token=None):
8
+ self.client = client
9
+ self.customer_id = customer_id
10
+ self.project_id = project_id
11
+ self.access_token = access_token
12
+
13
+ def for_customer(self, customer_id: str):
14
+ return WorkflowsResource(self.client, customer_id=customer_id)
15
+
16
+ def with_embedded_access(self, project_id: str, access_token: str):
17
+ return WorkflowsResource(self.client, project_id=project_id, access_token=access_token)
18
+
19
+ def _request(self, method, path, **options):
20
+ headers = dict(options.pop('headers', {}) or {})
21
+ if self.customer_id:
22
+ headers['X-Stacklink-Customer'] = self.customer_id
23
+ if self.access_token:
24
+ headers['authorization'] = 'Bearer ' + self.access_token
25
+ return self.client.request(method, path, headers=headers, **options)
26
+
27
+ def _path(self, workflow_id: str | None = None) -> str:
28
+ return ('/embedded/api/projects/' + quote(self.project_id, safe='') + '/workflows' if self.project_id else '/v1/workflows') + ('/' + quote(workflow_id, safe='') if workflow_id else '')
29
+
30
+ def list(self, *, cursor: str | None = None, search: str | None = None, limit: int = 200):
31
+ return self._request('GET', self._path(), query={'limit': limit, **({'cursor': cursor} if cursor else {}), **({'search': search} if search else {})})
32
+
33
+ def create(self, name: str, definition: dict[str, Any], *, source: str = 'code', description: str = '', deployment: dict[str, str] | None = None):
34
+ return self._request('POST', self._path(), json_body={'name': name, 'definition': definition, 'source': source, 'description': description, **({'deployment': deployment} if deployment is not None else {})})
35
+
36
+ def get(self, workflow_id: str):
37
+ return self._request('GET', self._path(workflow_id))
38
+
39
+ def update(self, workflow_id: str, revision: int, **changes):
40
+ return self._request('PATCH', self._path(workflow_id), json_body={**changes, 'revision': revision})
41
+
42
+ def validate(self, definition: dict[str, Any]):
43
+ return self._request('POST', self._path() + '/validate', json_body={'definition': definition})
44
+
45
+ def publish(self, workflow_id: str, revision: int):
46
+ return self._request('POST', self._path(workflow_id) + '/publish', json_body={'revision': revision})
47
+
48
+ def set_state(self, workflow_id: str, paused: bool):
49
+ return self._request('POST', self._path(workflow_id) + '/state', json_body={'paused': paused})
50
+
51
+ def versions(self, workflow_id: str):
52
+ return self._request('GET', self._path(workflow_id) + '/versions')
53
+
54
+ def run(self, workflow_id: str, session_id: str, job_key: str, *, input: dict[str, Any] | None = None, **options):
55
+ return self._request('POST', self._path(workflow_id) + '/runs', json_body={**options, 'sessionId': session_id, 'jobKey': job_key, 'input': input or {}}, idempotency_key=job_key)
56
+
57
+ def runs(self, workflow_id: str, *, cursor: str | None = None, limit: int = 50):
58
+ return self._request('GET', self._path(workflow_id) + '/runs', query={'limit': limit, **({'cursor': cursor} if cursor else {})})
59
+
60
+ def get_run(self, workflow_id: str, run_id: str):
61
+ return self._request('GET', self._path(workflow_id) + '/runs/' + quote(run_id, safe=''))
62
+
63
+ def events(self, workflow_id: str, run_id: str, *, before: str | None = None, limit: int = 200):
64
+ return self._request('GET', self._path(workflow_id) + '/runs/' + quote(run_id, safe='') + '/events', query={'limit': limit, **({'before': before} if before else {})})
65
+
66
+ def action(self, workflow_id: str, run_id: str, action: str, **options):
67
+ return self._request('POST', self._path(workflow_id) + '/runs/' + quote(run_id, safe='') + '/actions', json_body={**options, 'action': action})
68
+
69
+ def triggers(self, workflow_id: str):
70
+ return self._request('GET', self._path(workflow_id) + '/triggers')
71
+
72
+ def save_trigger(self, workflow_id: str, configuration: dict[str, Any], *, expected_revision: int):
73
+ """Save a draft trigger. Publish the returned revision to activate it."""
74
+ return self._request('POST', self._path(workflow_id) + '/triggers', json_body={**configuration, 'expectedRevision': expected_revision})
75
+
76
+ def delete_trigger(self, workflow_id: str, trigger_id: str, *, expected_revision: int):
77
+ return self._request('DELETE', self._path(workflow_id) + '/triggers/' + quote(trigger_id, safe=''), query={'expectedRevision': expected_revision})
78
+
79
+ def invoke_trigger(self, workflow_id: str, trigger_id: str, request_id: str, input: dict[str, Any]):
80
+ return self._request('POST', self._path(workflow_id) + '/triggers/' + quote(trigger_id, safe='') + '/invoke', json_body={'requestId': request_id, 'input': input}, idempotency_key=request_id)
81
+
82
+ def preview_schedule(self, cron: str, timezone: str = 'UTC'):
83
+ return self._request('POST', self._path() + '/schedule/preview', json_body={'cron': cron, 'timezone': timezone})
84
+
85
+ def suggest(self, workflow_id: str, description: str, revision: int, session_id: str):
86
+ return self._request('POST', self._path(workflow_id) + '/suggest', json_body={'description': description, 'revision': revision, 'sessionId': session_id}, timeout_ms=60_000)
87
+
88
+
89
+ def issue_embedded_access(self, customer_id: str, user_id: str, origin: str, role: str, *, decide_approvals: bool = False, view_usage: bool = False, workflow_id: str | None = None, customer_name: str | None = None, user_name: str | None = None):
90
+ return self.client.request('POST', '/v1/workflows/embed/access', json_body={
91
+ 'customerId': customer_id, 'userId': user_id, 'origin': origin, 'role': role,
92
+ 'decideApprovals': decide_approvals, 'viewUsage': view_usage,
93
+ **({'workflowId': workflow_id} if workflow_id else {}),
94
+ **({'customerName': customer_name} if customer_name else {}),
95
+ **({'userName': user_name} if user_name else {}),
96
+ })
97
+
98
+ def exchange_embedded_access(self, bootstrap_token: str, origin: str):
99
+ return self.client.request('POST', '/v1/workflows/embed/exchange', json_body={'bootstrapToken': bootstrap_token, 'origin': origin})
100
+
101
+ def embed_settings(self):
102
+ return self._request('GET', self._path() + '/embed')
103
+
104
+ def save_embed_settings(self, revision: int, config: dict):
105
+ return self._request('PUT', self._path() + '/embed', json_body={'revision': revision, 'config': config})
106
+
107
+ def customers(self, **query):
108
+ return self._request('GET', self._path() + '/embed/customers', query=query)
109
+
110
+ def customer(self, customer_id: str):
111
+ return self._request('GET', self._path() + '/embed/customers/' + quote(customer_id, safe=''))
112
+
113
+ def set_customer_automations(self, customer_id: str, enabled: bool):
114
+ return self._request('POST', self._path() + '/embed/customers/' + quote(customer_id, safe='') + '/automations', json_body={'enabled': enabled})
115
+
116
+ def set_customer_person_access(self, customer_id: str, person_id: str, action: str):
117
+ return self._request('POST', self._path() + '/embed/customers/' + quote(customer_id, safe='') + '/people/' + quote(person_id, safe='') + '/access', json_body={'action': action})
118
+
119
+ def analytics(self, *, days: int = 30, customer_id: str | None = None):
120
+ return self._request('GET', self._path() + '/analytics', query={'days': days, **({'customerId': customer_id} if customer_id else {})})
121
+
122
+ def activity(self, **query):
123
+ return self._request('GET', self._path() + '/activity', query=query)
124
+
125
+ def approvals(self, *, view: str = 'inbox', cursor: str | None = None, limit: int = 50):
126
+ return self._request('GET', self._path() + '/approvals', query={'view': view, 'limit': limit, **({'cursor': cursor} if cursor else {})})
127
+
128
+ def decide_approval(self, approval_id: str, decision: dict):
129
+ return self._request('POST', self._path() + '/approvals/' + quote(approval_id, safe='') + '/decide', json_body=decision)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink-sdk
3
- Version: 0.4.0
3
+ Version: 0.6.2
4
4
  Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
5
  Author: Stacklink
6
6
  License-Expression: LicenseRef-Proprietary
@@ -128,6 +128,33 @@ The async client provides the same operations with `await` and `async for`.
128
128
 
129
129
  Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
130
130
 
131
+ ## Workflows (0.6.2)
132
+
133
+ Use `https://api.stacklink.in` for production.
134
+ Workflow trigger changes now follow draft → publish. `workflows.triggers(id)`
135
+ returns the draft `revision`, draft `triggers`, and active `liveTriggers`.
136
+ Pass the required `expected_revision` keyword to `save_trigger` or
137
+ `delete_trigger`, then publish the returned revision to apply the change.
138
+ A stale revision returns 409; saving a trigger no longer activates it immediately.
139
+ The async client uses the same lifecycle.
140
+
141
+ Bot steps, child workflows, For each and Bot attachments accept `"live"` in place
142
+ of a numeric revision/version. New runs resolve live references once and retain
143
+ that setup through waits, retries and every item. Existing numeric pins remain
144
+ supported. Pausing blocks new starts; publishing does not resume a paused workflow.
145
+ The [Workflows guide](https://docs.stacklink.in/docs/workflows) covers definitions,
146
+ permissions, stable job keys and recovery.
147
+
148
+ Your Python backend can issue customer editor access through
149
+ `workflows.issue_embedded_access(customer_id=..., user_id=..., origin=..., role=...)`.
150
+ Derive those values from your authenticated application session. Mount the hosted
151
+ editor in the frontend with the separate `@stacklink/workflow-builder` JavaScript
152
+ package; never expose the project key. `with_embedded_access(project_id, access_token)`
153
+ provides person-scoped lists, approvals and analytics. `for_customer(customer_id)`
154
+ is for trusted backend administration. The async client supports the same methods.
155
+ See the [embedded guide](https://docs.stacklink.in/docs/workflows#embed-the-builder-in-your-application)
156
+ for the complete lifecycle.
157
+
131
158
  ## Shared approvals (0.3.3)
132
159
 
133
160
  `client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
@@ -27,12 +27,14 @@ stacklink/triggers.py
27
27
  stacklink/types.py
28
28
  stacklink/webhooks.py
29
29
  stacklink/workbench.py
30
+ stacklink/workflows.py
30
31
  stacklink/x_chat.py
31
32
  stacklink_sdk.egg-info/PKG-INFO
32
33
  stacklink_sdk.egg-info/SOURCES.txt
33
34
  stacklink_sdk.egg-info/dependency_links.txt
34
35
  stacklink_sdk.egg-info/requires.txt
35
36
  stacklink_sdk.egg-info/top_level.txt
37
+ tests/test_amazon_seller_provider.py
36
38
  tests/test_client.py
37
39
  tests/test_google_docs_provider.py
38
40
  tests/test_google_drive_provider.py
@@ -0,0 +1,38 @@
1
+ import unittest
2
+
3
+ from stacklink import Stacklink
4
+
5
+
6
+ class AmazonSellerClientTests(unittest.TestCase):
7
+ def test_authorization_uses_the_shared_session_route_and_exact_region(self):
8
+ calls = []
9
+
10
+ def transport(method, path, headers, body, query, timeout):
11
+ calls.append((method, path, body))
12
+ return 200, {}, {"authorization": {"providerKey": "amazon_seller", "status": "pending"}}
13
+
14
+ client = Stacklink(api_key="ppk_test", transport=transport)
15
+ client.sessions.authorize("session-owned", "opaque-ref", amazon_seller_region="EU")
16
+ self.assertEqual(calls, [("POST", "/v1/sessions/session-owned/connections/authorize", {
17
+ "connectionRef": "opaque-ref", "amazonSellerRegion": "EU", "metadata": {}
18
+ })])
19
+ with self.assertRaises(ValueError):
20
+ client.sessions.authorize("session-owned", "opaque-ref", amazon_seller_region="India")
21
+ self.assertEqual(len(calls), 1)
22
+
23
+ def test_execution_uses_existing_tool_route_without_provider_credentials(self):
24
+ calls = []
25
+
26
+ def transport(method, path, headers, body, query, timeout):
27
+ calls.append((method, path, body))
28
+ return 200, {}, {"status": "completed", "output": {"data": {"payload": []}}}
29
+
30
+ client = Stacklink(api_key="ppk_test", transport=transport)
31
+ payload = {"sessionId": "session-owned", "toolKey": "amazon_seller.seller.get_marketplace_participations", "input": {}}
32
+ result = client.tools.execute(payload)
33
+ self.assertEqual(result["status"], "completed")
34
+ self.assertEqual(calls, [("POST", "/v1/tools/execute", payload)])
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()
@@ -63,6 +63,68 @@ def stalled_stream_server(*, final_result=False, execution_page=False):
63
63
 
64
64
 
65
65
  class StacklinkClientTests(unittest.TestCase):
66
+ def test_workflow_embedded_access_maps_delegated_permissions(self):
67
+ calls = []
68
+ def transport(method, path, headers, body, query, timeout):
69
+ calls.append((method, path, body))
70
+ return 200, {}, {"bootstrapToken": "one-use", "embedUrl": "https://staging.stacklink.in/embed/workflows/project"}
71
+ client = Stacklink(api_key="host-backend-key", transport=transport)
72
+ client.workflows.issue_embedded_access(
73
+ "fernhill", "maya", "https://acme.example", "viewer",
74
+ decide_approvals=True, view_usage=True, workflow_id="workflow-1",
75
+ customer_name="Fernhill", user_name="Maya",
76
+ )
77
+ self.assertEqual(calls, [("POST", "/v1/workflows/embed/access", {
78
+ "customerId": "fernhill", "userId": "maya", "origin": "https://acme.example", "role": "viewer",
79
+ "decideApprovals": True, "viewUsage": True, "workflowId": "workflow-1",
80
+ "customerName": "Fernhill", "userName": "Maya",
81
+ })])
82
+
83
+ def test_workflow_embedded_context_does_not_change_project_client(self):
84
+ calls = []
85
+ def transport(method, path, headers, body, query, timeout):
86
+ calls.append((path, dict(headers), body))
87
+ return 200, {}, {"workflows": [], "nextCursor": None}
88
+ client = Stacklink(api_key="host-backend-key", transport=transport)
89
+ a = client.workflows.for_customer("fernhill")
90
+ b = client.workflows.for_customer("larkspur")
91
+ a.list()
92
+ b.list()
93
+ client.workflows.list()
94
+ person = client.workflows.with_embedded_access("project-1", "sle_person")
95
+ person.approvals()
96
+ person.decide_approval("task-1", {"kind": "workflow", "value": "approved"})
97
+ self.assertEqual([row[1].get("X-Stacklink-Customer") for row in calls], ["fernhill", "larkspur", None, None, None])
98
+ self.assertEqual(calls[3][0], "/embedded/api/projects/project-1/workflows/approvals")
99
+ self.assertEqual({key.lower(): value for key, value in calls[3][1].items()}["authorization"], "Bearer sle_person")
100
+ self.assertEqual(calls[4][2], {"kind": "workflow", "value": "approved"})
101
+
102
+ def test_workflow_retry_and_trigger_use_stable_request_identity(self):
103
+ calls = []
104
+ def transport(method, path, headers, body, query, timeout):
105
+ calls.append((method, path, headers.get("Idempotency-Key"), body, query))
106
+ if len(calls) == 1:
107
+ return 503, {}, {"message": "Lost acknowledgement"}
108
+ return 200, {}, {"id": "same-run", "runs": [], "nextCursor": None}
109
+ client = Stacklink(api_key="ppk_test", transport=transport)
110
+ with patch("stacklink.client.time.sleep", return_value=None):
111
+ result = client.workflows.run("wf-1", "session-1", "upload-42", input={"recording": "चर्चा"})
112
+ self.assertEqual(result["id"], "same-run")
113
+ self.assertEqual(calls[0], calls[1])
114
+ self.assertEqual(calls[0][2], "upload-42")
115
+ client.workflows.runs("wf-1", cursor="run-1", limit=10)
116
+ self.assertEqual(calls[2][4], {"cursor": "run-1", "limit": 10})
117
+ client.workflows.invoke_trigger("wf-1", "trigger-1", "event-8", {"subject": "Ready"})
118
+ self.assertEqual(calls[3][1:4], ("/v1/workflows/wf-1/triggers/trigger-1/invoke", "event-8", {"requestId": "event-8", "input": {"subject": "Ready"}}))
119
+ client.workflows.suggest("wf-1", "Review findings", 3, "session-1")
120
+ self.assertEqual(calls[4][1], "/v1/workflows/wf-1/suggest")
121
+ self.assertEqual(calls[4][3], {"description": "Review findings", "revision": 3, "sessionId": "session-1"})
122
+ client.workflows.save_trigger("wf-1", {"kind": "webhook"}, expected_revision=3)
123
+ self.assertEqual(calls[5][3], {"kind": "webhook", "expectedRevision": 3})
124
+ client.workflows.delete_trigger("wf-1", "trigger-1", expected_revision=4)
125
+ self.assertEqual(calls[6][4], {"expectedRevision": 4})
126
+
127
+
66
128
  def test_workbench_lost_acknowledgement_keeps_upload_and_artifact_identity(self):
67
129
  bodies = []
68
130
  def transport(method, path, headers, body, query, timeout):
@@ -143,6 +205,48 @@ class StacklinkClientTests(unittest.TestCase):
143
205
  self.assertEqual(calls[2][2], {'revision': 2})
144
206
 
145
207
 
208
+ def test_workbench_replay_observes_the_original_session(self):
209
+ for operation in ("bash", "run_code"):
210
+ calls = []
211
+ kind = "workbench_bash" if operation == "bash" else "workbench_code"
212
+ def transport(method, path, headers, body, query, timeout):
213
+ calls.append((path, body))
214
+ if path in {"/v1/workbench/bash", "/v1/workbench/run"}:
215
+ return 202, {}, {"executionId": "original-execution", "executionKind": kind, "status": "queued", "idempotency": {"replayed": True}, "workbenchSession": {"id": "workspace", "platformSessionId": "original-session"}}
216
+ self.assertEqual(body["sessionId"], "original-session")
217
+ return 200, {}, {"execution": {"id": "original-execution", "executionKind": kind, "status": "completed", "stdout": "saved result"}}
218
+ client = Stacklink(api_key="ppk_test", transport=transport)
219
+ payload = {"sessionId": "replacement-session", "idempotencyKey": "same-logical-job", "operation": operation, "command": "echo saved", "language": "python", "code": 'print("saved")'}
220
+ result = client.workbench.bash(payload) if operation == "bash" else client.workbench.run(payload)
221
+ self.assertEqual(result["status"], "completed")
222
+ self.assertEqual(len(calls), 2)
223
+ self.assertEqual(calls[0][1]["sessionId"], "replacement-session")
224
+
225
+ def test_replayed_run_keeps_the_replacement_sandbox_separate(self):
226
+ for operation in ("bash", "run_code"):
227
+ controls = []
228
+ def transport(method, path, headers, body, query, timeout):
229
+ if path.endswith("/files"):
230
+ self.assertEqual(query["sessionId"], "replacement-session")
231
+ self.assertEqual(path, "/v1/workbench/sessions/replacement-workspace/files")
232
+ return 200, {}, {"files": []}
233
+ if path.startswith("/v1/executions/"):
234
+ self.assertEqual(body["sessionId"], "original-session")
235
+ controls.append(path)
236
+ return 200, {}, {"execution": {"id": "original-execution", "status": "running"}}
237
+ if path == "/v1/workbench/sessions":
238
+ return 200, {}, {"workbenchSession": {"id": "replacement-workspace", "platformSessionId": "replacement-session"}}
239
+ self.assertEqual(body["sessionId"], "replacement-session")
240
+ self.assertEqual(body["workbenchSessionId"], "replacement-workspace")
241
+ return 202, {}, {"executionId": "original-execution", "executionKind": "workbench_bash" if operation == "bash" else "workbench_code", "status": "running", "workbenchSession": {"id": "original-workspace", "platformSessionId": "original-session"}}
242
+ client = Stacklink(api_key="ppk_test", transport=transport)
243
+ sandbox = SessionSandbox(client, "replacement-session")
244
+ run = sandbox.bash({"command": "echo saved", "idempotencyKey": "same-work", "background": True}) if operation == "bash" else sandbox.run({"language": "python", "code": 'print("saved")', "idempotencyKey": "same-work", "background": True})
245
+ run.wait()
246
+ run.cancel("explicit cancellation")
247
+ sandbox.files.list()
248
+ self.assertEqual(len(controls), 2)
249
+
146
250
  def test_workbench_approval_returns_to_caller_before_decision(self):
147
251
  for operation in ("run_code", "bash"):
148
252
  for admission_status in ("waiting_for_approval", "queued"):
File without changes
File without changes