stacklink-sdk 0.2.2__py3-none-any.whl → 0.3.10__py3-none-any.whl

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.
stacklink/__init__.py CHANGED
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from .bots import verify_bot_api_request, sign_bot_trigger_request
4
+ from .approvals import ApprovalDecision, ApprovalRecord, ApprovalEvent, ApprovalPermissions, CustomerApprovalGrantInput
5
+ from .webhooks import verify_webhook_signature
3
6
  from .client import AsyncStacklink, STACKLINK_PYTHON_SDK_VERSION, Stacklink
4
7
  from .claude_agent import create_stacklink_claude_agent_tool_config
5
8
  from .errors import RateLimitError, StacklinkError
@@ -24,7 +27,12 @@ from .types import (
24
27
  )
25
28
 
26
29
  __all__ = [
30
+ "ApprovalDecision", "ApprovalRecord", "ApprovalEvent", "ApprovalPermissions",
31
+ "CustomerApprovalGrantInput",
27
32
  "Stacklink",
33
+ "verify_bot_api_request",
34
+ "sign_bot_trigger_request",
35
+ "verify_webhook_signature",
28
36
  "AsyncStacklink",
29
37
  "STACKLINK_PYTHON_SDK_VERSION",
30
38
  "StacklinkError",
stacklink/approvals.py ADDED
@@ -0,0 +1,118 @@
1
+ """Shared approvals. Decisions apply to the stored action; never submit that action again."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import time
6
+ from typing import Any, Literal, TypedDict, Iterator, AsyncIterator
7
+ from urllib.parse import quote
8
+
9
+
10
+ class ApprovalDecision(TypedDict):
11
+ requirement: Literal['customer', 'company']
12
+ decision: Literal['approve', 'decline']
13
+ expectedRevision: int
14
+
15
+
16
+ class CustomerApprovalGrantInput(TypedDict):
17
+ revision: int
18
+ name: str
19
+ toolKeys: list[str]
20
+ accountIds: list[str]
21
+ resources: list[dict[str, str | int | float | bool]]
22
+
23
+
24
+ class ApprovalPermissions(TypedDict):
25
+ canDecide: bool
26
+ canManage: bool
27
+ requirement: Literal['customer', 'company'] | None
28
+ reason: str | None
29
+
30
+
31
+ class ApprovalRecord(TypedDict):
32
+ id: str
33
+ projectId: str
34
+ sessionId: str | None
35
+ revision: int
36
+ status: Literal['pending', 'approved', 'applying', 'applied', 'apply_failed', 'denied', 'cancelled', 'expired']
37
+ audience: Literal['approvers', 'customer', 'customer-approvers']
38
+ requirements: list[dict[str, Any]]
39
+ permissions: ApprovalPermissions
40
+ execution: dict[str, Any]
41
+ deliveries: list[dict[str, Any]]
42
+
43
+
44
+ class ApprovalEvent(TypedDict):
45
+ sequence: str
46
+ approvalId: str
47
+ type: Literal['approval.request.updated']
48
+ data: dict[str, Any]
49
+ occurredAt: str
50
+
51
+
52
+ class ApprovalsResource:
53
+ def __init__(self, client, *, async_mode: bool = False):
54
+ self.client = client
55
+ self.async_mode = async_mode
56
+
57
+ def get_customer_grant(self, *, session_id: str):
58
+ """Server-only: identity is resolved from this authenticated customer session."""
59
+ return self.client.request('GET', '/v1/approvals/customer-grant', query={'sessionId': session_id})
60
+
61
+ def save_customer_grant(self, grant: CustomerApprovalGrantInput, *, session_id: str):
62
+ return self.client.request('PUT', '/v1/approvals/customer-grant', query={'sessionId': session_id}, json_body=grant)
63
+
64
+ def remove_customer_grant(self, *, session_id: str, revision: int):
65
+ return self.client.request('DELETE', '/v1/approvals/customer-grant', query={'sessionId': session_id}, json_body={'revision': revision})
66
+
67
+ @staticmethod
68
+ def _query(session_id: str, as_customer: bool) -> dict[str, Any]:
69
+ return {'sessionId': session_id, 'asCustomer': 'true' if as_customer else 'false'}
70
+
71
+ def list(self, *, session_id: str, as_customer: bool = False, **filters):
72
+ return self.client.request('GET', '/v1/approvals', query={**filters, **self._query(session_id, as_customer)})
73
+
74
+ def get(self, approval_id: str, *, session_id: str, as_customer: bool = False):
75
+ return self.client.request('GET', '/v1/approvals/' + quote(approval_id, safe=''), query=self._query(session_id, as_customer))
76
+
77
+ def decide(self, approval_id: str, decision: ApprovalDecision, *, session_id: str, as_customer: bool = False, reason: str | None = None):
78
+ """Call only after a current authorized person confirms the displayed exact proposal."""
79
+ body = dict(decision)
80
+ if reason is not None:
81
+ body['reason'] = reason
82
+ return self.client.request('POST', '/v1/approvals/' + quote(approval_id, safe='') + '/decide', query=self._query(session_id, as_customer), json_body=body)
83
+
84
+ def change(self, approval_id: str, *, session_id: str, expected_revision: int, message: str, as_customer: bool = False):
85
+ return self.client.request('POST', '/v1/approvals/' + quote(approval_id, safe='') + '/change', query=self._query(session_id, as_customer), json_body={'expectedRevision': expected_revision, 'message': message})
86
+
87
+ def events(self, *, session_id: str, after: str = '0', as_customer: bool = False, timeout_ms: int = 15_000):
88
+ return self.client.request('GET', '/v1/approvals/events', query={**self._query(session_id, as_customer), 'after': after}, timeout_ms=timeout_ms)
89
+
90
+ def observe(self, *, session_id: str, after: str = '0', as_customer: bool = False, timeout_ms: int = 300_000) -> Iterator[ApprovalEvent] | AsyncIterator[ApprovalEvent]:
91
+ """Save each sequence and pass it back as after when reconnecting. Close the iterator to stop."""
92
+ if self.async_mode:
93
+ return self._observe_async(session_id, after, as_customer, timeout_ms)
94
+ return self._observe_sync(session_id, after, as_customer, timeout_ms)
95
+
96
+ def _observe_sync(self, session_id: str, cursor: str, as_customer: bool, timeout_ms: int) -> Iterator[ApprovalEvent]:
97
+ deadline = time.monotonic() + timeout_ms / 1000
98
+ while time.monotonic() < deadline:
99
+ page = self.events(session_id=session_id, after=cursor, as_customer=as_customer, timeout_ms=max(1, min(15_000, int((deadline - time.monotonic()) * 1000))))
100
+ for event in page['events']:
101
+ if int(event['sequence']) <= int(cursor):
102
+ continue
103
+ cursor = event['sequence']
104
+ yield event
105
+ if len(page['events']) < 100:
106
+ time.sleep(max(0, min(1, deadline - time.monotonic())))
107
+
108
+ async def _observe_async(self, session_id: str, cursor: str, as_customer: bool, timeout_ms: int) -> AsyncIterator[ApprovalEvent]:
109
+ deadline = time.monotonic() + timeout_ms / 1000
110
+ while time.monotonic() < deadline:
111
+ page = await self.events(session_id=session_id, after=cursor, as_customer=as_customer, timeout_ms=max(1, min(15_000, int((deadline - time.monotonic()) * 1000))))
112
+ for event in page['events']:
113
+ if int(event['sequence']) <= int(cursor):
114
+ continue
115
+ cursor = event['sequence']
116
+ yield event
117
+ if len(page['events']) < 100:
118
+ await asyncio.sleep(max(0, min(1, deadline - time.monotonic())))
stacklink/bots.py ADDED
@@ -0,0 +1,266 @@
1
+ """Hosted bot management and durable runs for sync and async clients."""
2
+ from __future__ import annotations
3
+ from typing import Any
4
+ from contextlib import closing, aclosing
5
+ import asyncio
6
+ import base64
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import time
11
+ from datetime import datetime
12
+ import httpx
13
+ from .errors import StacklinkError
14
+ from urllib.parse import quote
15
+ from uuid import uuid4
16
+
17
+
18
+ def sign_bot_trigger_request(*, trigger_id: str, secret: str, body: str, request_id: str | None = None, now: float | None = None) -> dict[str, str]:
19
+ """Sign the exact JSON bytes. Reuse request_id when retrying the same event."""
20
+ timestamp = str(int(time.time() if now is None else now))
21
+ request_id = request_id or str(uuid4())
22
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
23
+ signed = json.dumps(["stacklink-trigger-v1", trigger_id, timestamp, request_id, digest], separators=(",", ":"), ensure_ascii=False)
24
+ signature = hmac.new(secret.encode("utf-8"), signed.encode("utf-8"), hashlib.sha256).hexdigest()
25
+ return {"content-type": "application/json", "stacklink-timestamp": timestamp, "stacklink-request-id": request_id, "stacklink-signature": signature}
26
+
27
+
28
+ def verify_bot_api_request(*, method: str, url: str, body: str, headers: dict[str, str], project_id: str, keys: dict[str, str | dict], consume_request_id, now: float | None = None, tolerance_seconds: int = 300):
29
+ """Verify raw request content; consume_request_id(id, expires_at) must atomically reject replays."""
30
+ normalized = {key.lower(): value for key, value in headers.items()}
31
+ def get(name):
32
+ return normalized.get("x-stacklink-" + name, "")
33
+ key_id, timestamp, request_id = get("key-id"), get("timestamp"), get("request-id")
34
+ secret, tenant, user = keys.get(key_id), get("tenant"), get("customer")
35
+ current_time = time.time() if now is None else now
36
+ if isinstance(secret, dict):
37
+ expiry = secret.get("validUntil")
38
+ if expiry is not None:
39
+ try:
40
+ if datetime.fromisoformat(expiry[:-1] + '+00:00' if expiry.endswith('Z') else expiry).timestamp() <= current_time:
41
+ return None
42
+ except (ValueError, TypeError):
43
+ return None
44
+ secret = secret.get("secret")
45
+ try:
46
+ seconds = int(timestamp)
47
+ except (ValueError, TypeError):
48
+ return None
49
+ if not isinstance(secret, str) or not secret or not tenant or not request_id or get("project") != project_id or abs(current_time - seconds) > tolerance_seconds:
50
+ return None
51
+ payload = json.dumps(["stacklink-api-v1", method.upper(), url, request_id, timestamp, key_id, project_id, tenant, user, hashlib.sha256(body.encode("utf-8")).hexdigest()], ensure_ascii=False, separators=(",", ":"))
52
+ expected = "v1," + base64.b64encode(hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()).decode("ascii")
53
+ if not hmac.compare_digest(get("signature"), expected):
54
+ return None
55
+ if not consume_request_id(request_id, seconds + tolerance_seconds):
56
+ return None
57
+ return {"tenantId": tenant, "userId": user or None, "requestId": request_id}
58
+
59
+
60
+ class BotsResource:
61
+ def __init__(self, client, *, async_mode: bool = False):
62
+ self.client = client
63
+ self.async_mode = async_mode
64
+
65
+ def _path(self, bot_id: str, run_id: str | None = None) -> str:
66
+ path = f"/v1/bots/{quote(bot_id, safe='')}"
67
+ return f"{path}/runs/{quote(run_id, safe='')}" if run_id else path
68
+
69
+ def apis(self):
70
+ return self.client.request("GET", "/v1/bots/apis")
71
+
72
+ def update_api(self, api_id: str, revision: int, api: dict):
73
+ return self.client.request("PATCH", "/v1/bots/apis/" + quote(api_id, safe=""), json_body={"revision": revision, "api": api})
74
+
75
+ def delete_api(self, api_id: str):
76
+ return self.client.request("DELETE", "/v1/bots/apis/" + quote(api_id, safe=""))
77
+
78
+ def webhook_secret(self, bot_id: str):
79
+ return self.client.request("POST", self._path(bot_id) + "/webhook-secret", json_body={})
80
+
81
+ def trigger_webhook(self, bot_id: str, trigger_id: str):
82
+ return self.client.request("POST", self._path(bot_id) + "/triggers/" + quote(trigger_id, safe="") + "/webhook", json_body={})
83
+
84
+ def trigger_admission(self, bot_id: str, trigger_id: str, admission_id: str):
85
+ return self.client.request("GET", self._path(bot_id) + "/triggers/" + quote(trigger_id, safe="") + "/admissions/" + quote(admission_id, safe=""))
86
+
87
+ def delete_customer_data(self, bot_id: str, session_id: str):
88
+ """Erase this bot's customer data; shared Memory and API credentials remain unchanged."""
89
+ return self.client.request("POST", self._path(bot_id) + "/customer-data/delete", json_body={"sessionId": session_id})
90
+
91
+ def api_signing_key(self, bot_id: str, api_id: str, *, action: str = "read", overlap_seconds: int = 86400, key_id: str | None = None):
92
+ body: dict = {"action": action}
93
+ if action == "prepare":
94
+ body["overlapSeconds"] = overlap_seconds
95
+ if action in ("activate", "cancel"):
96
+ body["keyId"] = key_id
97
+ return self.client.request("POST", self._path(bot_id) + "/apis/" + quote(api_id, safe="") + "/signing-key", json_body=body)
98
+
99
+ def models(self):
100
+ return self.client.request("GET", "/v1/bots/models")
101
+
102
+ def set_credential(self, bot_id: str, secret: str):
103
+ return self.client.request("POST", self._path(bot_id) + "/credentials", json_body={"secret": secret})
104
+
105
+ def deliveries(self, bot_id: str, run_id: str):
106
+ return self.client.request("GET", self._path(bot_id, run_id) + "/deliveries")
107
+
108
+ def retry_delivery(self, bot_id: str, run_id: str, delivery_id: str):
109
+ return self.client.request("POST", self._path(bot_id, run_id) + "/deliveries/retry", json_body={"deliveryId": delivery_id})
110
+
111
+ def list(self):
112
+ return self.client.request("GET", "/v1/bots")
113
+
114
+ def delete(self, bot_id: str):
115
+ return self.client.request("DELETE", self._path(bot_id))
116
+
117
+ def set_state(self, bot_id: str, state: str, *, pause_for: str = "manual", reason: str = ""):
118
+ return self.client.request("POST", self._path(bot_id) + "/state", json_body={"state": state, "pauseFor": pause_for, "reason": reason})
119
+
120
+ def create(self, name: str):
121
+ return self.client.request("POST", "/v1/bots", json_body={"name": name})
122
+
123
+ def set_customer_credential(self, bot_id: str, *, api_id: str, session_id: str, secret: str):
124
+ return self.client.request("POST", self._path(bot_id) + "/credentials", json_body={"apiId": api_id, "sessionId": session_id, "secret": secret})
125
+
126
+ def get(self, bot_id: str):
127
+ return self.client.request("GET", self._path(bot_id))
128
+
129
+ def update(self, bot_id: str, revision: int, patch: dict[str, Any]):
130
+ return self.client.request("PATCH", self._path(bot_id), json_body={"revision": revision, "patch": patch})
131
+
132
+ def publish(self, bot_id: str, revision: int, channels: dict[str, bool]):
133
+ return self.client.request("POST", self._path(bot_id) + "/publish", json_body={"revision": revision, "channels": channels})
134
+
135
+ def run(self, bot_id: str, *, session_id: str, message: str, variables: dict[str, Any] | None = None, conversation_id: str | None = None, run_id: str | None = None):
136
+ body: dict[str, Any] = {"sessionId": session_id, "message": message, "variables": variables or {}, "runId": run_id or str(uuid4())}
137
+ if conversation_id:
138
+ body["conversationId"] = conversation_id
139
+ return self.client.request("POST", self._path(bot_id) + "/runs", json_body=body)
140
+
141
+ def get_run(self, bot_id: str, run_id: str):
142
+ return self.client.request("GET", self._path(bot_id, run_id))
143
+
144
+ def wait(self, bot_id: str, run_id: str, *, total_timeout_ms: int = 300_000):
145
+ """Return the finished run, or a run waiting for a person's approval or connection."""
146
+ if total_timeout_ms <= 0:
147
+ raise ValueError("total_timeout_ms must be positive")
148
+ return self._wait_async(bot_id, run_id, total_timeout_ms) if self.async_mode else self._wait_sync(bot_id, run_id, total_timeout_ms)
149
+
150
+ def _wait_sync(self, bot_id, run_id, total_timeout_ms):
151
+ deadline = time.monotonic() + total_timeout_ms / 1000
152
+ while time.monotonic() < deadline:
153
+ run = self.get_run(bot_id, run_id)
154
+ if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
155
+ return run
156
+ time.sleep(min(1, max(0, deadline - time.monotonic())))
157
+ 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})
158
+
159
+ async def _wait_async(self, bot_id, run_id, total_timeout_ms):
160
+ deadline = time.monotonic() + total_timeout_ms / 1000
161
+ while time.monotonic() < deadline:
162
+ run = await self.get_run(bot_id, run_id)
163
+ if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
164
+ return run
165
+ await asyncio.sleep(min(1, max(0, deadline - time.monotonic())))
166
+ 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})
167
+
168
+ def events(self, bot_id: str, run_id: str, *, after: str = "0"):
169
+ return self.client.request("GET", self._path(bot_id, run_id) + "/events", query={"after": after})
170
+
171
+ def stream(self, bot_id: str, run_id: str, *, after: str = "0", total_timeout_ms: int = 300_000):
172
+ method = self._stream_async if self.async_mode else self._stream_sync
173
+ return method(bot_id, run_id, after=after, total_timeout_ms=total_timeout_ms)
174
+
175
+ def _stream_sync(self, bot_id, run_id, *, after, total_timeout_ms):
176
+ deadline = time.monotonic() + total_timeout_ms / 1000
177
+ cursor = after
178
+ while True:
179
+ remaining = _remaining(deadline, run_id, cursor)
180
+ try:
181
+ with closing(self.client.request_sse("GET", self._path(bot_id, run_id) + "/events/stream", headers={"Last-Event-ID": cursor}, total_timeout_ms=min(remaining, 30_000))) as stream:
182
+ for envelope in stream:
183
+ event = envelope.get("data")
184
+ if not isinstance(event, dict) or "sequence" not in event:
185
+ continue
186
+ _validate_event(event, run_id)
187
+ if int(event["sequence"]) <= int(cursor):
188
+ continue
189
+ cursor = event["sequence"]
190
+ yield event
191
+ if _terminal(event):
192
+ return
193
+ except (StacklinkError, httpx.TransportError) as error:
194
+ if not _retryable(error):
195
+ raise
196
+ page = self.events(bot_id, run_id, after=cursor)
197
+ for event in page["events"]:
198
+ _validate_event(event, run_id)
199
+ if int(event["sequence"]) <= int(cursor):
200
+ continue
201
+ cursor = event["sequence"]
202
+ yield event
203
+ if _terminal(event):
204
+ return
205
+ if page.get("terminal") and not page["events"]:
206
+ return
207
+ if len(page["events"]) < 200:
208
+ time.sleep(min(1, _remaining(deadline, run_id, cursor) / 1000))
209
+
210
+ async def _stream_async(self, bot_id, run_id, *, after, total_timeout_ms):
211
+ deadline = time.monotonic() + total_timeout_ms / 1000
212
+ cursor = after
213
+ while True:
214
+ remaining = _remaining(deadline, run_id, cursor)
215
+ try:
216
+ async with aclosing(self.client.request_sse("GET", self._path(bot_id, run_id) + "/events/stream", headers={"Last-Event-ID": cursor}, total_timeout_ms=min(remaining, 30_000))) as stream:
217
+ async for envelope in stream:
218
+ event = envelope.get("data")
219
+ if not isinstance(event, dict) or "sequence" not in event:
220
+ continue
221
+ _validate_event(event, run_id)
222
+ if int(event["sequence"]) <= int(cursor):
223
+ continue
224
+ cursor = event["sequence"]
225
+ yield event
226
+ if _terminal(event):
227
+ return
228
+ except (StacklinkError, httpx.TransportError) as error:
229
+ if not _retryable(error):
230
+ raise
231
+ page = await self.events(bot_id, run_id, after=cursor)
232
+ for event in page["events"]:
233
+ _validate_event(event, run_id)
234
+ if int(event["sequence"]) <= int(cursor):
235
+ continue
236
+ cursor = event["sequence"]
237
+ yield event
238
+ if _terminal(event):
239
+ return
240
+ if page.get("terminal") and not page["events"]:
241
+ return
242
+ if len(page["events"]) < 200:
243
+ await asyncio.sleep(min(1, _remaining(deadline, run_id, cursor) / 1000))
244
+
245
+ def cancel(self, bot_id: str, run_id: str):
246
+ return self.client.request("POST", self._path(bot_id, run_id) + "/stop", json_body={})
247
+
248
+
249
+ def _terminal(event):
250
+ return event.get("type") in ("run.completed", "run.failed", "run.stopped")
251
+
252
+
253
+ def _validate_event(event, run_id):
254
+ if event.get("runId") != run_id:
255
+ raise StacklinkError("Bot event belongs to a different run.", status_code=502, code="bot_event_invalid")
256
+
257
+
258
+ def _retryable(error):
259
+ return isinstance(error, httpx.TransportError) or (isinstance(error, StacklinkError) and error.status_code in (408, 429, 502, 503, 504) and error.code != "bot_event_invalid")
260
+
261
+
262
+ def _remaining(deadline, run_id, cursor):
263
+ remaining = int((deadline - time.monotonic()) * 1000)
264
+ if remaining <= 0:
265
+ raise StacklinkError("Bot observation timed out. Resume the same run from the last sequence.", status_code=408, code="sdk_stream_timeout", details={"runId": run_id, "after": cursor})
266
+ return remaining
stacklink/client.py CHANGED
@@ -19,6 +19,8 @@ from .api_keys import ApiKeysResource
19
19
  from .artifacts import ArtifactsResource
20
20
  from .auth_configs import AuthConfigsResource
21
21
  from .billing import BillingResource
22
+ from .bots import BotsResource
23
+ from .approvals import ApprovalsResource
22
24
  from .connected_accounts import ConnectedAccountsResource
23
25
  from .errors import RateLimitError, StacklinkError
24
26
  from .executions import ExecutionsResource
@@ -35,7 +37,7 @@ from .types import Json, Transport
35
37
  from .webhooks import WebhooksResource
36
38
  from .workbench import WorkbenchResource
37
39
 
38
- STACKLINK_PYTHON_SDK_VERSION = "0.2.2"
40
+ STACKLINK_PYTHON_SDK_VERSION = "0.3.10"
39
41
  STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
40
42
 
41
43
 
@@ -52,6 +54,8 @@ class Stacklink:
52
54
  max_attempts: int = 3,
53
55
  http_client: httpx.Client | None = None,
54
56
  ):
57
+ self.bots = BotsResource(self)
58
+ self.approvals = ApprovalsResource(self)
55
59
  self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
56
60
  if not self.api_key:
57
61
  raise StacklinkError("STACKLINK_API_KEY is required.")
@@ -187,6 +191,7 @@ class Stacklink:
187
191
  headers: Mapping[str, str] | None = None,
188
192
  total_timeout_ms: int | None = None,
189
193
  idle_timeout_ms: int = 30_000,
194
+ cancel_event: threading.Event | None = None,
190
195
  ) -> Iterator[dict[str, Any]]:
191
196
  timeout_seconds = _positive_timeout_seconds(total_timeout_ms or int(self.timeout * 1000))
192
197
  idle_timeout_seconds = _positive_timeout_seconds(idle_timeout_ms)
@@ -259,7 +264,7 @@ class Stacklink:
259
264
  raise _stream_timeout_error() from error
260
265
  raise StacklinkError("Stacklink stream stopped receiving data.", status_code=408, code="sdk_stream_idle_timeout") from error
261
266
 
262
- yield from _observe_sync_stream(receive, deadline)
267
+ yield from _observe_sync_stream(receive, deadline, cancel_event)
263
268
 
264
269
  def _perform_request(
265
270
  self,
@@ -362,7 +367,7 @@ class Stacklink:
362
367
  details = body.get("details") if isinstance(body, dict) else None
363
368
  if status == 429:
364
369
  raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
365
- raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details)
370
+ raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
366
371
 
367
372
  def _parse_sse_lines(self, lines) -> Iterator[dict[str, Any]]:
368
373
  event_id = None
@@ -420,6 +425,8 @@ class AsyncStacklink:
420
425
  max_attempts: int = 3,
421
426
  http_client: httpx.AsyncClient | None = None,
422
427
  ):
428
+ self.bots = BotsResource(self, async_mode=True)
429
+ self.approvals = ApprovalsResource(self, async_mode=True)
423
430
  self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
424
431
  if not self.api_key:
425
432
  raise StacklinkError("STACKLINK_API_KEY is required.")
@@ -770,7 +777,7 @@ class _StreamLines:
770
777
  return lines
771
778
 
772
779
 
773
- def _observe_sync_stream(source: Callable[[threading.Event], Iterator[_StreamValue]], deadline: float) -> Iterator[_StreamValue]:
780
+ def _observe_sync_stream(source: Callable[[threading.Event], Iterator[_StreamValue]], deadline: float, cancel_event: threading.Event | None = None) -> Iterator[_StreamValue]:
774
781
  # A blocking httpx read has an inactivity timeout, not a total deadline.
775
782
  # Keep it in one reader with bounded backpressure so the caller's observation
776
783
  # can end on time. The reader owns and closes the response; it never retries.
@@ -804,13 +811,15 @@ def _observe_sync_stream(source: Callable[[threading.Event], Iterator[_StreamVal
804
811
  threading.Thread(target=context.run, args=(read,), daemon=True, name="stacklink-stream-reader").start()
805
812
  try:
806
813
  while True:
814
+ if cancel_event is not None and cancel_event.is_set():
815
+ raise StacklinkError("Execution observation was cancelled.", code="sdk_stream_cancelled")
807
816
  remaining = deadline - time.monotonic()
808
817
  if remaining <= 0:
809
818
  raise _stream_timeout_error()
810
819
  try:
811
- kind, value = messages.get(timeout=remaining)
812
- except queue.Empty as error:
813
- raise _stream_timeout_error() from error
820
+ kind, value = messages.get(timeout=min(remaining, 0.05) if cancel_event is not None else remaining)
821
+ except queue.Empty:
822
+ continue
814
823
  if kind == "done":
815
824
  return
816
825
  if kind == "error":
@@ -867,7 +876,7 @@ def _handle_response(status: int, headers: Mapping[str, str], body: Json) -> Jso
867
876
  details = body.get("details") if isinstance(body, dict) else None
868
877
  if status == 429:
869
878
  raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
870
- raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details)
879
+ raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
871
880
 
872
881
 
873
882
  def _timeout_error(timeout_seconds: float, request_id: str) -> StacklinkError:
stacklink/errors.py CHANGED
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  class StacklinkError(Exception):
4
- def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None):
4
+ def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None, headers=None):
5
5
  super().__init__(message)
6
6
  self.status_code = status_code
7
7
  self.code = code
8
8
  self.details = details
9
+ self.headers = headers or {}
9
10
 
10
11
 
11
12
  class RateLimitError(StacklinkError):
stacklink/executions.py CHANGED
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import threading
4
+
3
5
  from typing import Any, TypedDict, cast
4
6
 
5
7
 
@@ -47,6 +49,7 @@ class ExecutionsResource:
47
49
  after_sequence: str | None = None,
48
50
  limit: int = 200,
49
51
  total_timeout_ms: int = 30_000,
52
+ cancel_event: threading.Event | None = None,
50
53
  ):
51
54
  return self.client.request_sse(
52
55
  "GET",
@@ -58,6 +61,7 @@ class ExecutionsResource:
58
61
  },
59
62
  headers={"Last-Event-ID": after_sequence} if after_sequence else None,
60
63
  total_timeout_ms=total_timeout_ms,
64
+ cancel_event=cancel_event,
61
65
  )
62
66
 
63
67
  def wait(self, session_id: str, execution_kind: str, execution_id: str, body: dict[str, Any] | None = None):
stacklink/webhooks.py CHANGED
@@ -1,6 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
+ import base64
5
+ import hashlib
6
+ import hmac
7
+ import time
8
+
9
+
10
+ def verify_webhook_signature(*, body: str, secret: str, id: str, timestamp: str, signature: str, now: float | None = None, tolerance_seconds: int = 300) -> bool:
11
+ """Verify the original callback body and destination-specific signing secret."""
12
+ try:
13
+ seconds = int(timestamp)
14
+ except (TypeError, ValueError):
15
+ return False
16
+ if abs((time.time() if now is None else now) - seconds) > tolerance_seconds:
17
+ return False
18
+ expected = base64.b64encode(hmac.new(secret.encode("utf-8"), f"{id}.{timestamp}.{body}".encode("utf-8"), hashlib.sha256).digest()).decode("ascii")
19
+ for entry in signature.split(" "):
20
+ version, separator, candidate = entry.partition(",")
21
+ if separator and version == "v1" and hmac.compare_digest(candidate, expected):
22
+ return True
23
+ return False
4
24
 
5
25
 
6
26
  class WebhooksResource:
stacklink/workbench.py CHANGED
@@ -2,6 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import base64
4
4
  import time
5
+ import threading
6
+ from email.utils import parsedate_to_datetime
7
+ import httpx
5
8
  from typing import Any, Iterator, Literal, Mapping, TypedDict, cast
6
9
  from urllib.error import HTTPError
7
10
  from urllib.request import Request, urlopen
@@ -14,6 +17,7 @@ WorkbenchOutputMode = Literal["inline", "preview", "events", "artifact"]
14
17
  WorkbenchExecutionKind = Literal["workbench_code", "workbench_bash"]
15
18
  WorkbenchReplayEnvironmentMode = Literal["require_match", "restore_snapshot", "current"]
16
19
  WorkbenchExecutionStatus = Literal[
20
+ "waiting_for_approval",
17
21
  "queued",
18
22
  "started",
19
23
  "running",
@@ -459,6 +463,9 @@ class WorkbenchExecutionStateResponse(TypedDict, total=False):
459
463
 
460
464
 
461
465
  class WorkbenchExecutionResponse(TypedDict, total=False):
466
+ approvalRequestId: str
467
+ executed: bool
468
+ nextAction: Literal["wait_for_approval"]
462
469
  workbenchSession: WorkbenchSession
463
470
  executionKind: WorkbenchExecutionKind
464
471
  executionId: str
@@ -478,41 +485,70 @@ class WorkbenchRun:
478
485
  self.execution_id = str(response["executionId"])
479
486
  self.execution_kind = str(response["executionKind"])
480
487
 
481
- def events(self, *, stream_timeout_ms: int = 30_000, limit: int = 200) -> Iterator[WorkbenchExecutionEvent]:
482
- after_sequence: str | None = None
483
- while True:
488
+ def events(self, *, stream_timeout_ms: int = 30_000, limit: int = 200,
489
+ total_timeout_ms: int = 3_600_000, max_reconnect_attempts: int = 8,
490
+ after_sequence: str | None = None, cancel_event: threading.Event | None = None) -> Iterator[WorkbenchExecutionEvent]:
491
+ if min(stream_timeout_ms, total_timeout_ms, max_reconnect_attempts) <= 0:
492
+ raise ValueError("Stream timeouts and reconnect attempts must be positive.")
493
+ deadline = time.monotonic() + total_timeout_ms / 1000
494
+ cancelled = cancel_event or threading.Event()
495
+ failures = 0
496
+ while not cancelled.is_set() and time.monotonic() < deadline:
497
+ delay = min(1.0, 0.1 * 2 ** min(failures, 4))
484
498
  try:
485
499
  stream = self.client.executions.stream(
486
- self.platform_session_id,
487
- self.execution_kind,
488
- self.execution_id,
489
- after_sequence=after_sequence,
490
- limit=limit,
491
- total_timeout_ms=stream_timeout_ms,
500
+ self.platform_session_id, self.execution_kind, self.execution_id,
501
+ after_sequence=after_sequence, limit=limit, cancel_event=cancelled,
502
+ total_timeout_ms=min(stream_timeout_ms, max(1, int((deadline - time.monotonic()) * 1000))),
492
503
  )
493
- for message in stream:
494
- payload = message.get("data") if isinstance(message, dict) else None
495
- if not isinstance(payload, dict):
496
- continue
497
- events = payload.get("events")
498
- if isinstance(events, list):
499
- for raw_event in events:
500
- if not isinstance(raw_event, dict):
504
+ try:
505
+ for message in stream:
506
+ if cancelled.is_set():
507
+ break
508
+ payload = message.get("data") if isinstance(message, dict) else None
509
+ if not isinstance(payload, dict) or not isinstance(payload.get("events"), list):
510
+ raise StacklinkError("Execution stream returned an invalid event page.", code="execution_event_invalid")
511
+ failures = 0
512
+ for event in payload["events"]:
513
+ if not isinstance(event, dict) or not isinstance(event.get("sequence"), (str, int)):
514
+ raise StacklinkError("Execution event has no durable sequence.", code="execution_event_invalid")
515
+ sequence = str(event["sequence"])
516
+ if after_sequence is not None and int(sequence) <= int(after_sequence):
501
517
  continue
502
- event = cast(WorkbenchExecutionEvent, raw_event)
503
- yield event
504
- sequence = event.get("sequence")
505
- if isinstance(sequence, (str, int)):
506
- after_sequence = str(sequence)
507
- next_cursor = payload.get("nextCursor")
508
- if isinstance(next_cursor, str):
509
- after_sequence = next_cursor
510
- if payload.get("terminal") is True:
511
- return
512
- except StacklinkError as error:
513
- if error.code != "sdk_stream_timeout":
514
- raise
515
- # A bounded connection is resumed from the last durable cursor.
518
+ after_sequence = sequence
519
+ yield cast(WorkbenchExecutionEvent, event)
520
+ next_cursor = payload.get("nextCursor")
521
+ if isinstance(next_cursor, str) and (after_sequence is None or int(next_cursor) > int(after_sequence)):
522
+ after_sequence = next_cursor
523
+ if payload.get("terminal") is True:
524
+ return
525
+ finally:
526
+ close = getattr(stream, "close", None)
527
+ if close:
528
+ close()
529
+ except (StacklinkError, httpx.TransportError) as error:
530
+ if isinstance(error, StacklinkError):
531
+ if error.code not in {"sdk_stream_timeout", "sdk_stream_idle_timeout"} and error.status_code not in {408, 429, 502, 503, 504}:
532
+ raise
533
+ headers = getattr(error, "headers", {})
534
+ retry_after = next((value for key, value in headers.items() if key.lower() == "retry-after"), None)
535
+ if retry_after is not None:
536
+ try:
537
+ delay = max(0.0, float(retry_after))
538
+ except ValueError:
539
+ try:
540
+ delay = max(0.0, parsedate_to_datetime(retry_after).timestamp() - time.time())
541
+ except (ValueError, TypeError, OverflowError):
542
+ pass
543
+ failures += 1
544
+ if failures > max_reconnect_attempts:
545
+ raise StacklinkError("Execution observation could not reconnect. Resume the same execution.", code="sdk_stream_reconnect_exhausted", details={"executionId": self.execution_id, "afterSequence": after_sequence})
546
+ if delay >= deadline - time.monotonic():
547
+ break
548
+ cancelled.wait(delay)
549
+ raise StacklinkError("Execution observation stopped; the operation was not resubmitted or cancelled.",
550
+ code="sdk_stream_cancelled" if cancelled.is_set() else "sdk_stream_observation_timeout",
551
+ details={"executionId": self.execution_id, "afterSequence": after_sequence})
516
552
 
517
553
  def wait(self, *, wait_timeout_ms: int | None = None) -> WorkbenchExecutionStateResponse:
518
554
  body = {
@@ -547,7 +583,8 @@ def _wait_for_workbench_response(
547
583
  execution_id = accepted.get("executionId")
548
584
  execution_kind = accepted.get("executionKind")
549
585
  if (
550
- not isinstance(session_id, str)
586
+ accepted.get("status") == "waiting_for_approval"
587
+ or not isinstance(session_id, str)
551
588
  or not isinstance(execution_id, str)
552
589
  or execution_kind not in {"workbench_code", "workbench_bash"}
553
590
  ):
@@ -562,6 +599,7 @@ def _wait_for_workbench_response(
562
599
  ))
563
600
  execution = waited.get("execution")
564
601
  if not isinstance(execution, dict) or execution.get("status") not in {
602
+ "waiting_for_approval",
565
603
  "completed",
566
604
  "failed",
567
605
  "cancelled",
@@ -576,6 +614,11 @@ def _wait_for_workbench_response(
576
614
  "execution": cast(WorkbenchExecutionLog, execution),
577
615
  "executionLog": cast(WorkbenchExecutionLog, execution),
578
616
  }
617
+ if isinstance(execution.get("approvalRequestId"), str):
618
+ response["approvalRequestId"] = execution["approvalRequestId"]
619
+ if execution.get("status") == "waiting_for_approval":
620
+ response["executed"] = False
621
+ response["nextAction"] = "wait_for_approval"
579
622
  output_mode = execution.get("outputMode")
580
623
  if output_mode in {"inline", "preview", "events", "artifact"}:
581
624
  response["outputMode"] = cast(WorkbenchOutputMode, output_mode)
stacklink/x_chat.py ADDED
@@ -0,0 +1,138 @@
1
+ """Optional native X Chat for bot-owned keys on the customer's infrastructure.
2
+
3
+ This module performs no HTTP requests. It never accepts a personal Chat PIN.
4
+ Persist key state in your secret manager, never in tool inputs or execution logs.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Callable, Mapping, Any
10
+
11
+ from chat_xdk import Chat
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class XBotChatKeyState:
16
+ account_id: str
17
+ bot_user_id: str
18
+ key_version: str
19
+ key_blob: bytes = field(repr=False)
20
+
21
+
22
+ class XBotChat:
23
+ """Binds the official native crypto engine to one bot and registered key.
24
+
25
+ ``persist`` must replace the secret for the exact (account_id, bot_user_id)
26
+ pair before returning. The registered version is part of its stored value.
27
+ """
28
+
29
+ def __init__(self, account_id: str, bot_user_id: str, chat: Chat,
30
+ persist: Callable[[XBotChatKeyState], None], key_version: str,
31
+ registration: dict[str, Any] | None = None):
32
+ if not account_id or not bot_user_id or not key_version:
33
+ raise ValueError("Bind bot keys to an exact account, X user and key version.")
34
+ self.account_id = account_id
35
+ self.bot_user_id = bot_user_id
36
+ self.registration = registration
37
+ self._chat = chat
38
+ self._persist = persist
39
+ self._key_version = key_version
40
+ self._bound = False
41
+ self._closed = False
42
+ chat.set_reject_unverified(True)
43
+ chat.set_cache_keys(True)
44
+
45
+ def _save(self) -> None:
46
+ blob = self._chat.export_keys()
47
+ try:
48
+ self._persist(XBotChatKeyState(self.account_id, self.bot_user_id,
49
+ self._key_version, bytes(blob)))
50
+ finally:
51
+ blob[:] = bytes(len(blob))
52
+
53
+ def bind_registered_identity(self, record: Mapping[str, str]) -> None:
54
+ """Bind a fresh public-key record returned by X for this exact bot.
55
+
56
+ The identity and signing keys must both match the locally held keys.
57
+ Only public metadata is passed here; secure-backup tokens are not needed.
58
+ """
59
+ if self._closed:
60
+ raise RuntimeError("This X bot Chat session is closed.")
61
+ self._bound = False
62
+ if record.get("user_id") != self.bot_user_id:
63
+ raise ValueError("The X public-key record belongs to a different bot.")
64
+ version = record.get("public_key_version")
65
+ if not version:
66
+ raise ValueError("X must return the registered public-key version.")
67
+ challenge = b"stacklink.x_bot_chat.registered_signing_key"
68
+ if not self._chat.matches_registered_key(record.get("public_key", "")) or not self._chat.verify(
69
+ record.get("signing_public_key", ""), self._chat.sign(challenge), challenge):
70
+ raise ValueError("The X public keys do not match this bot's stored keys.")
71
+ if not self._chat.verify_key_binding(record["public_key"], record["signing_public_key"],
72
+ record["identity_public_key_signature"]):
73
+ raise ValueError("The X public-key binding is invalid.")
74
+ self._key_version = version
75
+ self._chat.set_identity(self.bot_user_id, version)
76
+ self._save()
77
+ self._bound = True
78
+
79
+ @property
80
+ def crypto(self) -> Chat:
81
+ """The official Chat XDK, including messages, groups, events and media."""
82
+ if self._closed or not self._bound:
83
+ raise RuntimeError("Bind the registered bot identity before using Chat.")
84
+ return self._chat
85
+
86
+ def close(self) -> None:
87
+ self._closed = True
88
+ self._bound = False
89
+ self._chat.lock()
90
+
91
+ def __enter__(self) -> XBotChat:
92
+ return self
93
+
94
+ def __exit__(self, *_: object) -> None:
95
+ self.close()
96
+
97
+
98
+ def create_x_bot_chat(account_id: str, bot_user_id: str,
99
+ persist: Callable[[XBotChatKeyState], None]) -> XBotChat:
100
+ """Generate and persist keys before submitting ``registration`` to X.
101
+
102
+ After registration, fetch the bot's public-key record and call
103
+ ``bind_registered_identity``. An uncertain registration must be reconciled
104
+ against that record; do not generate a second set of keys automatically.
105
+ """
106
+ chat = Chat()
107
+ try:
108
+ registration = chat.generate_keypairs()
109
+ key = registration.public_key
110
+ body = {"version": registration.version, "generate_version": registration.generate_version,
111
+ "public_key": {name: getattr(key, name) for name in (
112
+ "identity_public_key_signature", "public_key", "public_key_fingerprint",
113
+ "registration_method", "signing_public_key", "signing_public_key_signature")}}
114
+ session = XBotChat(account_id, bot_user_id, chat, persist, str(registration.version), body)
115
+ session._save()
116
+ return session
117
+ except BaseException:
118
+ chat.lock()
119
+ raise
120
+
121
+
122
+ def restore_x_bot_chat(account_id: str, bot_user_id: str, state: XBotChatKeyState,
123
+ record: Mapping[str, str],
124
+ persist: Callable[[XBotChatKeyState], None]) -> XBotChat:
125
+ """Load an exact bot key blob and verify it against the current X record."""
126
+ if state.account_id != account_id or state.bot_user_id != bot_user_id:
127
+ raise ValueError("Stored Chat keys belong to a different account or bot.")
128
+ if state.key_version != record.get("public_key_version"):
129
+ raise ValueError("The registered bot key version has changed.")
130
+ chat = Chat()
131
+ try:
132
+ chat.import_keys(state.key_blob, version=state.key_version)
133
+ session = XBotChat(account_id, bot_user_id, chat, persist, state.key_version)
134
+ session.bind_registered_identity(record)
135
+ return session
136
+ except BaseException:
137
+ chat.lock()
138
+ raise
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: stacklink-sdk
3
+ Version: 0.3.10
4
+ Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
+ Author: Stacklink
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Documentation, https://docs.stacklink.in/docs/quickstart
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: httpx[http2]<1,>=0.27
12
+ Provides-Extra: x-chat
13
+ Requires-Dist: chatxdk==0.5.0; extra == "x-chat"
14
+ Provides-Extra: openai
15
+ Requires-Dist: openai<3,>=2.0; extra == "openai"
16
+ Provides-Extra: openai-agents
17
+ Requires-Dist: openai-agents<0.19,>=0.18; extra == "openai-agents"
18
+ Provides-Extra: claude-agent
19
+ Requires-Dist: claude-agent-sdk<0.3,>=0.2; extra == "claude-agent"
20
+ Dynamic: license-file
21
+
22
+ # Stacklink Python SDK
23
+
24
+ ## X Chat bot keys
25
+
26
+ Install `stacklink-sdk[x-chat]` for the official native Chat XDK. Import
27
+ `create_x_bot_chat`, `restore_x_bot_chat` and `XBotChatKeyState` from
28
+ `stacklink.x_chat`. This optional module runs on your bot infrastructure and makes
29
+ no network requests. Personal X Chat remains in the browser SDK; do not pass a
30
+ person's PIN or private keys to this module or to Stacklink's backend.
31
+
32
+ Call `create_x_bot_chat(account_id, bot_user_id, persist)` once. The `persist`
33
+ callback must store the opaque key state in your secret manager, replacing the
34
+ value for that exact account and bot before returning. Submit the returned
35
+ `registration` public-key payload through X's bot bearer-token API. Fetch the
36
+ registered public-key record, add its exact `user_id`, and call
37
+ `bind_registered_identity(record)`. The helper checks both public keys and their
38
+ binding before exposing `crypto`, the official native XDK for messages, groups,
39
+ reactions, history and encrypted media. Close the session after use.
40
+
41
+ On restart, call `restore_x_bot_chat(account_id, bot_user_id, stored_state,
42
+ current_public_record, persist)`. Account, bot, key version and both keys must
43
+ match. An uncertain registration must be reconciled using X's current public
44
+ record; do not generate replacement keys automatically. Keep the bot bearer
45
+ token retrieved by `x.getStoredBotToken` in the same customer-owned secret
46
+ manager, separately from its private key blob. Bot bearer tokens are separate
47
+ from a person's OAuth2 grant. This helper supplies local cryptography; it does
48
+ not turn a person's Stacklink connection into a bot connection.
49
+
50
+ Stacklink supplies executable, governed tools to an agent harness. The selected harness owns the model call, tool loop, and final answer.
51
+
52
+ Install the public package with the harness extra you use:
53
+
54
+ ```bash
55
+ uv add 'stacklink-sdk[openai-agents]'
56
+ ```
57
+
58
+ or:
59
+
60
+ ```bash
61
+ uv add 'stacklink-sdk[claude-agent]'
62
+ ```
63
+
64
+ The complete guides and certified recipes live at [docs.stacklink.in](https://docs.stacklink.in/docs/quickstart). PyPI is the package delivery system, not the primary documentation surface. Stacklink does not display registry download badges in its product or documentation.
65
+
66
+ ## Certified agent harnesses
67
+
68
+ | Harness | Stacklink adapter |
69
+ |---|---|
70
+ | OpenAI Agents SDK | `create_stacklink_openai_agents_tools(session)` |
71
+ | Claude Agent SDK | `create_stacklink_claude_agent_tool_config(session)` |
72
+
73
+ Both start with one explicit Stacklink session:
74
+
75
+ ```python
76
+ import os
77
+ from stacklink import Stacklink
78
+
79
+ stacklink = Stacklink(
80
+ api_key=os.environ['STACKLINK_API_KEY'],
81
+ base_url=os.getenv('STACKLINK_BASE_URL', 'https://api.stacklink.in'),
82
+ )
83
+ session = stacklink.sessions.create_bound({
84
+ 'externalTenantId': 'acme',
85
+ 'externalUserId': 'user_123',
86
+ 'modelSurface': {'mode': 'router'},
87
+ })
88
+
89
+ try:
90
+ # Pass the selected Stacklink adapter output to your agent harness.
91
+ pass
92
+ finally:
93
+ session.close()
94
+ ```
95
+
96
+ The complete OpenAI Agents and Claude Agent examples shown by the Stacklink Overview are generated from the executable files under `examples/sdk/`. The dashboard, documentation, and certification tests all read those same files.
97
+
98
+ The raw OpenAI `with_stacklink_memory` helper remains a separate Memory integration. It is not the agent-tool integration and is not part of basic Overview onboarding.
99
+
100
+ Environment variables are `STACKLINK_API_KEY`, optional `STACKLINK_BASE_URL`, and the model-provider credentials required by the selected harness. The SDK is proprietary and is provided under the applicable Stacklink agreement.
101
+
102
+ ## Accepted operations, approvals and retries
103
+
104
+ An execution response can be pending. Keep its `executionKind`, `executionId`, session ID and retry key in your application. Read or wait for that operation using the existing execution APIs. A lost connection does not submit a second action, and closing the application session can remove the authority required to execute pending work.
105
+
106
+ Retry keys are case-sensitive and accept up to 240 characters. Tool and batch requests have separate key namespaces. Reuse a key only for the exact same request in the same SDK session. Changed inputs return `409 idempotency_key_reused`; an expired stored result returns `410 execution_result_expired`. Neither response authorizes submitting the write again.
107
+
108
+ For `waiting_for_approval`, read the shared request using `client.approvals` and `platform_approvals:read`. A decision also needs `platform_approvals:manage` and the authority required by that request. A server key alone cannot approve on behalf of a company person; eligible customer confirmation uses the verified session and its scoped grant. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
109
+
110
+ Use execution cancellation for an explicit Stop request. Losing a stream only stops observation. Vercel and LangChain adapters forward their supported abort signal; other adapters keep the structured operation identity available for explicit cancellation. MCP progress reports actual stages, not invented percentages.
111
+
112
+ Streaming has a total observation deadline and a separate inactivity timeout. A timeout raises `sdk_stream_timeout` without repeating the action; use its original operation ID to resume observation. Stream responses are limited to 8 MiB, including JSON responses to streaming requests. The synchronous client keeps blocking reads in a bounded reader and releases the response when that read finishes or reaches its inactivity timeout. Custom transports must honor the timeout passed to them. MCP calls return once all matching responses arrive, even if the server keeps the stream open.
113
+
114
+ ## Hosted Bots (0.3.2)
115
+
116
+ Bots are saved, published agents with project-scoped configuration and durable runs. Use a server-held project API key with `bots:manage` to create/update/publish, and `bots:run` to invoke. Supply a session established from your authenticated customer; input variables do not grant identity or access. Both SDKs expose run status, replayable events, cancellation and callback delivery recovery. A callback failure does not rerun the model.
117
+
118
+ ```python
119
+ bot = stacklink.bots.get(bot_id)
120
+ run = stacklink.bots.run(bot_id, session_id=session.id, message="Where is my order?", variables={"orderNumber": "4821"})
121
+ for event in stacklink.bots.stream(bot_id, run["id"]):
122
+ print(event)
123
+ result = stacklink.bots.get_run(bot_id, run["id"])
124
+ print(result["output"] or result["finalText"])
125
+ ```
126
+
127
+ The async client provides the same operations with `await` and `async for`.
128
+
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
+
131
+ ## Shared approvals (0.3.3)
132
+
133
+ `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.
134
+
135
+ All enabled notification channels use the same stored request. Decision, notification delivery and execution have separate states. See the [approval guide](https://docs.stacklink.in/docs/reference/sdk-python#shared-approvals) for request and lifecycle details.
136
+
137
+ When project settings require Workbench code approval, a foreground code/bash call returns `waiting_for_approval`, `approvalRequestId`, and its original execution ID immediately. No runtime sandbox is allocated for that wait. After the authorized person decides, observe the original execution; do not submit the code again.
138
+
139
+ Server-side customer grant management (0.3.5): `get_customer_grant`, `save_customer_grant`, and `remove_customer_grant` operate on an authenticated customer session. Save exact tool keys and resource restrictions with the returned settings revision. The server supplies identity from the session; callers cannot choose another customer in the request body. These methods never grant company approval or bypass connected-account access.
@@ -1,13 +1,15 @@
1
- stacklink/__init__.py,sha256=HmuzFZk1Kb0PYP3de8gP28N_i7uVwLBphW2728bK_Uk,1525
1
+ stacklink/__init__.py,sha256=nEmn75pcnZV_JP_FJCipceSnZ6nVrJwy8bOfAFEfeWc,1969
2
2
  stacklink/api_keys.py,sha256=saqiPIu2tlsFVvLuCDjWoS0P0ThKGZGGSkDeKdGCrnM,460
3
+ stacklink/approvals.py,sha256=IJGWim2j4hEzn6Njj7x6LGRgvrebfu4eInJhWfonpuY,5803
3
4
  stacklink/artifacts.py,sha256=B0AcL3_JuHHBvV2HhRhcZWQFklSkIhFrB6tUu5PS1g8,235
4
5
  stacklink/auth_configs.py,sha256=j07EgP9-Pd8eR59FZetxyrXcC2LM7uH46sqAI8LDvqc,1056
5
6
  stacklink/billing.py,sha256=YGwpVaSUoX1KsGywMP63t8d6k3me44UGbYQWxYaSxRs,1623
7
+ stacklink/bots.py,sha256=unW5yJl2db34rs1CJHZ9YDfdzl5doakbpk_0KchISoU,14257
6
8
  stacklink/claude_agent.py,sha256=qGmFY6gIHaGOSkpyGW9RSGuN48juIvG12ffNPXqNzns,757
7
- stacklink/client.py,sha256=67fFpA00JnIDCK5SKGKWKUyS7FWnT5WXLrBN93YhPm8,35858
9
+ stacklink/client.py,sha256=t4CpWA8YwSIwog4lbixGvtfiMxh2KS7xjEWi5P3Avmg,36473
8
10
  stacklink/connected_accounts.py,sha256=SCDzA3CAQBun0gi0XZqr4-2f-GAwXIB5TcSZBHQtL24,3946
9
- stacklink/errors.py,sha256=OUrmPgPx2Z8Bd_w-YI4xAiu05U6ag7x2mzD8TyjanJg,603
10
- stacklink/executions.py,sha256=NFS8e23ofR8Xa3bgha9N88rkZcoURmyIkikKF0YFVQg,2493
11
+ stacklink/errors.py,sha256=oSSHHSl1O61n0PcLBgDIEC_9RBCLZOTtuysMTToh15Q,654
12
+ stacklink/executions.py,sha256=PKWGuaSSYzv--yaa_YKKK9etrk05oY3snKxgjaFJghk,2603
11
13
  stacklink/knowledge.py,sha256=vzRxJIWgHMbH1_v3eRD4vTmYtwQUzNS9XXbfuMwSVr0,798
12
14
  stacklink/logs.py,sha256=cf-7VGpFWEcfNeoCTh6b7xp6CdObuJVgRYSy5iYSiJ0,970
13
15
  stacklink/mcp.py,sha256=9EJh-iaf-yzlb5_4qIJPUtD9dG5QtUW4n-Aw_csB7HQ,5871
@@ -20,10 +22,11 @@ stacklink/sessions.py,sha256=JpZNQjhlWDWJa1fDQ48J1yqggQnAa3PHQgccy_NTukM,11361
20
22
  stacklink/tools.py,sha256=JGculek2ViQKA221OTiDptOepMm6QSIiorxm76p4qIM,1355
21
23
  stacklink/triggers.py,sha256=RGB7gd_DPbx4PTQWlwMEup8-42yHBkVmr1iVZZ_2Wi0,952
22
24
  stacklink/types.py,sha256=sO2ofetRNP06WtWTe-Ls8k2V2NjptlSF63NDnFhILJ4,8703
23
- stacklink/webhooks.py,sha256=-IuoN-e_hD1nSXGp1BAv7ReAzQgDyaJWo9f3_mZBrmg,1091
24
- stacklink/workbench.py,sha256=csBHixJA74P4NC8SxKglcO-maZSGu1GgDkpQ22sZrl8,37537
25
- stacklink_sdk-0.2.2.dist-info/licenses/LICENSE,sha256=Je4dQL-m555y5qBe8IhbO-YOFIvC4qZZSfyt67hCCkk,412
26
- stacklink_sdk-0.2.2.dist-info/METADATA,sha256=wn6ri6JNcscbPcwRlHL7wymHAPaVjiwzwcglYOBg3AQ,4714
27
- stacklink_sdk-0.2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
28
- stacklink_sdk-0.2.2.dist-info/top_level.txt,sha256=rGAB6iqp4vzE7YArYOPTZGsAF221SnxNbemNHpzVKgg,10
29
- stacklink_sdk-0.2.2.dist-info/RECORD,,
25
+ stacklink/webhooks.py,sha256=2C3CIrVV3k1Y5efWGFovBA3oDGEjnU61CxKoYOcsF10,1973
26
+ stacklink/workbench.py,sha256=VJjeFbqczrRf53suBwql2sIHu5vP-GbSuUf2vWOG0Ds,40761
27
+ stacklink/x_chat.py,sha256=Sy-BrPY7KxgU_hidi4HlEuWHNSn10L6CDx5qmzktmgU,5851
28
+ stacklink_sdk-0.3.10.dist-info/licenses/LICENSE,sha256=Je4dQL-m555y5qBe8IhbO-YOFIvC4qZZSfyt67hCCkk,412
29
+ stacklink_sdk-0.3.10.dist-info/METADATA,sha256=oM1dMYLLo9N0eVlyD3lXmFTYu4mhKTnqIy59o9RDXuE,9240
30
+ stacklink_sdk-0.3.10.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
31
+ stacklink_sdk-0.3.10.dist-info/top_level.txt,sha256=rGAB6iqp4vzE7YArYOPTZGsAF221SnxNbemNHpzVKgg,10
32
+ stacklink_sdk-0.3.10.dist-info/RECORD,,
@@ -1,84 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: stacklink-sdk
3
- Version: 0.2.2
4
- Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
- Author: Stacklink
6
- License-Expression: LicenseRef-Proprietary
7
- Project-URL: Documentation, https://docs.stacklink.in/docs/quickstart
8
- Requires-Python: >=3.10
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Requires-Dist: httpx[http2]<1,>=0.27
12
- Provides-Extra: openai
13
- Requires-Dist: openai<3,>=2.0; extra == "openai"
14
- Provides-Extra: openai-agents
15
- Requires-Dist: openai-agents<0.19,>=0.18; extra == "openai-agents"
16
- Provides-Extra: claude-agent
17
- Requires-Dist: claude-agent-sdk<0.3,>=0.2; extra == "claude-agent"
18
- Dynamic: license-file
19
-
20
- # Stacklink Python SDK
21
-
22
- Stacklink supplies executable, governed tools to an agent harness. The selected harness owns the model call, tool loop, and final answer.
23
-
24
- Install the public package with the harness extra you use:
25
-
26
- ```bash
27
- uv add 'stacklink-sdk[openai-agents]'
28
- ```
29
-
30
- or:
31
-
32
- ```bash
33
- uv add 'stacklink-sdk[claude-agent]'
34
- ```
35
-
36
- The complete guides and certified recipes live at [docs.stacklink.in](https://docs.stacklink.in/docs/quickstart). PyPI is the package delivery system, not the primary documentation surface. Stacklink does not display registry download badges in its product or documentation.
37
-
38
- ## Certified agent harnesses
39
-
40
- | Harness | Stacklink adapter |
41
- |---|---|
42
- | OpenAI Agents SDK | `create_stacklink_openai_agents_tools(session)` |
43
- | Claude Agent SDK | `create_stacklink_claude_agent_tool_config(session)` |
44
-
45
- Both start with one explicit Stacklink session:
46
-
47
- ```python
48
- import os
49
- from stacklink import Stacklink
50
-
51
- stacklink = Stacklink(
52
- api_key=os.environ['STACKLINK_API_KEY'],
53
- base_url=os.getenv('STACKLINK_BASE_URL', 'https://api.stacklink.in'),
54
- )
55
- session = stacklink.sessions.create_bound({
56
- 'externalTenantId': 'acme',
57
- 'externalUserId': 'user_123',
58
- 'modelSurface': {'mode': 'router'},
59
- })
60
-
61
- try:
62
- # Pass the selected Stacklink adapter output to your agent harness.
63
- pass
64
- finally:
65
- session.close()
66
- ```
67
-
68
- The complete OpenAI Agents and Claude Agent examples shown by the Stacklink Overview are generated from the executable files under `examples/sdk/`. The dashboard, documentation, and certification tests all read those same files.
69
-
70
- The raw OpenAI `with_stacklink_memory` helper remains a separate Memory integration. It is not the agent-tool integration and is not part of basic Overview onboarding.
71
-
72
- Environment variables are `STACKLINK_API_KEY`, optional `STACKLINK_BASE_URL`, and the model-provider credentials required by the selected harness. The SDK is proprietary and is provided under the applicable Stacklink agreement.
73
-
74
- ## Accepted operations, approvals and retries
75
-
76
- An execution response can be pending. Keep its `executionKind`, `executionId`, session ID and retry key in your application. Read or wait for that operation using the existing execution APIs. A lost connection does not submit a second action, and closing the application session can remove the authority required to execute pending work.
77
-
78
- Retry keys are case-sensitive and accept up to 240 characters. Tool and batch requests have separate key namespaces. Reuse a key only for the exact same request in the same SDK session. Changed inputs return `409 idempotency_key_reused`; an expired stored result returns `410 execution_result_expired`. Neither response authorizes submitting the write again.
79
-
80
- For `waiting_for_approval`, your backend can use the session approval read, approve and deny APIs with `platform_approvals:read` / `platform_approvals:manage` scopes. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
81
-
82
- Use execution cancellation for an explicit Stop request. Losing a stream only stops observation. Vercel and LangChain adapters forward their supported abort signal; other adapters keep the structured operation identity available for explicit cancellation. MCP progress reports actual stages, not invented percentages.
83
-
84
- Streaming has a total observation deadline and a separate inactivity timeout. A timeout raises `sdk_stream_timeout` without repeating the action; use its original operation ID to resume observation. Stream responses are limited to 8 MiB, including JSON responses to streaming requests. The synchronous client keeps blocking reads in a bounded reader and releases the response when that read finishes or reaches its inactivity timeout. Custom transports must honor the timeout passed to them. MCP calls return once all matching responses arrive, even if the server keeps the stream open.