threadify-sdk 0.2.8__tar.gz → 0.2.9__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 (28) hide show
  1. {threadify_sdk-0.2.8/threadify_sdk.egg-info → threadify_sdk-0.2.9}/PKG-INFO +25 -2
  2. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/README.md +24 -1
  3. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/pyproject.toml +1 -1
  4. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_client.py +16 -9
  5. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_connection.py +100 -0
  6. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_models.py +4 -3
  7. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_step.py +2 -3
  8. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/connection.py +35 -21
  9. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/data_retriever.py +6 -2
  10. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/otel_exporter.py +16 -3
  11. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/step.py +0 -12
  12. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9/threadify_sdk.egg-info}/PKG-INFO +25 -2
  13. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/CHANGELOG.md +0 -0
  14. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/LICENSE +0 -0
  15. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/MANIFEST.in +0 -0
  16. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/setup.cfg +0 -0
  17. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_data_retriever.py +0 -0
  18. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_notification.py +0 -0
  19. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_thread.py +0 -0
  20. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/__init__.py +0 -0
  21. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/client.py +0 -0
  22. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/models.py +0 -0
  23. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/notification.py +0 -0
  24. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/thread.py +0 -0
  25. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/SOURCES.txt +0 -0
  26. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/dependency_links.txt +0 -0
  27. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/requires.txt +0 -0
  28. {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadify-sdk
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Python SDK for Threadify — Service-delivery intelligence. Track every customer request from start to finish across every system, team, and partner. Visit: https://threadify.dev
5
5
  Author-email: Threadify Team <team@threadify.dev>
6
6
  License: MIT
@@ -63,7 +63,14 @@ async def main():
63
63
  return
64
64
 
65
65
  try:
66
- thread = await conn.start("Order-123")
66
+ thread = await conn.start(
67
+ contract_name="order_processing",
68
+ role="customer",
69
+ refs={"order_id": "ORD-123"},
70
+ tags=["priority"],
71
+ )
72
+
73
+ await thread.add_refs({"crm_id": "CRM-456"})
67
74
 
68
75
  # Easy chaining!
69
76
  await (
@@ -130,6 +137,22 @@ thread = await conn.join(
130
137
  )
131
138
  ```
132
139
 
140
+ ### Start options
141
+
142
+ `Connection.start(...)` supports named options for contract, role, refs, and tags, while preserving the older positional forms:
143
+
144
+ ```python
145
+ thread = await conn.start(
146
+ contract_name="order_processing",
147
+ role="customer",
148
+ refs={"customer_id": "123"},
149
+ tags=["priority"],
150
+ )
151
+ thread = await conn.start("Order-123", "customer")
152
+ thread = await conn.start({"customer_id": "123"}, "customer")
153
+ thread = await conn.start("Order-123") # contract is optional
154
+ ```
155
+
133
156
  ## Subscriptions
134
157
 
135
158
  Preferred API:
@@ -36,7 +36,14 @@ async def main():
36
36
  return
37
37
 
38
38
  try:
39
- thread = await conn.start("Order-123")
39
+ thread = await conn.start(
40
+ contract_name="order_processing",
41
+ role="customer",
42
+ refs={"order_id": "ORD-123"},
43
+ tags=["priority"],
44
+ )
45
+
46
+ await thread.add_refs({"crm_id": "CRM-456"})
40
47
 
41
48
  # Easy chaining!
42
49
  await (
@@ -103,6 +110,22 @@ thread = await conn.join(
103
110
  )
104
111
  ```
105
112
 
113
+ ### Start options
114
+
115
+ `Connection.start(...)` supports named options for contract, role, refs, and tags, while preserving the older positional forms:
116
+
117
+ ```python
118
+ thread = await conn.start(
119
+ contract_name="order_processing",
120
+ role="customer",
121
+ refs={"customer_id": "123"},
122
+ tags=["priority"],
123
+ )
124
+ thread = await conn.start("Order-123", "customer")
125
+ thread = await conn.start({"customer_id": "123"}, "customer")
126
+ thread = await conn.start("Order-123") # contract is optional
127
+ ```
128
+
106
129
  ## Subscriptions
107
130
 
108
131
  Preferred API:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadify-sdk"
7
- version = "0.2.8"
7
+ version = "0.2.9"
8
8
  description = "Python SDK for Threadify — Service-delivery intelligence. Track every customer request from start to finish across every system, team, and partner. Visit: https://threadify.dev"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -9,14 +9,21 @@ from threadify.models import ConnectOptions
9
9
 
10
10
  class TestThreadifyConnect:
11
11
  @pytest.mark.asyncio
12
- async def test_requires_ws_url(self, monkeypatch):
13
- ws_connect = AsyncMock(
14
- side_effect=AssertionError("websockets.connect should not be called")
15
- )
12
+ async def test_connect_uses_default_ws_url_when_not_provided(self, monkeypatch):
13
+ ws = AsyncMock()
14
+ ws.send = AsyncMock()
15
+ ws.recv = AsyncMock(return_value=json.dumps({"action": "connect", "status": "success"}))
16
+ ws.close = AsyncMock()
17
+
18
+ ws_connect = AsyncMock(return_value=ws)
16
19
  monkeypatch.setattr("threadify.client.websockets.connect", ws_connect)
17
20
 
18
- with pytest.raises(ValueError, match="ws_url is required"):
19
- await Threadify.connect("test-api-key", "test-service")
21
+ conn = await Threadify.connect("test-api-key", "test-service")
22
+
23
+ ws_connect.assert_awaited_once_with("wss://eng.threadify.dev/threads")
24
+ sent_msg = json.loads(ws.send.call_args.args[0])
25
+ assert sent_msg["serviceName"] == "test-service"
26
+ assert conn.is_connected is True
20
27
 
21
28
  @pytest.mark.asyncio
22
29
  async def test_connect_with_explicit_ws_url(self, monkeypatch):
@@ -64,6 +71,6 @@ class TestThreadifyConnect:
64
71
 
65
72
 
66
73
  class TestThreadifyFactory:
67
- def test_create_requires_ws_url(self):
68
- with pytest.raises(ValueError, match="ws_url is required"):
69
- Threadify.create("test-api-key")
74
+ def test_create_uses_default_ws_url(self):
75
+ factory = Threadify.create("test-api-key")
76
+ assert factory._options.ws_url == "wss://eng.threadify.dev/threads"
@@ -36,6 +36,7 @@ def _make_connection():
36
36
  conn._notification_handlers = {}
37
37
  conn._active_subscriptions = {}
38
38
  conn._processed_notifications = set()
39
+ conn._processed_notifications_max_size = 10_000
39
40
  conn._recv_queue = asyncio.Queue()
40
41
  conn._data_retriever = None
41
42
  import logging
@@ -120,6 +121,105 @@ class TestConnectionProperties:
120
121
  assert conn.is_connected is True
121
122
 
122
123
 
124
+ class TestStart:
125
+ @pytest.mark.asyncio
126
+ async def test_start_with_label_and_optional_contract(self):
127
+ conn = _make_connection()
128
+ conn._send = AsyncMock()
129
+ conn._wait_response = AsyncMock(
130
+ return_value={
131
+ "action": "startThread",
132
+ "status": "success",
133
+ "threadId": "thread-123",
134
+ }
135
+ )
136
+
137
+ thread = await conn.start("customer-123", "customer")
138
+
139
+ assert thread.thread_id == "thread-123"
140
+ assert thread.role == "test"
141
+ assert thread.refs["label"] == "customer-123"
142
+ assert thread.refs["serviceName"] == "test-service"
143
+ sent_msg = conn._send.call_args.args[0]
144
+ assert sent_msg["refs"]["label"] == "customer-123"
145
+ assert sent_msg["contractName"] == "customer"
146
+ assert sent_msg["role"] == "test"
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_start_with_refs_and_contract(self):
150
+ conn = _make_connection()
151
+ conn._send = AsyncMock()
152
+ conn._wait_response = AsyncMock(
153
+ return_value={
154
+ "action": "startThread",
155
+ "status": "success",
156
+ "threadId": "thread-legacy-123",
157
+ }
158
+ )
159
+
160
+ thread = await conn.start(refs={"customer_id": "123"}, contract_name="customer")
161
+
162
+ assert thread.thread_id == "thread-legacy-123"
163
+ assert thread.refs["customer_id"] == "123"
164
+ assert thread.refs["serviceName"] == "test-service"
165
+ sent_msg = conn._send.call_args.args[0]
166
+ assert sent_msg["refs"]["customer_id"] == "123"
167
+ assert "label" not in sent_msg["refs"]
168
+ assert sent_msg["contractName"] == "customer"
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_start_without_contract_still_works(self):
172
+ conn = _make_connection()
173
+ conn._send = AsyncMock()
174
+ conn._wait_response = AsyncMock(
175
+ return_value={
176
+ "action": "startThread",
177
+ "status": "success",
178
+ "threadId": "thread-no-contract-123",
179
+ }
180
+ )
181
+
182
+ thread = await conn.start("customer-123")
183
+
184
+ assert thread.thread_id == "thread-no-contract-123"
185
+ assert thread.refs["label"] == "customer-123"
186
+ assert thread.refs["serviceName"] == "test-service"
187
+ sent_msg = conn._send.call_args.args[0]
188
+ assert sent_msg["refs"]["label"] == "customer-123"
189
+ assert "contractName" not in sent_msg
190
+ assert "role" not in sent_msg
191
+
192
+ @pytest.mark.asyncio
193
+ async def test_start_accepts_keyword_config_options(self):
194
+ conn = _make_connection()
195
+ conn._send = AsyncMock()
196
+ conn._wait_response = AsyncMock(
197
+ return_value={
198
+ "action": "startThread",
199
+ "status": "success",
200
+ "threadId": "thread-options-123",
201
+ }
202
+ )
203
+
204
+ thread = await conn.start(
205
+ contract_name="order_processing",
206
+ role="merchant",
207
+ refs={"customer_id": "123"},
208
+ tags=["priority", "external"],
209
+ )
210
+
211
+ assert thread.thread_id == "thread-options-123"
212
+ assert thread.role == "merchant"
213
+ assert thread.tags == ["priority", "external"]
214
+ assert thread.refs["customer_id"] == "123"
215
+ assert thread.refs["serviceName"] == "test-service"
216
+ sent_msg = conn._send.call_args.args[0]
217
+ assert sent_msg["contractName"] == "order_processing"
218
+ assert sent_msg["role"] == "merchant"
219
+ assert sent_msg["refs"]["customer_id"] == "123"
220
+ assert sent_msg["tags"] == ["priority", "external"]
221
+
222
+
123
223
  class TestJoin:
124
224
  @pytest.mark.asyncio
125
225
  async def test_join_with_token_keyword(self):
@@ -3,6 +3,7 @@ import pytest
3
3
  from threadify.models import (
4
4
  DEFAULT_CONNECT_TIMEOUT,
5
5
  DEFAULT_MAX_IN_FLIGHT,
6
+ DEFAULT_WS_URL,
6
7
  ConnectOptions,
7
8
  StepResult,
8
9
  StepStatus,
@@ -77,10 +78,10 @@ class TestConnectOptions:
77
78
  def test_defaults(self):
78
79
  opts = ConnectOptions()
79
80
  opts.with_defaults()
80
- assert opts.ws_url == ""
81
+ assert opts.ws_url == DEFAULT_WS_URL
81
82
  assert opts.max_in_flight == DEFAULT_MAX_IN_FLIGHT
82
83
  assert opts.connect_timeout == DEFAULT_CONNECT_TIMEOUT
83
- assert opts.graphql_url == ""
84
+ assert opts.graphql_url == derive_graphql_url(DEFAULT_WS_URL)
84
85
 
85
86
  def test_preserves_custom(self):
86
87
  opts = ConnectOptions(
@@ -100,7 +101,7 @@ class TestConnectOptions:
100
101
  opts2 = ConnectOptions(ws_url="wss://custom.com/threads", max_in_flight=100)
101
102
  opts2.validate() # Should not raise.
102
103
 
103
- def test_validate_requires_ws_url(self):
104
+ def test_validate_requires_non_empty_ws_url(self):
104
105
  opts = ConnectOptions(ws_url="")
105
106
  with pytest.raises(ValueError, match="ws_url is required"):
106
107
  opts.validate()
@@ -24,9 +24,7 @@ class TestThreadStepFluent:
24
24
  def test_chaining(self):
25
25
  thread = _make_thread()
26
26
  step = ThreadStep("order_placed", thread, "test-svc")
27
- result = step.add_context({"orderId": "ORD-1", "amount": "99.99"}).add_refs(
28
- {"stripe": "pi_abc"}
29
- )
27
+ result = step.add_context({"orderId": "ORD-1", "amount": "99.99"})
30
28
  assert result is step # Fluent returns self.
31
29
  assert step.context == {"orderId": "ORD-1", "amount": "99.99"}
32
30
 
@@ -278,3 +276,4 @@ class TestStepProperties:
278
276
  assert data["action"] == "recordThreadEvent"
279
277
  assert data["stepName"] == "order_placed"
280
278
  assert data["threadId"] == "thread-001"
279
+ assert "refs" not in data
@@ -20,6 +20,8 @@ from threadify.models import (
20
20
  ACTION_START_THREAD,
21
21
  ACTION_SUBSCRIBE,
22
22
  ACTION_UNSUBSCRIBE,
23
+ DEFAULT_PROCESSED_MAX_SIZE,
24
+ FIELD_ACCESS_LEVEL,
23
25
  FIELD_ACK_TOKEN,
24
26
  FIELD_ACTION,
25
27
  FIELD_CONTRACT_NAME,
@@ -40,7 +42,6 @@ from threadify.models import (
40
42
  FIELD_THREAD_ID_ACK,
41
43
  FIELD_THREAD_TOKEN,
42
44
  STATUS_SUCCESS,
43
- DEFAULT_PROCESSED_MAX_SIZE,
44
45
  RefQuery,
45
46
  first_non_empty,
46
47
  require_non_empty,
@@ -146,6 +147,7 @@ class Connection:
146
147
  service_name: str = "",
147
148
  refs: dict[str, Any] | None = None,
148
149
  tags: list[str] | None = None,
150
+ role: str = "",
149
151
  ) -> ThreadInstance:
150
152
  from threadify.thread import ThreadInstance
151
153
 
@@ -154,27 +156,29 @@ class Connection:
154
156
 
155
157
  effective_service = first_non_empty(service_name, self._service_name)
156
158
 
157
- # Prepare refs
158
159
  message_refs = (refs or {}).copy()
160
+ label_value = label
161
+
159
162
  message_refs[FIELD_SERVICE_NAME] = effective_service
160
- if label:
161
- message_refs["label"] = label
163
+ if label_value:
164
+ message_refs["label"] = label_value
162
165
 
163
166
  msg: dict[str, Any] = {
164
167
  FIELD_ACTION: ACTION_START_THREAD,
165
168
  FIELD_REFS: message_refs,
166
169
  }
167
170
 
171
+ effective_role = role
168
172
  if contract_name:
169
173
  msg[FIELD_CONTRACT_NAME] = contract_name
170
- role = ""
171
- # Check if role is in refs or if we should derive it
172
- if effective_service:
173
- role = effective_service.removesuffix("-service")
174
- else:
175
- role = "participant"
176
-
177
- msg[FIELD_ROLE] = role
174
+ if not effective_role:
175
+ if effective_service:
176
+ effective_role = effective_service.removesuffix("-service")
177
+ else:
178
+ effective_role = "participant"
179
+
180
+ if effective_role:
181
+ msg[FIELD_ROLE] = effective_role
178
182
 
179
183
  # Validate and attach tags if provided
180
184
  if tags:
@@ -191,7 +195,14 @@ class Connection:
191
195
  raise RuntimeError(resp.get(FIELD_MESSAGE, "failed to start thread"))
192
196
 
193
197
  thread_id = resp[FIELD_THREAD_ID]
194
- thread = ThreadInstance(self, thread_id, contract_name, "", resp.get(FIELD_ACCESS_LEVEL, ""), None)
198
+ thread = ThreadInstance(
199
+ self,
200
+ thread_id,
201
+ contract_name,
202
+ effective_role,
203
+ resp.get(FIELD_ACCESS_LEVEL, ""),
204
+ message_refs.copy(),
205
+ )
195
206
  thread.tags = list(tags) if tags else []
196
207
  self._threads[thread_id] = thread
197
208
  self._logger.debug(f"Thread started: {thread_id}")
@@ -219,16 +230,19 @@ class Connection:
219
230
  msg[FIELD_THREAD_TOKEN] = token
220
231
  elif thread_id is not None:
221
232
  require_non_empty("thread_id", thread_id)
222
- require_non_empty("role", role)
223
233
  msg[FIELD_THREAD_ID] = thread_id
224
- msg[FIELD_ROLE] = role
225
- elif token_or_thread_id is not None:
226
- require_non_empty("token_or_thread_id", token_or_thread_id)
227
234
  if role:
228
- msg[FIELD_THREAD_ID] = token_or_thread_id
229
235
  msg[FIELD_ROLE] = role
230
- else:
236
+ elif token_or_thread_id is not None:
237
+ require_non_empty("token_or_thread_id", token_or_thread_id)
238
+ # If it has 3 parts (JWT) or is a long string, it's likely a token. Otherwise it's a thread ID.
239
+ is_token = len(token_or_thread_id.split('.')) == 3 or len(token_or_thread_id) > 50
240
+ if is_token and not role:
231
241
  msg[FIELD_THREAD_TOKEN] = token_or_thread_id
242
+ else:
243
+ msg[FIELD_THREAD_ID] = token_or_thread_id
244
+ if role:
245
+ msg[FIELD_ROLE] = role
232
246
  else:
233
247
  raise ValueError("provide token, thread_id+role, or token_or_thread_id")
234
248
 
@@ -274,7 +288,7 @@ class Connection:
274
288
  event: str,
275
289
  step_name_or_handler: str | NotificationHandler | None = None,
276
290
  handler: NotificationHandler | None = None,
277
- ) -> "Connection":
291
+ ) -> Connection:
278
292
  """Subscribe to notifications for a step or thread-level event.
279
293
 
280
294
  Supports two signatures:
@@ -306,7 +320,7 @@ class Connection:
306
320
  self._notification_handlers.setdefault(key, []).append(actual_handler)
307
321
  return self
308
322
 
309
- def unsubscribe(self, event: str, step_name: str = "") -> "Connection":
323
+ def unsubscribe(self, event: str, step_name: str = "") -> Connection:
310
324
  """Unsubscribe from notifications.
311
325
 
312
326
  Args:
@@ -183,8 +183,12 @@ class DataRetriever:
183
183
  if q.started_before:
184
184
  variables["startedBefore"] = q.started_before
185
185
  data = await self._client.query(query, variables)
186
- connection = data.get("threadsByRef") or {}
187
- threads_list = connection.get("threads") or []
186
+ threads_by_ref = data.get("threadsByRef")
187
+ if isinstance(threads_by_ref, list):
188
+ threads_list = threads_by_ref
189
+ else:
190
+ connection = threads_by_ref or {}
191
+ threads_list = connection.get("threads") or []
188
192
  return [ArchivedThread(t, self._client) for t in threads_list if isinstance(t, dict)]
189
193
 
190
194
  async def get_validation_results(self, thread_id: str, step_name: str = "") -> list[dict[str, Any]]:
@@ -150,6 +150,7 @@ class ThreadifySpanExporter(_SpanExporterBase):
150
150
  "threadify.step_name",
151
151
  "threadify.role",
152
152
  "threadify.service",
153
+ "threadify.tags",
153
154
  }:
154
155
  continue
155
156
 
@@ -169,7 +170,7 @@ class ThreadifySpanExporter(_SpanExporterBase):
169
170
  if context:
170
171
  step.add_context(context)
171
172
  if refs:
172
- step.add_refs(refs)
173
+ await thread.add_refs(refs)
173
174
 
174
175
  # Map timing (OTel uses nanoseconds since epoch)
175
176
  start_time_ns = span.start_time
@@ -228,8 +229,6 @@ class ThreadifySpanExporter(_SpanExporterBase):
228
229
 
229
230
  async def _get_or_start_thread(self, span: ReadableSpan, trace_id: str) -> Any:
230
231
  """Get or create a ThreadInstance for this trace."""
231
- from threadify.thread import ThreadInstance
232
-
233
232
  if trace_id not in self._trace_threads:
234
233
  fut: asyncio.Future[Any] = asyncio.get_event_loop().create_future()
235
234
  self._trace_threads[trace_id] = fut
@@ -264,10 +263,12 @@ class ThreadifySpanExporter(_SpanExporterBase):
264
263
  except Exception:
265
264
  pass
266
265
 
266
+ tags = self._span_attr_list(span, "threadify.tags")
267
267
  thread = await self._connection.start(
268
268
  label=label,
269
269
  contract_name=contract_name or "",
270
270
  service_name=service_name,
271
+ tags=tags,
271
272
  )
272
273
  fut.set_result(thread)
273
274
  except Exception as exc:
@@ -298,6 +299,18 @@ class ThreadifySpanExporter(_SpanExporterBase):
298
299
  value = span.attributes.get(key)
299
300
  return str(value) if value is not None else None
300
301
 
302
+ @staticmethod
303
+ def _span_attr_list(span: ReadableSpan, key: str) -> list[str] | None:
304
+ value = span.attributes.get(key)
305
+ if value is None:
306
+ return None
307
+ if isinstance(value, list):
308
+ return [str(v) for v in value]
309
+ if isinstance(value, str):
310
+ # Allow comma-separated tags as a fallback
311
+ return [v.strip() for v in value.split(",") if v.strip()]
312
+ return None
313
+
301
314
  @staticmethod
302
315
  def _make_result(code: int, error: str | None = None) -> Any:
303
316
  """Build an OpenTelemetry ExportResult-compatible object."""
@@ -13,7 +13,6 @@ from threadify.models import (
13
13
  FIELD_IDEMPOTENCY_KEY,
14
14
  FIELD_IS_DUPLICATE,
15
15
  FIELD_MESSAGE,
16
- FIELD_REFS,
17
16
  FIELD_SERVICE_NAME,
18
17
  FIELD_STARTED_AT,
19
18
  FIELD_STATUS,
@@ -41,7 +40,6 @@ class ThreadStep:
41
40
  result = await (
42
41
  step
43
42
  .add_context({"orderId": "ORD-123", "amount": 99.99})
44
- .add_refs({"stripe_id": "pi_abc"})
45
43
  .success("Order placed!")
46
44
  )
47
45
  """
@@ -59,7 +57,6 @@ class ThreadStep:
59
57
  self._manual_idempotency_key: str = ""
60
58
  self._sub_steps: list[SubStepData] = []
61
59
  self._context: dict[str, str] = {}
62
- self._refs: dict[str, str] = {}
63
60
  self._metadata: dict[str, Any] | None = None
64
61
  self._error: Exception | None = None
65
62
 
@@ -108,14 +105,6 @@ class ThreadStep:
108
105
  self._context[f"private_{k}"] = s
109
106
  return self
110
107
 
111
- def add_refs(self, refs: dict[str, str] | None) -> ThreadStep:
112
- """Add external system references."""
113
- if self._error is not None:
114
- return self
115
- if refs:
116
- self._refs.update(refs)
117
- return self
118
-
119
108
  def sub_step(
120
109
  self,
121
110
  name: str,
@@ -181,7 +170,6 @@ class ThreadStep:
181
170
  self._event[FIELD_FINISHED_AT] = now_iso()
182
171
  self._event[FIELD_STATUS] = status
183
172
  self._event[FIELD_CONTEXT] = self._context
184
- self._event[FIELD_REFS] = self._refs
185
173
 
186
174
  # Handle optional message/data.
187
175
  if message_or_data is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadify-sdk
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Python SDK for Threadify — Service-delivery intelligence. Track every customer request from start to finish across every system, team, and partner. Visit: https://threadify.dev
5
5
  Author-email: Threadify Team <team@threadify.dev>
6
6
  License: MIT
@@ -63,7 +63,14 @@ async def main():
63
63
  return
64
64
 
65
65
  try:
66
- thread = await conn.start("Order-123")
66
+ thread = await conn.start(
67
+ contract_name="order_processing",
68
+ role="customer",
69
+ refs={"order_id": "ORD-123"},
70
+ tags=["priority"],
71
+ )
72
+
73
+ await thread.add_refs({"crm_id": "CRM-456"})
67
74
 
68
75
  # Easy chaining!
69
76
  await (
@@ -130,6 +137,22 @@ thread = await conn.join(
130
137
  )
131
138
  ```
132
139
 
140
+ ### Start options
141
+
142
+ `Connection.start(...)` supports named options for contract, role, refs, and tags, while preserving the older positional forms:
143
+
144
+ ```python
145
+ thread = await conn.start(
146
+ contract_name="order_processing",
147
+ role="customer",
148
+ refs={"customer_id": "123"},
149
+ tags=["priority"],
150
+ )
151
+ thread = await conn.start("Order-123", "customer")
152
+ thread = await conn.start({"customer_id": "123"}, "customer")
153
+ thread = await conn.start("Order-123") # contract is optional
154
+ ```
155
+
133
156
  ## Subscriptions
134
157
 
135
158
  Preferred API:
File without changes
File without changes
File without changes