driftstack-sdk 0.3.0__tar.gz → 0.5.0__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 (43) hide show
  1. driftstack_sdk-0.5.0/CHANGELOG.md +1373 -0
  2. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/PKG-INFO +68 -42
  3. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/README.md +64 -38
  4. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/pyproject.toml +11 -4
  5. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/__init__.py +29 -17
  6. driftstack_sdk-0.5.0/src/driftstack/_generated/models.py +2306 -0
  7. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/_version.py +1 -1
  8. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/client.py +7 -49
  9. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/errors.py +281 -21
  10. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/http.py +11 -4
  11. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/__init__.py +0 -3
  12. driftstack_sdk-0.5.0/src/driftstack/resources/_session_search_login.py +85 -0
  13. driftstack_sdk-0.5.0/src/driftstack/resources/account.py +120 -0
  14. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/agent_sessions.py +347 -226
  15. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/archetypes.py +0 -1
  16. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/egress.py +74 -12
  17. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/profiles.py +15 -47
  18. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/recipes.py +5 -6
  19. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/sessions.py +37 -13
  20. driftstack_sdk-0.5.0/src/driftstack/resources/support.py +201 -0
  21. driftstack_sdk-0.3.0/CHANGELOG.md +0 -438
  22. driftstack_sdk-0.3.0/src/driftstack/_generated/models.py +0 -2730
  23. driftstack_sdk-0.3.0/src/driftstack/resources/account.py +0 -171
  24. driftstack_sdk-0.3.0/src/driftstack/resources/api_keys.py +0 -119
  25. driftstack_sdk-0.3.0/src/driftstack/resources/audit_log.py +0 -98
  26. driftstack_sdk-0.3.0/src/driftstack/resources/auth.py +0 -187
  27. driftstack_sdk-0.3.0/src/driftstack/resources/billing.py +0 -54
  28. driftstack_sdk-0.3.0/src/driftstack/resources/crypto_orders.py +0 -271
  29. driftstack_sdk-0.3.0/src/driftstack/resources/email_preferences.py +0 -68
  30. driftstack_sdk-0.3.0/src/driftstack/resources/legal.py +0 -52
  31. driftstack_sdk-0.3.0/src/driftstack/resources/mfa.py +0 -74
  32. driftstack_sdk-0.3.0/src/driftstack/resources/team.py +0 -197
  33. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/.gitignore +0 -0
  34. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/LICENSE +0 -0
  35. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/_generated/__init__.py +0 -0
  36. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/pagination.py +0 -0
  37. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/py.typed +0 -0
  38. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/_common.py +0 -0
  39. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/profile_snapshots.py +0 -0
  40. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/usage.py +0 -0
  41. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/resources/webhooks.py +0 -0
  42. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/retry.py +0 -0
  43. {driftstack_sdk-0.3.0 → driftstack_sdk-0.5.0}/src/driftstack/webhook_signature.py +0 -0
@@ -0,0 +1,1373 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `driftstack` Python SDK. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning
5
+ follows [SemVer](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.5.0] - 2026-10-04
10
+
11
+ **The client methods remove nothing; the generated models change shape.**
12
+ Every method, error class and exported name 0.4.0 published is still there,
13
+ and the methods still return plain `dict`s. If you import classes from
14
+ `driftstack._generated.models` to validate or build a turn's `intents` or
15
+ `results`, read **Changed — BREAKING** below first. The headline is
16
+ `agent_sessions.run_steps()`: run a list of steps you write, as written, with
17
+ no AI planning. Some answers change for the same request — an empty `type`
18
+ value now clears the field, and `ok` is `False` in more cases; read
19
+ **Changed** below before upgrading. Every change below applies to both
20
+ `Driftstack` and `AsyncDriftstack`.
21
+
22
+ ### Changed — BREAKING
23
+
24
+ - **The generated models for a step and its result are one class each.** The
25
+ published spec now references `AgentIntent` and `IntentResult` from every
26
+ turn result, list-of-steps result and conflict problem, where each of those
27
+ used to carry its own copy. In `driftstack._generated.models`, `intents` is
28
+ `list[AgentIntent]`, `results` and `partial_results` are
29
+ `list[IntentResult]`, and an `IntentResult`'s `intent` is an `AgentIntent`.
30
+ The 135 numbered copies are gone: `Intent` and `Intent1` to `Intent107`,
31
+ `Intents` and `Intents1` to `Intents17`, `Results` and `Results1` to
32
+ `Results5`, and `PartialResults`, `PartialResults1` and `PartialResults2`.
33
+ `AgentIntent` and `IntentResult` are root models: read the step or result
34
+ through `.root` (`response.intents[0].root.kind`,
35
+ `response.results[0].root.intent.root.kind`), and replace an import or an
36
+ `isinstance` check of a numbered copy with `AgentIntent` or `IntentResult`.
37
+ Nothing changes for the client methods, which return the response as a
38
+ `dict`, and nothing changes on the wire.
39
+
40
+ ### Added
41
+
42
+ - **`agent_sessions.run_steps(agent_session_id, steps, ...)`: run steps you
43
+ already know, without the AI** (`POST /v1/agent-sessions/{id}/steps`), sync
44
+ and async. `steps` is a list of up to 8 step dicts, the vocabulary a
45
+ message's `intents` use; they run in order, as written, through the same
46
+ checks a message's steps go through, with no planning, no read-back and none
47
+ of the AI budget (no `usage` and no `answer` on the result). The result is a
48
+ `dict` keyed by `kind`: `plan-executed` (`intents`, `results`, `ok`, and
49
+ `notice` / `notice_reason` when the run ended early for a reason no step
50
+ says) or `stopped` when you called `stop()`. A step the session cannot run —
51
+ one outside the vocabulary, an address on a private network or carrying a
52
+ user name or password, a key no iPhone keyboard has, a native list, a frame
53
+ the page did not list or this device cannot act in — is a failed row in
54
+ `results` whose `reason` says why; nothing after it is sent, and the steps
55
+ after it are listed in `intents` with no result. A `type` step with text
56
+ adds to what the field holds; one with an empty `value` clears it. A wait
57
+ that times out does not stop the run. More than 8 steps raises a 400
58
+ `ValidationError`; nothing is cut. `approve_consequential_actions` approves
59
+ a step a previous call stopped at (`confirmation_required`); it counts only
60
+ when that call is the session's latest and `steps` is the rest of its list
61
+ from the stopped step on. `idempotency_key`, `timeout_s`, `on_step` and
62
+ `on_event` work as on `message()`; a key used for a message cannot be reused
63
+ here. It needs an account key with `write`, and shares the message rate
64
+ limit and the running-turns limit with `message()`. A session a person has
65
+ control of, or one in manual mode, raises a 409 `ConflictError`
66
+ (`ai_control_unavailable`) and nothing runs. New generated models describe
67
+ the result: `AgentStepsResponse` (a root model over `AgentStepsResponse1`,
68
+ `plan-executed`, and `AgentStepsResponse2`, `stopped`, with the numbered
69
+ `Session6`, `Session7`, `CapabilityReport6` and `CapabilityReport7` they
70
+ carry) and `AgentStepsResponseStream`.
71
+
72
+ - **A transcript entry's `origin`.** `"steps"` on the one entry a `run_steps`
73
+ call writes (steps you sent and ran as written, not planned by the agent);
74
+ absent on every other entry.
75
+
76
+ - **A turn's `answer` can name the selector of any control on the page, and
77
+ is no longer cut at 512 characters.** Asked "what is the selector of the Pay
78
+ button?" or to list a page's buttons with their selectors, the answer quotes
79
+ the selectors the agent was shown for the page it ended on (`#pay`).
80
+ `answer` is now at most 4,000 characters (it was cut at 512, which could stop
81
+ inside a selector); a longer one is cut after its last whole line and ends
82
+ with `… (the rest was cut for length)` on a line of its own. Its line breaks
83
+ are kept, so a list asked for one control per line comes back one per line;
84
+ the transcript entry holds the same text on one line. A CSS selector written
85
+ in the message is used exactly as written.
86
+
87
+ - **An `extract` step can read one attribute of an element.** A step with
88
+ `"kind": "extract"` and a `"selector"` can carry `"attribute"` — never with
89
+ `"body"` or a frame — naming the attribute that was read instead of the
90
+ element's text (`"href"`, `"src"`, `"data-order-id"`; the generated model's
91
+ field allows 1-128 characters of an attribute name). The step's summary reads
92
+ `extracted attribute href from #terms: …`, and an element without the
93
+ attribute is said to have none
94
+ (`extracted nothing: the element #terms has no href attribute`), which is not
95
+ the same answer as a selector that matched nothing.
96
+
97
+ - **`Session.proxy_id`: which of your saved proxies a session runs through.**
98
+ Additive. `sessions.create()`, `sessions.get()`, `sessions.list()` and
99
+ `profiles.launch()` now report the saved proxy the session's traffic goes
100
+ out through: the `proxy_id` the create named, or the proxy the launched
101
+ profile is bound to (on a team, a session an admin started with a proxy
102
+ saved on the admin's own account reports that admin's proxy). It is kept
103
+ after the session ends. `None` for a session started without a saved proxy,
104
+ and when it is not reported: on a read by a team member without admin role,
105
+ on a read that could not look it up at that moment, and on an older
106
+ server. `None` alone does not tell those apart; this does:
107
+ `"proxy_id" in session.model_fields_set` is true only when the server sent
108
+ the key. A `None` with the key present means no saved proxy, and a `None`
109
+ without it means not reported (never read that as "no proxy"). Before
110
+ this, a session started through a proxy did not say which one.
111
+
112
+ - **On a session whose device supports it, a tap or a wait can name a frame.**
113
+ Additive, and nothing changes on any other session. On a session whose
114
+ device can act inside an embedded frame (an iframe), a step in a turn's
115
+ `intents` can carry `frame` on an `interact` with `"action": "tap"`, and on a
116
+ `wait` with `"condition": "selector_visible"` — the generated `wait` models
117
+ gain an optional `frame`, the same path of positions as a read's. The tap's
118
+ selector is matched, and the element is waited for, inside that frame. A
119
+ `"scroll"` or `"press"` never carries `frame`: both act on the page itself.
120
+ Such a step succeeded only when the session confirmed it was done inside
121
+ exactly that frame; one it did not confirm fails and is not tried again
122
+ (`diagnosis.category` `"unknown"` for a tap, `"condition_not_met"` for a
123
+ wait). A tap inside a frame that pays, buys or deletes an account asks for
124
+ approval as one on the page does.
125
+
126
+ - **Session secrets: `agent_sessions.register_secret()`, `list_secrets()` and
127
+ `delete_secret()`**, sync and async. `register_secret` takes the session id
128
+ and the keyword arguments `label`, `secret` and `sites` (and optionally
129
+ `include_subdomains` and `ttl_seconds`), holds a password, a one-time code or
130
+ a payment card number for that one agent session, and returns its handle
131
+ (`sec_…`); write `{{credential:<handle>}}` in a `message()` as the whole value
132
+ of the step that types it. The plan, the transcript and every response carry
133
+ the handle, and the value is typed only on an `https://` page on one of
134
+ `sites`. Once typed it is on the page; where the page shows it again it is
135
+ replaced by its placeholder in what the model is shown, the step results and
136
+ the transcript, as for a saved credential (a card number however its digits
137
+ are grouped, but not part of it). `label` appears in refusals the model reads,
138
+ so put nothing secret in it. It is held in server memory only and dropped
139
+ when the session ends, when deleted, when the service restarts, or when
140
+ `ttl_seconds` runs out (default 3600); one a step has typed, or tried to, is
141
+ kept past `ttl_seconds`, never typed again, only to keep hiding it from the
142
+ model. The answer is an `AgentSessionSecret` (exported from `driftstack`);
143
+ the generated models gain `CreateAgentSessionSecret`, `AgentSessionSecret`
144
+ and `AgentSessionSecretList`.
145
+ `register_secret` is not retried on a network error, since a retry could
146
+ register the value twice.
147
+
148
+ - **`frame_match` on `sessions.capture`** (the generated `FrameMatch` model, or a
149
+ dict). With `kind="dom_snapshot"`, reads one embedded frame (an iframe) named
150
+ by its address instead of the page: `host` (required; exact, letter case
151
+ aside, port not compared), `path_prefix` (whole path segments) and `query`
152
+ (each parameter with exactly that value; at most 10). Exactly one frame must
153
+ match. With none or several, nothing is read, the session stays ready, and
154
+ the call raises a 409 `ConflictError` whose `problem["code"]` is
155
+ `"frame_not_found"`, `"frame_match_ambiguous"` (`problem["matched_frames"]`
156
+ is the count) or `"frame_match_unconfirmed"`. A `query` parameter whose value
157
+ the browser removes before it reaches Driftstack (`client_secret`, `token`,
158
+ `code_verifier`, ...; the full list is in the Sessions reference), or
159
+ `frame_match` on a screenshot or a PDF, raises a 400 `BadRequestError`; a key
160
+ `frame_match` does not know, a 400 `ValidationError` (built with the
161
+ `FrameMatch` model, pydantic refuses it before anything is sent). A session started with a `proxy_id` cannot run
162
+ it yet, like every step-by-step operation.
163
+
164
+ ### Changed
165
+
166
+ - **A `type` step with an empty `value` clears the field.** The session
167
+ deletes what the field holds, one key at a time as a person would, types
168
+ nothing, and the step's summary says `cleared #email`, never `typed`. To
169
+ replace what a field holds, send an empty `type` and then one with the new
170
+ text; the agent now plans a replacement that way. A `type` step with text
171
+ still adds to what the field holds. A field that cannot be emptied key by
172
+ key (over 200 characters, or one whose page puts characters back, such as a
173
+ fixed prefix or an input mask) is not cleared and the step fails with
174
+ nothing typed; a session whose browser cannot empty a field refuses the
175
+ step without sending it, and the turn stops there.
176
+ - **`ok` is `False` in more cases.** A planned step that would have acted on
177
+ the page but could not be sent is now a failed row
178
+ (`diagnosis.category` `"invalid_request"`) that halts its plan like any
179
+ failed step; the steps planned after it are listed in `intents` with no
180
+ result instead of disappearing, and `ok` stays `False` unless a later step
181
+ acts on the page. `ok` is also `False` when the turn delivered nothing — no
182
+ step that changed the page or scrolled it, no screenshot and no `answer` —
183
+ unless the message only asked to wait, and when an English message asks for
184
+ more things to be done than distinct steps that changed the page ran. Every
185
+ entry of `results` can then be a success: read `notice`, or
186
+ `answer_unavailable` when the answer asked for could not be read.
187
+ - **`recipes.create()` leaves out steps that never ran.** A recipe's
188
+ `intent_log` no longer includes a step the agent planned but could not send,
189
+ nor the steps planned after it in that plan.
190
+ - **The `refuse` result is documented in full.** No step was planned or run
191
+ and the session stays active, but the turn is still recorded: the refusal is
192
+ added to the transcript, and the tokens used to read the message are charged
193
+ to the session's token budget like any other turn (not when the AI was
194
+ briefly unavailable or the session stopped answering). Resending cannot
195
+ repeat anything on the page, but each resend is a new turn; use a new
196
+ idempotency key. Nothing about the response changed.
197
+
198
+ ## [0.4.0] - 2026-10-03
199
+
200
+ **Breaking: the SDK now covers what a program needs to run Driftstack, and
201
+ nothing a person manages in the dashboard.** Sign-up and sign-in, two-factor,
202
+ API keys, team members and invites, billing, checkout and crypto orders,
203
+ account and notification settings, the audit log and legal acceptance are
204
+ done in the [Driftstack dashboard](https://app.driftstack.io). Their methods
205
+ are removed, and their endpoints are no longer in the API reference. Sessions,
206
+ agent sessions, profiles and snapshots, saved proxies, archetypes, recipes,
207
+ webhooks, usage and rate limits stay in the SDK. Every change below applies
208
+ to both `Driftstack` and `AsyncDriftstack`.
209
+
210
+ 0.4.0 also runs every session through a proxy of yours:
211
+ `agent_sessions.create()`, and on hosted Driftstack `sessions.create()` and
212
+ `profiles.launch()`, are refused when there is no proxy to use. See
213
+ **Changed — BREAKING** below.
214
+
215
+ ### Removed
216
+
217
+ - **Resources:** `api_keys`, `auth`, `mfa`, `team`, `billing`,
218
+ `crypto_orders`, `audit_log`, `email_preferences` and `legal`, with the
219
+ `ApiKeyList`, `Team*` and `AcceptInviteResponse` exports.
220
+ - **`account`:** `update_me`, `upload_avatar`, `clear_avatar`,
221
+ `list_web_sessions`, `revoke_web_session`, `revoke_all_other_web_sessions`,
222
+ `get_bundled_llm_settings`, `update_bundled_llm_settings` and the four
223
+ `…_byok_anthropic_key` methods. The monthly AI cap and a stored Anthropic
224
+ key are set in dashboard Settings; a key for one agent session is still
225
+ passed to `agent_sessions.create`.
226
+ - **`agent_sessions`:** `set_mode`, `takeover`, `handback` and
227
+ `send_input_event`. They control a session a person drives in the
228
+ Driftstack desktop app, which is not part of this API. `livekit_token`
229
+ stays: live video is part of the public API.
230
+ - **`profiles.transfer`:** giving a profile to another account is not part of
231
+ the public API. To hand one over, `profiles.export` it and have the other
232
+ account `profiles.import_` the file.
233
+ - **Generated models** (`driftstack._generated.models`) for the endpoints
234
+ above and for Driftstack's own staff and internal endpoints, which the
235
+ committed `openapi.json` no longer describes. Import the public models from
236
+ `driftstack`; a model you imported from `_generated` for one of these
237
+ endpoints is gone.
238
+ - **`examples/billing_flow.py` and `examples/crypto_checkout.py`.**
239
+ - **`CANONICAL_MODIFIER_NAMES` and `CanonicalModifier`** (in
240
+ `driftstack.resources.agent_sessions`): the modifier vocabulary of the
241
+ desktop app's live-input channel, which is not part of this API.
242
+ - **`PublicArchetype.canvas_family`**, which the API reference leaves out. A
243
+ response that still carries it parses; the field is ignored.
244
+ - **`pair_mode_state` on the generated `AgentSession` model**, with the
245
+ `PairModeState` model. It describes the desktop app's takeover state, which
246
+ is not part of this API. A response that still carries it parses; the field
247
+ is ignored. The dicts the `agent_sessions` methods return still have the
248
+ key while the server sends it.
249
+ - **`measured_from` on the generated `AccountProxyTestResult1`,
250
+ `AccountProxyTestResult2` and `AccountProxyTestResult3` models, and
251
+ `node_id` on `AccountProxyTestResult3`** (the result of a proxy test). Read
252
+ `measured_by`, which the same models have carried since 0.3.0, for where a
253
+ test ran. A response that still carries either field parses; the field is
254
+ ignored.
255
+
256
+ ### Changed — BREAKING
257
+
258
+ - **`agent_sessions.create()` needs a proxy: `proxy_id` in its body.** Every
259
+ agent session now runs through a proxy you chose, so a create without
260
+ `proxy_id` (or with `proxy_id` set to `None` or `""`) is refused with the
261
+ new **`ProxyRequiredError`** (HTTP 422, type
262
+ `https://errors.driftstack.dev/proxy-required`, `code` `"proxy_required"`)
263
+ before anything is created — no session, no charge, nothing recorded under
264
+ your `idempotency_key` — unless its `profile_id` names a profile bound to a
265
+ proxy (`proxy_choice` `"bound"`), which then runs through that proxy. A
266
+ profile whose proxy was deleted (`"detached"`) is a 409 (`ConflictError`,
267
+ `err.problem["code"]` `"proxy_detached"`) instead. `body` no longer has a
268
+ default, so `create()` with no argument is a `TypeError`; the body is a
269
+ plain `dict`, so a missing key is caught only by the server: set
270
+ `proxy_id` on every create.
271
+ **What to do:** pass the id of one of your saved proxies:
272
+ `client.agent_sessions.create({"mode": "ai", "proxy_id": "b1d7…"})`.
273
+ `client.egress.list_proxies()` lists the ids you have and
274
+ `client.egress.create_proxy(...)` saves a new one; both need a key with the
275
+ `account_owner` scope. A proxy you add in the desktop app is saved to your
276
+ account the first time the app tests it or launches through it. In the
277
+ published OpenAPI document (`openapi.json`) the request body is now
278
+ required; `proxy_id` stays optional in its schema, and its description
279
+ says when it may be left out.
280
+ - **`sessions.create()` and `profiles.launch()`** (`POST /v1/sessions`,
281
+ `POST /v1/profiles/{id}/launch`) **take `proxy_id`** in their body: the id
282
+ of one of your saved proxies; the session's traffic goes out through it.
283
+ Hosted Driftstack runs every session through a proxy of yours, so a create
284
+ without one is refused with the same `ProxyRequiredError` (from a profile
285
+ bound to a proxy, that proxy is used when `proxy_id` is omitted). The key
286
+ is optional in `CreateSessionRequest` and in a plain `dict` body, so a
287
+ missing one is caught only by the server: pass it on every create. A
288
+ session started with a `proxy_id` cannot run the
289
+ step-by-step operations (navigate, interact, capture, ...) yet: they answer
290
+ 503 and leave the session ready. It ends, and reads `destroyed`, when its
291
+ agent session is closed or it stops on its own (30 minutes without
292
+ activity, or its time limit).
293
+
294
+ ### Added
295
+
296
+ - **`account.whoami()`** — `GET /v1/whoami`: the `account_id`, `api_key_id`,
297
+ `tier` and `scopes` behind the key. It needs no scope, so a read-only key
298
+ can call it.
299
+ - **`ready` and `ready_at` on an agent session.** `status` reads `"active"`
300
+ from the moment a session is created; `ready` turns `True` only once the
301
+ session reports that its browser has finished starting, and
302
+ `ready_at` says when. Wait for `ready` before sending the first message. A
303
+ session on a VPN can take longer to become ready. A session that closes while
304
+ `ready` is still `False` failed to start; `closed_reason` says why. `None` from
305
+ an older server, which does not hold messages either: treat `None` as ready.
306
+ - **`SessionNotReadyError`** (HTTP 409, type
307
+ `https://errors.driftstack.dev/session-not-ready`, a `ConflictError`
308
+ subclass). A message sent before the session is ready waits for it, for up to
309
+ 45 seconds on the streamed response the SDK uses; if it is still starting
310
+ then, nothing ran and the message is
311
+ refused with this error. `is_retryable()` returns `True`;
312
+ `retry_after_seconds` says how long to wait, and the same `idempotency_key` is
313
+ safe to reuse.
314
+ - **Two more agent-session models: `"claude-fable-5-1"` (Claude Fable 5.1) and
315
+ `"claude-opus-5-5"` (Claude Opus 5.5).** Both run only on your own Anthropic
316
+ key, like every Opus model: when a session would run on Driftstack's included
317
+ AI, creating it (and every message) raises a 403 `ForbiddenError` whose
318
+ `requires_own_key` is true. The default model is unchanged
319
+ (`"claude-sonnet-5"`).
320
+ - **The generated `AgentIntent` model lists every step kind the API
321
+ returns.** It gains `{"kind": "back"}`, `{"kind": "extract"}` with optional
322
+ `selector`, `body` and `frame` (the text of one element, or of the whole
323
+ page) and `{"kind": "tap_at", "x": ..., "y": ...}` (a tap at a point, in
324
+ viewport pixels from the top-left corner), and so do the step models of
325
+ `intents`, of each result's `intent` and of a recipe's `intent_log`. The
326
+ API already returned these steps; validating one against the 0.3.0 models
327
+ failed. The dicts the SDK returns are unchanged.
328
+ - **`frame` on a read step.** A step with `"kind": "capture"` and
329
+ `"capture": "dom_snapshot"`, and one with `"kind": "extract"` and
330
+ `"body": True`, can now carry `"frame"`: a list of ints naming the embedded
331
+ document (an iframe) the step read instead of the page, as a path of
332
+ positions — the frame's position among the page's frames, then its position
333
+ inside that frame for a nested one, outermost first (`[0]`, `[0, 1]`). Each
334
+ position is 0 to 512, and a path is 1 to 8 levels deep. Frames are numbered
335
+ by the session's own frame list, in the order the browser created them, which
336
+ is not always the order of the iframe tags in the markup. `"frame"` also
337
+ appears on an `interact` with `"action": "type"`: the step typed into an
338
+ element inside that frame. It never appears on a screenshot, a PDF, an
339
+ `extract` by selector or a step that taps, scrolls, presses or waits, so no
340
+ step can name a frame to tap into. A `tap_at` step taps a point on the
341
+ screen, and that point can be inside a frame. No `"frame"` key means the step read
342
+ the page itself. You see it in the steps a turn reports (`intents`, and each
343
+ result's `intent`) and in a recipe's `intent_log`; the generated models
344
+ (`driftstack._generated.models`) carry it as `frame: list[FrameItem] | None`.
345
+ - **`account.list_credentials()`** — `GET /v1/account/me/credentials`: the
346
+ `handle`, `label`, `sites`, `include_subdomains` and `created_at` of each
347
+ saved credential on the account, never the value. Write a handle into an
348
+ agent task as `{{credential:<handle>}}`, as the text of a type step. Each
349
+ credential is locked to its `sites`: a step types it only on an `https://`
350
+ page on one of them, and is refused elsewhere with `diagnosis.category`
351
+ `"credential_site_not_allowed"` (now one of the listed categories). Saving
352
+ and deleting a credential are done in the dashboard. With
353
+ `effective_account` set, a team member of either role lists the account
354
+ owner's saved credentials — the ones agent tasks in that workspace use — as
355
+ the same fields, never a value; a workspace you are not a member of
356
+ answers 403.
357
+ - **`"session_unresponsive"`** joins the `diagnosis.category` values and the
358
+ `AgentNoticeReason` values. The session stopped answering automated steps (the live view may still show the page): it had already failed to answer one, and a quick check sent just before the next step got no answer either, so that step was not sent. The step fails
359
+ with `diagnosis = {"category": "session_unresponsive", "retryable": False}` and
360
+ the turn stops there with `notice_reason == "session_unresponsive"`; a turn
361
+ that hit it before planning anything answers `kind == "refuse"` with the
362
+ same `notice_reason == "session_unresponsive"` (a `refuse` carries
363
+ `notice_reason` only when the refusal was not the agent's choice). **What to
364
+ do:** end the session and launch a new one — sending `"continue"` will not
365
+ help. If the session answers the check again, steps run as normal. Both are
366
+ open strings, so an older install still parses the value.
367
+ - **Five `notice_reason` values for a paused session** (`AgentNoticeReason`).
368
+ A step was refused because a verification check appeared, and nothing
369
+ carries on until the session is resumed (`agent_sessions.resume()`, or
370
+ Resume in the live view). `"challenge_solver_pending"`: the check was
371
+ handed to your configured solver; wait for the page to clear, then resume
372
+ and send `"continue"`. `"challenge_customer_handoff"`: the check needs a
373
+ person, because no solver is configured or it cannot solve this kind;
374
+ solve it in the live view, resume, then send `"continue"`.
375
+ `"verification_email"` and `"verification_sms"`: the site sent a code by
376
+ email or by text message; enter it in the live view, resume, then send
377
+ `"continue"`. `"unexplained_pause"`: the session is paused and no check is
378
+ showing, or the reason could not be confirmed; open the live view, resume,
379
+ then send `"continue"`. No solver provider is offered yet, so today every
380
+ such check is handed to you (`"challenge_customer_handoff"`).
381
+ `AgentNoticeReason` is an open union (`Literal[...] | str`), so an older
382
+ install still parses these values.
383
+ - **`warning` on a successful step result** — present on a `results` entry of
384
+ kind `"success"` when there is something worth knowing about the step, and
385
+ absent otherwise. `"http_error_status"`: a navigation
386
+ reached the site and the site answered with an HTTP status of 400 or above,
387
+ carried in `status`. The step still succeeded — the page that loaded may be
388
+ an error page, a page asking to sign in or to complete a verification step,
389
+ or the whole page served under that status — and its `summary` says what the
390
+ site answered. `"effect_unknown"`: a tap whose effect on the page could not
391
+ be checked (see **Changed**). The generated models list the known kinds and
392
+ accept any other string: treat a kind you do not recognise as a note and
393
+ read `summary`.
394
+ - **`AgentSession.proxy_id`** (optional, `str | None`, on the generated
395
+ models): the id of the account proxy (`GET /v1/account/me/proxies`) the
396
+ session's traffic goes out through. That is the `proxy_id` the create named
397
+ (when it named none, the proxy its profile is bound to), or the one a
398
+ successful `POST /v1/agent-sessions/{id}/egress` moved it to.
399
+ It is set from the create's response onwards and does not follow a
400
+ profile's proxy setting. Every agent session created since proxy_id became required has one;
401
+ `None` means an older session created without a proxy of yours, and also
402
+ that an older server did not send the field. Use
403
+ `"proxy_id" in model.model_fields_set` to tell the two apart.
404
+ - **`profile_id` on an agent session** (optional, `str | None`, on the
405
+ generated models): the saved profile the session runs (`prof_…`), or `None`
406
+ for a session started without one. Absent from older servers.
407
+ - **`upload_max_file_bytes` on an agent session** (optional, `int | None`, on
408
+ the generated models): the largest file, in bytes, that one upload to the
409
+ session (`POST /v1/agent-sessions/{id}/files`) can carry right now. Each
410
+ session takes a file up to its own size, so this is often smaller than the
411
+ 64 MiB per-file maximum, and it can change while the session runs.
412
+ `agent_sessions.get()` returns it while the session is running and has
413
+ reported it; a missing key means not known, not "no limit". A larger file
414
+ is refused with a 413 (`payload-too-large`) and nothing is sent.
415
+ - **`ProxyRequiredError`** — see **Changed — BREAKING** above. `err.code` is
416
+ `"proxy_required"`, and the message is a sentence you can show your user.
417
+ `is_retryable()` is False: the same request is refused the same way.
418
+ - **`DashboardOnlyError`** (403, `https://errors.driftstack.dev/dashboard-only`):
419
+ raised when a request made with an API key asks for something that is done
420
+ in the dashboard — checkout and the billing portal, creating, editing or
421
+ cancelling a crypto order, creating an API key, accepting a team invite or
422
+ the legal terms, and signing dashboard sessions out. It subclasses
423
+ `ForbiddenError`.
424
+ - **`DeviceUnavailableError`** (409, problem type `device-unavailable`,
425
+ subclasses `ConflictError`): a create on a profile whose device is not
426
+ offered right now — on hold until its evidence exists, or still in
427
+ development — is refused before any session exists, so nothing starts and no
428
+ concurrent-session slot is used. `err.archetype` is the profile's device id,
429
+ `err.held_reason` the reason for the hold in one plain sentence (`None` when
430
+ there is none), and the message a sentence you can show.
431
+ - **`AiCreditsExhaustedError`** (HTTP 402, type
432
+ `https://errors.driftstack.dev/ai-credits-exhausted`): an AI turn could not
433
+ run on your account's AI credits, and nothing ran. `reason` is `"balance"`
434
+ (the credits your plan includes are used up; `available_credits`,
435
+ `required_credits` and, when known, `resets_at`), `"debt"` (AI is paused on
436
+ the account; `debt_reason`) or `"task_too_large"` (shorten the request or
437
+ start a new chat). `reason` is also `"balance"` when a paid plan has ended
438
+ and the account has fallen back to the trial plan, which has no AI: no
439
+ credit figures are sent, the message reads _Your plan has ended. Choose a
440
+ plan to use Driftstack’s AI again._, and only choosing a plan lets the
441
+ turn run. Otherwise, on a plan that takes a key of your own, that key
442
+ still runs the turn. Not retryable as-is.
443
+ - **`TrialEndedError`** (HTTP 402, type
444
+ `https://errors.driftstack.dev/trial-ended`): your free trial has ended, or a
445
+ paid plan has, so a new session is refused until you choose a plan. When a
446
+ paid plan ended, `err.problem["code"]` is `"plan_ended"` and the message
447
+ says the plan ended; after a trial the problem has no `code`. Not retryable
448
+ as-is. Reads keep working; everything else runs under the trial's limits
449
+ (see **The trial's limits** below). That includes an account whose paid
450
+ plan ended, which is moved to the trial: its AI messages raise the
451
+ plan-ended `AiCreditsExhaustedError` above; it keeps one saved profile, so
452
+ `profiles.create()`, `clone()`, `import_()` and
453
+ `profile_snapshots.restore()` raise a 429 `QuotaExceededError`
454
+ (`tier-limit`) while it holds one or more; and creating or updating an
455
+ OpenVPN or WireGuard proxy raises a 403 (`ForbiddenError`). An account
456
+ whose trial ended without a plan being chosen is sent an email 30 days
457
+ later, and may be closed 7 days after that email, which revokes its API
458
+ keys; an account whose paid plan ended is not closed this way.
459
+ - **`PayloadTooLargeError`** (HTTP 413, type
460
+ `https://errors.driftstack.dev/payload-too-large`): the request is too
461
+ large for where it has to go, and nothing was sent there — a body over the
462
+ endpoint's size limit, or a file or cookie jar larger than the session takes
463
+ at once. When the session is the limit, `limit_bytes` is the most that fits
464
+ and `size_bytes` what was sent; both are `None` otherwise. A 413 used to
465
+ arrive as a plain `BadRequestError`, so this one subclasses
466
+ `BadRequestError` and an existing `except BadRequestError` still catches it.
467
+ - **`UpgradeRequiredError`** (HTTP 426, type
468
+ `https://errors.driftstack.dev/upgrade-required`): a write or a launch made
469
+ with a Driftstack desktop app sign-in whose app build is older than the
470
+ server's minimum, or that sent no build. Reads still work. It is never
471
+ raised for an API key; SDK code sees it only when it runs on a credential
472
+ the desktop app provisioned. `code` is `"desktop_update_required"` and
473
+ `min_desktop_build` names the build to update to.
474
+ - **The 2026-09 plans** in the generated models' `tier` values: `"trial"`
475
+ (the 7-day free trial a new account starts on), `"starter_v3"`, `"pro_v3"`,
476
+ `"team_v3"` and `"scale_v3"`. They are available from 2026-09-29:
477
+ `"starter_v3"`, `"pro_v3"` and `"team_v3"` can be bought in the dashboard,
478
+ and Scale (`"scale_v3"`) is sold by talking to us. The earlier plans stay
479
+ valid for the accounts that hold them. No new id reuses an old one.
480
+ `usage.current_period()` now works for an account on one of these plans;
481
+ 0.3.0 raised `TransportError` there, because its `UsagePeriodSummary.tier`
482
+ did not list them.
483
+ - **`trial_ends_at` and `trial_ended` on the account (`account.me()`).** When
484
+ the account's free trial ends (or ended), `None` when it is not on a trial;
485
+ and whether it has ended. An account whose trial has ended can still sign in
486
+ and read everything, but starting a session is refused until it subscribes.
487
+ - **`profile_trash_count`, `proxy_cap` and `proxy_count` on the account
488
+ (`account.me()`).** `profile_trash_count` is the number of profiles in the
489
+ trash: they count against `profile_cap` together with `profile_count`, so
490
+ the two add up to what a new profile is checked against. `proxy_cap` is
491
+ the number of saved proxies the account's plan keeps (`None` when
492
+ unmetered), and `proxy_count` the saved proxies of the account itself.
493
+ - **The trial's limits (`"trial"`).** A trial account runs one session at a
494
+ time and keeps one saved profile. Its sessions run as the iPhone 13 or
495
+ iPhone 13 mini only: `profiles.create()` with any other device raises a
496
+ 403 (`ForbiddenError`), and a profile set up as another device cannot
497
+ launch (`"device_not_on_plan"`, see **Changed**). The trial has no AI
498
+ agent, on any funding source, a key of your own included:
499
+ `agent_sessions.create()` with `"mode"` `"ai"` (the default) or `"pair"`
500
+ raises a 403 `ForbiddenError` whose `err.problem` carries
501
+ `"ai_not_on_plan": True` and `"tier"`, and no session starts;
502
+ `"mode": "manual"` is allowed. `agent_sessions.message()` raises too, on
503
+ every turn (see **A plan with no AI runs no AI turn** under **Changed**).
504
+ The trial has no VPN proxies: `egress.create_proxy()` of an OpenVPN or
505
+ WireGuard proxy, and `update_proxy()` of one that stays OpenVPN or
506
+ WireGuard, raise a 403 (`ForbiddenError`), and so do
507
+ `agent_sessions.create()`, `sessions.create()` and `profiles.launch()`
508
+ through one saved earlier (`err.problem["feature"]` is `"vpnEgress"`),
509
+ before any session starts. SOCKS5 proxies work. A trial session runs for
510
+ at most 20 minutes, and the trial includes 120 minutes of session time in
511
+ total, counted from the day it started across every session the account
512
+ runs. A new session gets 20 minutes or what is left of the 120, whichever
513
+ is shorter. An agent session closed at its 20 minutes has `closed_reason`
514
+ `"trial_session_limit"`, and one still running when the 120 minutes run
515
+ out has `"trial_time_used"`; a session from `sessions.create()` or
516
+ `profiles.launch()` is ended the same way and reads `destroyed`. Once the
517
+ 120 minutes are used, `agent_sessions.create()`, `sessions.create()` and
518
+ `profiles.launch()` raise a 429 `QuotaExceededError` (`tier-limit`) whose
519
+ `err.problem["code"]` is `"trial_session_time_used"` (`err.limit` is 120
520
+ and `err.record_type` is `"session_minutes"`), and no session starts.
521
+ `usage.current_period().quotas.session_minute` is 120 on the trial, the
522
+ allowance for the whole trial; it is `None` on every other plan, as it was
523
+ on every plan before. `totals.session_minute` still counts the calendar
524
+ month.
525
+ - **`capability_report.egress_state` can read `"default_connection_down"`**
526
+ (the generated `CapabilityReport` model's `Literal` lists it) — only on a
527
+ session created before proxy_id became required, without a proxy of its own: the connection
528
+ Driftstack provided then stopped carrying traffic while the session ran. It
529
+ was on our side. No session created since reports it, because every one
530
+ runs through a proxy of yours. `"dead_proxy"` keeps
531
+ its meaning — your own proxy stopped. On `GET /v1/sessions/{id}` the same
532
+ fact arrives as the `default_connection_down` code in
533
+ `egress_capabilities.warnings`, and for that session a connection without
534
+ UDP reads `quic_unavailable` and a failed check that its traffic left the
535
+ right way reads `safeguard_failed` — never `udp_unsupported_by_proxy` or
536
+ `safeguard_failed:proxy_egress_verification`, which name a proxy it does
537
+ not have.
538
+ - **Error code `"default_egress_unavailable"`** in an agent session's
539
+ `error_event.code`, and as its `closed_reason` — only on a session created
540
+ before proxy_id became required, without a proxy of its own, whose connection (the one
541
+ Driftstack provided then) failed. It arrives with `customer_actionable`
542
+ `False` — ours to fix. No session created since can report it. `code` stays a `str`, so nothing to change
543
+ unless you branch on it.
544
+ - **`"agent_sessions:live"`** can appear as a `bucket_key` in
545
+ `account.rate_limits()`: the bucket for what a live window reads and
546
+ controls on one agent session, kept apart so list calls on `"global"`
547
+ cannot slow a session you are watching. `agent_sessions.get()`, `stop()`
548
+ and `livekit_token()` now count against it instead of `"global"`; listing
549
+ agent sessions stays on `"global"`. It grows with the number of sessions
550
+ your plan runs at once. The response is a plain `dict`, so no type changes;
551
+ code that handles every bucket key by name has this one to handle.
552
+ - **`egress.create_proxy(body, idempotency_key=...)`.** The key is sent as
553
+ the `Idempotency-Key` header: a retry with the same key returns the proxy
554
+ already created under it — the same id, one row — so a create lost to a
555
+ timeout is safe to repeat. In a team workspace (`effective_account`) the
556
+ proxy is created on the owner's account under the owner's plan, and the
557
+ caller needs the admin role there.
558
+ - **More on each saved proxy** (keys of what `egress.list_proxies()`,
559
+ `create_proxy()` and `update_proxy()` return, and fields of the generated
560
+ `AccountProxyMetadata` model): `revision`, which moves on every edit you
561
+ make and never on a background reading; `config`, the part of a VPN proxy's
562
+ configuration that is not secret (the WireGuard `peer_public_key`,
563
+ `endpoint`, `allowed_ips`, `address` and `dns`, or the OpenVPN `username`;
564
+ `None` on a SOCKS5 or HTTP proxy; the generated `Config` model), while the
565
+ private key, the pre-shared key and the `.ovpn` file stay write-only;
566
+ `has_preshared_key`; and `full_check_ok` and `full_check_at`, the outcome
567
+ of the last full test that ran where sessions run (`measured_by` `"phone"`)
568
+ and when it ran, `None` when no such test has run since the proxy's
569
+ address, scheme or credentials last changed. The list also carries `cap`
570
+ (the workspace's saved-proxy allowance, `None` when unmetered) and `count`.
571
+ An older server does not send them.
572
+ - **A team member's rows in `egress.list_proxies()`.** A team member listing
573
+ a team's proxies (`effective_account` set to the team owner's account)
574
+ gets a narrow row for each: `member_view` (`True`), `id`, `label`,
575
+ `scheme`, `country`, `status`, `revision`, `created_at` and `updated_at` —
576
+ never the address, the username or whether a password is stored (the
577
+ generated `AccountProxyMemberView`; `AccountProxyList.data` holds either
578
+ kind). An admin of the team, and any call without `effective_account`,
579
+ gets full rows (before, `effective_account` was ignored here: see **Saved
580
+ proxies follow the team workspace** under **Changed**). Check for
581
+ `"member_view"` before reading `host`, `port` or `has_password` from a row.
582
+ `status` is `"ok"` or `"failing"` from the last full check Driftstack ran
583
+ through the proxy, or `"unknown"` when none has run since the proxy last
584
+ changed, and `country` is where it was last seen exiting, or `None`.
585
+ - **`expected_revision` on `egress.update_proxy()`.** Send the `revision` you
586
+ last read, and an update to a proxy that another computer changed first is
587
+ refused with a 409 `ConflictError` (`err.problem["code"]` is
588
+ `"stale_revision"`, with `err.problem["current_revision"]`) and nothing
589
+ changes. Omit it to write unconditionally, as before.
590
+ - **A profile's proxy and launch settings** (keys of the profile dict that
591
+ `profiles.get()`, `list()`, `create()`, `update()` and every other call
592
+ that returns a profile give back). `default_proxy_id` is the saved proxy
593
+ the profile launches through, or `None`; set it in the body of `create()`
594
+ or `update()` (`None` unsets it). A proxy that is not one of yours is a
595
+ 404, and an `http` proxy, which cannot carry a session, is a 400.
596
+ `proxy_choice` is set by the server: `"unset"` (never chosen: a launch
597
+ without a `proxy_id` is refused, as before), `"bound"` (a launch of the
598
+ profile without a `proxy_id`, by `profiles.launch()`, or by
599
+ `sessions.create()` or `agent_sessions.create()` with its `profile_id`,
600
+ uses `default_proxy_id`) or
601
+ `"detached"` (its proxy was deleted: a launch without a `proxy_id` is a 409
602
+ until you choose one, and it is never given another proxy on its own).
603
+ `update()` takes `{"proxy_choice": "detached"}` to mark a profile detached
604
+ yourself; beside a non-`None` `default_proxy_id` that is a 400.
605
+ `geolocation` (`{"latitude", "longitude", "accuracy"?}` or `None`) and
606
+ `stop_on_exit_ip_change`, set on `create()` or `update()`, apply to a
607
+ session created with the profile unless the create sets its own. `clone()`
608
+ copies these settings; `import_()` and `profile_snapshots.restore()` start
609
+ the new profile `"unset"` with no location. An older server does not send
610
+ them.
611
+ - **`notes` on a profile** (`str | None`): free-text notes kept with the
612
+ profile, plain text of at most 16 KiB of UTF-8, `None` until something is
613
+ written. Set them with `update(profile_id, {"notes": ...})` (`None` clears
614
+ them). They travel with `clone()` and in what `profiles.export()` returns
615
+ (`profile["notes"]`), which `profiles.import_()` reads back; a file
616
+ exported before notes existed still imports, with `None`.
617
+ - **`revision` on a profile, and `expected_revision` in the body of
618
+ `profiles.update()`.** `revision` moves on every edit (a launch, or a
619
+ session saving its state back, does not move it). Send it back as
620
+ `expected_revision`, and an update to a profile another computer changed
621
+ first is refused with a 409 `ConflictError` (`err.problem["code"]` is
622
+ `"stale_revision"`, with `err.problem["current_revision"]`) and nothing
623
+ changes. Omit it to write unconditionally, as before.
624
+ - **`pages_withheld` in what `profiles.activity()` returns.** The pages and
625
+ session ids come from agent-session records, so they are shown only to a
626
+ caller who could read those sessions: the key needs the `read:sessions`
627
+ scope as well, and in a teammate's workspace (`effective_account`) the
628
+ caller must be an admin there. Anyone else gets `data` empty and
629
+ `pages_withheld` `True`, with `sessions_scanned` and `truncated` as before.
630
+ An older server does not send it.
631
+ - **`client.support`**: your Help conversations with Driftstack, the same
632
+ ones the desktop app and the dashboard show. `list(cursor=..., limit=...)`
633
+ returns a `SupportConversationList` (`data` newest first, `next_cursor`,
634
+ `unread_count`, `open_count`); `create(body, ...)`, with the keyword
635
+ arguments `subject`, `topic_hint`, `context` and `idempotency_key`, opens
636
+ one with its first message and returns a `SupportConversationMessage` (a
637
+ retry with the same key returns the original conversation instead of
638
+ opening a second); `get(id)` returns a `SupportConversationDetail` with its
639
+ messages; `add_message(id, body)`
640
+ sends a follow-up and reopens a closed conversation; `mark_read(id)` and
641
+ `close(id)` return the `SupportConversation`. The models
642
+ (`SupportConversation`, `SupportMessage`, `SupportConversationList`,
643
+ `SupportConversationDetail`, `SupportConversationMessage`) are exported
644
+ from `driftstack`. A person reads and approves every reply; replies appear
645
+ here, and a copy goes to the account's email address. Conversations
646
+ started by email are not listed. An account can have 3 open
647
+ conversations, start at most one every 10 seconds and 5 an hour, and send
648
+ 30 messages in 24 hours; past a limit, `create()` and `add_message()`
649
+ raise a 429 `RateLimitError` whose `err.problem["reason"]` is
650
+ `"support_limit_reached"`, with `err.retry_after_seconds`. A `body` over
651
+ 8,000 characters or a `subject` over 200 is a 400 (`ValidationError`).
652
+ - **`os_fingerprint` and `exit_tcp_stack` on the generated `CapabilityReport`
653
+ model** (an agent session's `capability_report`). `os_fingerprint` is the
654
+ last reading Driftstack took of the operating system of the session's
655
+ proxy: `os`, `confidence`, `at` (when it was taken; it can be any age) and
656
+ how it was taken. `None` means not measured, never "no OS".
657
+ `exit_tcp_stack` (the generated `ExitTcpStack` model) says whether the
658
+ network stack of the session's proxy looks like an iPhone's, measured once
659
+ when the session starts: `"match"`, `"mismatch"` or `"unknown"` (read a
660
+ value you do not recognise as `"unknown"`), with `measured_at` and
661
+ `proxy_id`, the proxy it describes. A `"mismatch"` is a warning: nothing is
662
+ blocked and the session keeps running. `None` means it was not measured.
663
+
664
+ ### Changed
665
+
666
+ - **A tap that changed nothing is a failed step, and a turn that stopped
667
+ because nothing was changing is not `ok`.** A tap the browser made and then
668
+ saw change nothing on the page now comes back as a `failure` with
669
+ `diagnosis["category"] == "no_effect"` (added to the generated `Diagnosis`
670
+ literals), not as a `success`; the agent looks at the page again and tries
671
+ something else. A tap where whether it changed anything could not be checked
672
+ is still a `success`, now with `warning == {"kind": "effect_unknown"}` (added
673
+ to the generated warning literals) and a summary that says so. A
674
+ `plan-executed` result with `notice_reason == "no_progress"` now always has
675
+ `ok` false. A `type` step the browser could type only part of is a `failure`.
676
+ - **The AI agent does not leave your page for a website your message does
677
+ not name.** On a turn that starts on an open page, a plan that would
678
+ navigate to a site the message does not name — by its address or domain,
679
+ or by the site's name after a word such as _open_, _visit_ or _go to_ — or
680
+ step `back` off that page when the message does not ask to go back, is not
681
+ run. The agent plans once more, and if it would still leave, the turn
682
+ answers `kind` `"clarify"` with the `clarifying_question` _I stayed on the
683
+ page you’re on, because your message doesn’t name another website. Should
684
+ I work on this page, or which website should I open?_ (or _Which website
685
+ should I open? Please give its name or address._). A message that asks
686
+ the agent to choose sites itself ("visit a few popular websites", "search
687
+ the web"), or a turn that starts with no page open, still lets it go to
688
+ sites it picks, but never to Driftstack's own. Site names are recognised
689
+ in English; in another language, give the address. A task with no site,
690
+ sent to a session already on a page, now gets this question where the
691
+ agent used to choose a site: name the site in the message.
692
+ - The transport-error scrub redacts `authorization` and every header whose
693
+ name ends in `-key` from the request an error chains, instead of a fixed
694
+ list of header names.
695
+ - **`purpose`** on `Session`, `CreateSessionRequest` and
696
+ `CreateSessionResponse` is typed `Literal["production_customer"]`, the one
697
+ customer value and the default. The two validation values are for
698
+ Driftstack's own validation runs and are not part of the API reference,
699
+ and the server now refuses them: a create with any `"purpose"` other
700
+ than `"production_customer"` raises a 400 (`ValidationError`) that names only `"production_customer"`, where 0.3.0
701
+ created the session. A plain `dict` body that sends either value has to
702
+ drop it.
703
+ - `sessions.search`, `sessions.login` and `agent_sessions.set_egress` stay,
704
+ marked **not available yet** in their docstrings: the server does not serve
705
+ them on any deployment today, and they are not in the API reference until it
706
+ does. The request and response models of `search` and `login` are kept by
707
+ hand, with the same fields; the request models no longer check selector
708
+ lengths or the `timeout_seconds` range themselves.
709
+ - **`egress.update_proxy` refuses `"password": ""`** with a 400
710
+ (`BadRequestError`), and nothing changes. An empty string used to clear the
711
+ stored password, so a blank field in an editor removed a credential without
712
+ anyone asking; send `None` to clear it, and leave `password` out to keep
713
+ it.
714
+ - **Recipes follow the team workspace.** With `effective_account` set to a
715
+ team owner's account, `recipes.create()`, `list()`, `iterate()`, `get()`
716
+ and `delete()` act in the owner's workspace and need the admin role there:
717
+ without it, each one, reads included, raises a 403 (`ForbiddenError`).
718
+ `create()` takes a session of the owner's, files the recipe under the
719
+ owner's account and counts it against the owner's plan; the other four
720
+ read and delete the owner's recipes. Before, `effective_account` was
721
+ ignored here: a recipe was filed under, and listed from, your own account
722
+ whichever workspace you worked in. `recipes.suggest()` is unchanged.
723
+ Without `effective_account`, `create()` takes only a session of your
724
+ own account, so saving a team owner's session from your own workspace is
725
+ now a 404; set `effective_account` to the owner instead. A recipe saved
726
+ before this change stays in the account it was filed in.
727
+ - **A limit on recipes, and a session is saved once.** Each plan keeps up to
728
+ a set number of recipes (the API reference lists them); a save past it
729
+ raises a 429 `QuotaExceededError` (`err.record_type` `"recipe"`, with
730
+ `err.limit` and `err.current`). A session already saved as a recipe cannot
731
+ be saved again under another label or description: that is a 409
732
+ `ConflictError` with the saved recipe's id in `err.problem["recipe_id"]`.
733
+ Repeating the exact same save returns the recipe already saved instead of
734
+ storing a second copy.
735
+ - **Saved proxies follow the team workspace.** With `effective_account` set
736
+ to a team owner's account, `egress.list_proxies()`, `create_proxy()`,
737
+ `update_proxy()`, `delete_proxy()` and `test_proxy()` act on the owner's
738
+ saved proxies, under the owner's plan. Any member of the team can list
739
+ them (a member who is not an admin gets the narrow rows described under
740
+ **Added**); `create_proxy()`, `update_proxy()`, `delete_proxy()` and
741
+ `test_proxy()` need the admin role there, and without it raise a 403
742
+ (`ForbiddenError`). Before, `effective_account` was ignored here: each of
743
+ these acted on your own account's proxies, so an admin who set it listed,
744
+ changed, deleted and tested their own proxies, not the owner's. Without
745
+ `effective_account` they act on your own account, as before.
746
+ - **In a team workspace, a session can go out through the admin's own saved
747
+ proxy.** With `effective_account` set to a team owner's account,
748
+ `agent_sessions.create()`, `sessions.create()` and `profiles.launch()`
749
+ take a `proxy_id` saved on your own account as well as one the owner
750
+ saved. The proxy stays on your account: the owner cannot list it or
751
+ launch through it. Any other id raises a 404 (`NotFoundError`,
752
+ `err.problem["resource"]` `"proxy"`). Before, `agent_sessions.create()`
753
+ looked the id up only among the owner's proxies, so your own was a 404. A
754
+ member without the admin role gets a 403 (`ForbiddenError`); from
755
+ `agent_sessions.create()` its `err.problem["required_role"]` is `"admin"`,
756
+ and from the other two it carries no such key.
757
+ - **The quick proxy check is limited on the Free plan.** On `"free"`,
758
+ `egress.test_proxy()` runs at most 5 times a day (UTC). Every further call
759
+ that day raises a 429 (`RateLimitError`) until midnight UTC, and a check
760
+ counts even when it could not finish. Other plans are not limited.
761
+ - **More saved proxies on the earlier Team and Agency plans.**
762
+ `"team_manual"` keeps 50 saved proxies instead of 25, and `"agency_manual"`
763
+ 200 instead of 50: the same number as the saved profiles each keeps.
764
+ `egress.create_proxy()` on those plans now succeeds up to the new number,
765
+ where it used to raise a 400 (`BadRequestError`, _Proxy limit reached_),
766
+ and the new `cap` in what `egress.list_proxies()` returns, and `proxy_cap`
767
+ on `account.me()`, report it.
768
+ - **An account holding more than its plan keeps launches only what it used
769
+ most recently.** When an account holds more saved profiles than its plan
770
+ keeps (after moving to a smaller plan, for example), nothing is deleted,
771
+ but only the profiles it used most recently, as many as the plan keeps,
772
+ can launch. `agent_sessions.create()` with a `profile_id`,
773
+ `sessions.create()` with a `profile_id` and `profiles.launch()` on any
774
+ other profile raise a 429 `QuotaExceededError` (`tier-limit`) whose
775
+ `err.problem["code"]` is `"over_plan_limit"` (`err.record_type`
776
+ `"profile"`, `err.limit` the number the plan keeps and `err.current` the
777
+ number the account holds), and nothing starts. Profiles in the trash count
778
+ toward `err.current` until they are deleted permanently. Saved proxies
779
+ follow the same rule: a launch through a saved proxy that is not among the
780
+ ones used most recently, as many saved proxies as the plan keeps, raises
781
+ the same 429 with `err.record_type` `"proxy"`. To launch the others,
782
+ delete some or move to a plan that keeps more. A plan without a limit
783
+ never refuses.
784
+ - **A profile whose device the plan does not include cannot launch.**
785
+ `agent_sessions.create()` with a `profile_id`, `sessions.create()` with a
786
+ `profile_id` and `profiles.launch()` raise a 403 `ForbiddenError` whose
787
+ `err.problem["code"]` is `"device_not_on_plan"` (with `"resource"`
788
+ `"profile"`, `"archetype"`, the profile's device id, and `"tier"`) when
789
+ the profile is set up as a device the account's plan does not include,
790
+ and nothing starts. The plans limited to the iPhone 13 and iPhone 13 mini,
791
+ the trial and Free, are the ones that refuse it. The profile is not
792
+ changed, and launches again on a plan that includes its device. Before, a
793
+ profile's device was checked only when the profile was made, so it could
794
+ go on launching as a device the plan no longer included.
795
+ - **A plan with no AI runs no AI turn.** On Free and the trial,
796
+ `agent_sessions.message()` is refused on every turn before anything runs,
797
+ whichever key would have run it, a key of your own included: it raises a
798
+ 403 `ForbiddenError` whose `err.problem` carries `"ai_not_on_plan": True`
799
+ and `"tier"`, or, for an account whose paid plan ended, the plan-ended 402
800
+ `AiCreditsExhaustedError` (see **Added**). The plan is read on each turn,
801
+ so an AI session created before the account moved to Free or the trial
802
+ stops at its next message. Before, whether the plan included the AI agent
803
+ was settled only when a session was created or its mode was changed, never
804
+ on a message.
805
+ - **An agent session created without a profile runs as the iPhone 17**
806
+ (`iphone17_ios18_7_safari26_4`: iOS 18.7, Safari 26.4), the device a
807
+ profile created without an `archetype` gets, instead of the iPhone 16 Pro
808
+ (`iphone16pro_ios18_6_safari18_6`: iOS 18.6, Safari 18.6). That is the
809
+ device and browser the sites it visits see. On a plan that does not
810
+ include the iPhone 17, the session runs as the plan's own default device
811
+ instead: an iPhone 13 on the trial. To keep a session on one device,
812
+ create it with a `profile_id`.
813
+ - **`agent_sessions.get_capture()` serves a screenshot only while its
814
+ session is active, and for at most 30 minutes.** On a session whose
815
+ `status` is not `"active"` (paused, or closed) it raises `NotFoundError`
816
+ (404). Each screenshot also expires 30 minutes after it was taken, even
817
+ while the session keeps taking more. Before, a screenshot could be removed
818
+ only once 30 minutes passed without a new one in that session, and it
819
+ could still be fetched after its session ended. At most the 20 most recent
820
+ per session are kept, as before, so fetch one as soon as its turn ends.
821
+ - **A paused webhook endpoint holds its deliveries, and counts toward the
822
+ limit.** After `webhooks.update(webhook_id, {"active": False})`, the
823
+ endpoint's deliveries are not attempted, not dead-lettered and not counted
824
+ as failures, and events raised while it is paused are queued for it;
825
+ `update(webhook_id, {"active": True})` resumes it and they are delivered.
826
+ Before, an event raised during a pause never reached the endpoint. The
827
+ limit of 10 endpoints an account can have now counts paused ones:
828
+ `webhooks.create()` on an account that has 10, some of them paused, raises
829
+ a 409 (`ConflictError`) where it used to succeed, and resuming an endpoint
830
+ while the account's other endpoints number 10 is a 409 that leaves it
831
+ paused. Delete an endpoint to make room.
832
+ - **A second `webhooks.rotate_secret()` within the grace window succeeds.**
833
+ It replaces only the new secret: the original keeps working until its
834
+ `grace_expires_at`, which the second rotation does not move (its response
835
+ carries the same time). It used to raise a 409 until the first rotation's
836
+ grace window ended.
837
+ - **`webhooks.replay_delivery()` on a delivery that is being attempted right
838
+ now** (`status` `"in_flight"`) raises a 409 (`ConflictError`) instead of a
839
+ 404, and nothing changes. Check its status again in a few minutes, and
840
+ replay it if it was not delivered.
841
+ - **Eight devices are no longer offered.** `archetypes.list()` no longer
842
+ returns `iphone16_ios18_6_safari26_0`, `iphone16_ios18_7_safari26_3`,
843
+ `iphone16pro_ios18_6_safari26_0`, `iphone16pro_ios18_6_safari26_3`,
844
+ `iphone16pro_ios18_7_safari26_3`, `iphone16promax_ios18_6_safari26_0` and
845
+ `iphone16promax_ios18_7_safari26_3`, now on hold until their canvas parity
846
+ is shown, or `iphone17promax_ios18_7_safari26_0_1`, now in development. A
847
+ session on a profile set to one of them is refused with
848
+ `DeviceUnavailableError` before anything starts.
849
+ - **Generated model names moved.** The generator numbers models that share a
850
+ name (`Intent27`, `Session1`, `CapabilityReport1`, …) in the order the API
851
+ reference lists them, and this release's reference has more of them, so a
852
+ numbered name can be a different model than in 0.3.0. `OsFingerprint` is
853
+ now the reading on an agent session's `capability_report`; a saved proxy's
854
+ is `OsFingerprint1`. Import a generated model by a name without a number
855
+ where there is one, and do not rely on a numbered name staying the same
856
+ between releases.
857
+ - **The generated problem models** (`Problem` and the `Agent…Problem`
858
+ models) keep members they do not declare (`extra="allow"`) instead of
859
+ dropping them, and `AgentMessageConflictProblem` declares `code`,
860
+ `retryable` and `retry_after_seconds`, the fields of the 409 a message gets
861
+ while the session is not ready. The errors the client raises read the
862
+ problem document directly, so they are unchanged by this.
863
+
864
+ ### Deprecated
865
+
866
+ - **`egress.attach_to_session` and `egress.get_session_proxy`.** The server
867
+ retired `/v1/sessions/:id/proxy`: both answer 410 (`FeatureUnavailableError`,
868
+ `code: "endpoint_retired"`) on every deployment. Choose the proxy when you
869
+ create the session, with `proxy_id`. Each call emits a `DeprecationWarning`
870
+ naming the replacement.
871
+ - **`EmailAlreadyRegisteredError`, `InvalidCredentialsError`,
872
+ `InvalidAuthTokenError`, `EmailNotVerifiedError`, `MfaStepUpRequiredError`
873
+ and `LegalAcceptanceRequiredError`.** Only dashboard sign-in and dashboard
874
+ actions raise them, so a program using an API key does not receive them.
875
+ They stay so code that catches them keeps working.
876
+ - **`account.me()`** — the dashboard's profile read, not part of the public
877
+ API. Use `account.whoami()`. It keeps working until a future minor release,
878
+ and its first call in a process emits a `DeprecationWarning`.
879
+
880
+ ### Fixed
881
+
882
+ - **`webhooks.list_deliveries()` and `iterate_deliveries()` no longer skip
883
+ deliveries at a page break.** A delivery created in the same millisecond
884
+ as the last one on a page was left off every page. The cursor now carries
885
+ the full time, and a cursor from an earlier call still works.
886
+ - **A dead-lettered webhook delivery counts every attempt.** A delivery that
887
+ used all 6 attempts reached `status` `"dlq"` with `attempts` 5; it now
888
+ reads 6. Deliveries dead-lettered before this release still read one
889
+ fewer. Its `last_response_excerpt` is now the final attempt's response
890
+ body (`None` when that attempt got no response), to match
891
+ `last_response_status`; before, it kept an earlier attempt's body.
892
+ - **A turn refused before the AI model was called costs nothing.** A turn
893
+ on Driftstack's included AI that was refused before the model saw it (the
894
+ usage-policy check refused the task, or the session's token budget was
895
+ spent) reported `cost_usd_cents` 10 in its `usage` and counted 10¢
896
+ against the month's included-AI budget. It now reports 0 and counts
897
+ nothing.
898
+ - **Two `egress.test_proxy()` readings were wrong.** A SOCKS5 proxy that
899
+ accepts the tunnel and closes it at once could fail only after about 12
900
+ seconds, with the `reason` for a slow proxy (_The proxy was too slow to
901
+ respond…_). It now fails at once with _The proxy connected but could not
902
+ reach the internet. Check with your proxy provider._, and a launch through
903
+ it raises `ProxyValidationFailedError` with `reason` `"egress_blocked"`
904
+ instead of `"timeout"`. A test now records `udp_probe` `True` on a SOCKS5
905
+ proxy only when traffic went both ways over UDP through the proxy, and
906
+ records nothing otherwise. Before, it recorded the answer of Driftstack's
907
+ own side, which accepts UDP before your proxy is contacted, so a proxy
908
+ that refuses or drops UDP read `True`. Every SOCKS5 `udp_probe` recorded
909
+ earlier was cleared, so it reads `None` until a test records a new one.
910
+
911
+ ### Security
912
+
913
+ - **Webhook changes in a team workspace need the `account_owner` scope.**
914
+ With `effective_account` set to a team owner's account,
915
+ `webhooks.create()`, `update()`, `delete()`, `rotate_secret()`,
916
+ `send_test()` and `replay_delivery()` need a key with the `account_owner`
917
+ scope as well as the admin role there, as they always did on your own
918
+ account; a key without it raises a 403 (`ForbiddenError`). Before, the
919
+ scope was not checked on a team owner's endpoints, so a team admin's key
920
+ with a narrower scope could create an endpoint on the owner, and receive
921
+ its secret and the owner's events, or rotate or delete the owner's
922
+ endpoints.
923
+ - **`egress.update_proxy` needs `password` when the address changes.** A body
924
+ that changes `host`, `port` or `scheme` (`socks5` ↔ `http`) of a saved proxy
925
+ that stores a password must now carry `password` too — the password, or
926
+ `None` to clear it. Without it the server answers `400`
927
+ (`BadRequestError`) and nothing changes: _To change this proxy’s address,
928
+ send its password again (or null to remove it). A saved password is never
929
+ sent to a new server on its own._ Before, a `password` left out was always
930
+ kept, so anyone able to edit a proxy could send its password to a server of
931
+ their choosing. Omitting `password` on an update that keeps the address — a
932
+ `label` or `username` change, or a resend of the same address — keeps the
933
+ stored password as before. On an `openvpn` or `wireguard` proxy, change
934
+ `host` or `port` by sending `scheme` with the full VPN configuration. On
935
+ both `Driftstack` and `AsyncDriftstack`. The refused attempt is recorded in
936
+ the account's audit log (read it in the dashboard) as `"proxy.move_refused"`. Shipped without a deprecation window under the
937
+ API's [security-fix policy](https://docs.driftstack.io/api/versioning/#security-fixes).
938
+ - **`agent_sessions.livekit_token()` needs the `read:sessions` scope as well
939
+ as `write`.** The token it returns shows the session's live screen as well
940
+ as sending it input, and `agent_sessions.get()` and `get_capture()` already
941
+ needed `read:sessions`. A key whose scopes give it `write` but neither
942
+ `read` nor `read:sessions` (`write` alone, or `write` with another
943
+ `read:…` scope) now raises a 403 (`ForbiddenError`) and no token is made;
944
+ before, it got a token. A key with `read` or `read:sessions` beside
945
+ `write`, or with `account_owner`, works as before.
946
+
947
+ ## [0.3.0] - 2026-09-22
948
+
949
+ **Nothing was removed.** Every name 0.2.0 exported, every method, every
950
+ keyword argument and every error class is still here and still means the
951
+ same thing, so upgrading takes no code change on its own — except where
952
+ **Migration** below says a string comparison needs updating. Every addition
953
+ below exists on **both** `Driftstack` and `AsyncDriftstack`.
954
+
955
+ ### Added
956
+
957
+ - **`EgressCapabilities.safeguards`** — `"passed"`, `"failed"` or
958
+ `"unverified"`, summarising whether every egress safeguard held for a
959
+ session. `"failed"` wins whenever any check did not pass; `"passed"` only
960
+ when the device declared the full set of checks a healthy session reports
961
+ and every one of them reported back; `"unverified"` otherwise. The field is
962
+ **absent**, not `None`, on a session reported before it existed — do not
963
+ read an absent value as `"unverified"` or `"passed"`. Rides everywhere
964
+ `egress_capabilities` already does: `sessions.get()` / `.list()` /
965
+ `.create()`, `profiles.launch()`, and the
966
+ `session.egress_capability_changed` webhook payload.
967
+ - **`measured_by`** on a `?check=full` proxy test result
968
+ (`egress.test_proxy(proxy_id)`) — `"phone"` when a real phone session took
969
+ the measurement, `"driftstack"` when Driftstack itself did because no phone
970
+ could be reached in time. The field this replaces, `measured_from`, is
971
+ still sent beside it with its original values for existing integrations,
972
+ but is no longer documented; read `measured_by` from here on.
973
+ - **`direct_reading` and `website_like_reading`** on `os_fingerprint` — the
974
+ same two facts `single_host_vantage` and `web_port_vantage` already carry,
975
+ under plainer names, added beside the originals rather than replacing
976
+ them. Present on the proxy test result **and** on each saved
977
+ proxy returned by `egress.list_proxies()` / `.update_proxy(proxy_id, body)`.
978
+ - **`"page_unreadable"`** joins the `AgentNoticeReason` values a
979
+ `plan-executed` result can carry: the page could not be read to plan the
980
+ next step, so the task stopped rather than guess. Send `continue` to try
981
+ again.
982
+
983
+ ### Changed
984
+
985
+ - **Two dead `egress_capabilities.warnings` codes are retired from the
986
+ documentation**: `quic_disabled_fallback_http2` and
987
+ `dns_remote_resolve_unsupported_by_proxy`. Neither has ever been sent, so
988
+ this is a documentation correction, not a behavioural change.
989
+
990
+ ### Migration
991
+
992
+ Two closed-string fields were narrowed — values removed, not added — inside
993
+ the `?check=full` proxy test result. `egress.test_proxy()` and
994
+ `egress.list_proxies()` return a plain `dict`, not a validated model, so the
995
+ affected `AccountProxyTestResult*` / `OsFingerprint` models in
996
+ `driftstack._generated.models` are typing-only for these two fields: neither
997
+ change raises at call time, and code comparing a value against one of the old
998
+ strings simply stops matching, silently. The server has sent the new values
999
+ only since 2026-09-21.
1000
+
1001
+ - **`not_run`** — `"node_busy"`, `"node_error"` and `"no_node"` merged into
1002
+ `"check_unavailable"` (you can do exactly one thing about any of the
1003
+ three: try again shortly, or contact support if it persists);
1004
+ `"unresolvable"` is now `"config_unresolvable"`, matching the word the
1005
+ "why a launch is refused" vocabulary already used for the identical fact.
1006
+ `"live_session"` is unchanged.
1007
+ - **`os_fingerprint_unavailable`** — `"vpn_tunnel"` is now
1008
+ `"not_available_for_vpn"`, `"not_observed"` is now `"not_captured"`, and
1009
+ `"observer_off"` is now `"not_offered_here"`.
1010
+
1011
+ Update any code that compares `not_run` or `os_fingerprint_unavailable`
1012
+ against one of the old strings to compare against its replacement instead.
1013
+
1014
+ ### Pre-1.0 stability
1015
+
1016
+ The SDK is pre-1.0. Pin `driftstack-sdk~=0.3.0` rather than an exact version
1017
+ and read this file before bumping.
1018
+
1019
+ ## [0.2.0] - 2026-09-20
1020
+
1021
+ The release the guide [Run AI tasks from your
1022
+ code](https://docs.driftstack.io/guides/run-ai-tasks-from-code/) is written
1023
+ against — 0.1.5 cannot run any of its examples, because the AI agent was not
1024
+ reachable from it at all.
1025
+
1026
+ **Nothing was removed.** Every name 0.1.5 exported, every method, every
1027
+ keyword argument and every error class is still here and still means the same
1028
+ thing, so upgrading takes no code change. What grew: both clients went from 4
1029
+ resources to 19, and `driftstack.__all__` from 22 names to 56. Every addition
1030
+ below exists on **both** `Driftstack` and `AsyncDriftstack`. Read **Changed**
1031
+ before you upgrade anyway — a few behaviours differ, and one of them changes
1032
+ which exception an `except` clause sees.
1033
+
1034
+ ### Added
1035
+
1036
+ #### Run an AI task end to end
1037
+
1038
+ `client.agent_sessions` is new, and is the whole AI surface.
1039
+
1040
+ - **Start it, send the task, close it** — `create(body, ...)` opens a
1041
+ session, on a saved profile if you pass one; `message(id, text, ...)` sends
1042
+ the task in plain words and returns what happened; `get(id)`,
1043
+ `list(...)` and `iterate(...)` read sessions back; `close(id)` ends one and
1044
+ saves the profile's sign-in. A new session is `provisioning` until its
1045
+ browser is ready, then `active`.
1046
+ - **Every way a turn can end is a named result kind** — `plan-executed` (the
1047
+ steps ran, with `answer` when you asked a question), `clarify` (the agent
1048
+ is asking you something), `refuse` (it will not do this), `stopped`, and a
1049
+ step held for your approval. `answer_unavailable` says why there is no
1050
+ `answer` when you asked for one.
1051
+ - **Live progress while it runs** — `message(..., on_step=..., on_event=...)`
1052
+ parses the turn's stream as it arrives: `on_step(step)` gets each step
1053
+ (`{"index", "result"}`) and `on_event(name, data)` every other progress
1054
+ event (`phase`, `plan`, `step_start`, `answer`, `notice`, and any added
1055
+ later). On the async client either callback may be `async def`.
1056
+ - **Approve a step, or don't** — a step with real-world consequences (a
1057
+ payment, a message sent, something deleted) pauses the turn and comes back
1058
+ as `confirmation_required`. Send the same task again with
1059
+ `approve_consequential_actions=` to release it; it accepts the step result
1060
+ exactly as it was returned, as well as `{"category", "matched_text"}`
1061
+ dicts, and raises `ValueError` before sending anything if an entry is
1062
+ neither. An unattended job simply never approves.
1063
+ - **Stop a task that runs too long** — `stop(id)` asks the running turn to
1064
+ stop; the waiting `message()` then returns kind `stopped` rather than
1065
+ raising.
1066
+ - **Screenshots** — `get_capture(id, capture_id)` returns the image behind a
1067
+ `capture` step's `captureId` as `{"content_type", "bytes"}` (`image/png` or
1068
+ `image/jpeg`). Screenshots are kept only briefly, so fetch one as soon as
1069
+ its turn ends; one that is no longer kept raises `NotFoundError`.
1070
+ - **Transcripts** — `transcript(id, last_event_id=..., timeout_s=...)`
1071
+ iterates the session's conversation — an iterator on the sync client, an
1072
+ async iterator on the async one: every entry so far, then each new one as
1073
+ it is written. `last_event_id` resumes after the last `index` you saw, and
1074
+ closing the iterator closes the connection.
1075
+ - **Why a turn handed back, in one word** — a `plan-executed` result carries
1076
+ `notice_reason` beside the `notice` sentence: `"step_limit"`,
1077
+ `"time_limit"`, `"budget_low"`, `"no_progress"`, `"repeated_step"`,
1078
+ `"ai_unavailable"`, `"question"` or `"declined"`, exported as the open
1079
+ union `AgentNoticeReason` (`Literal[...] | str`), so an ending this SDK has
1080
+ never heard of still type-checks and still parses — match the ones you know
1081
+ and show `notice` for the rest.
1082
+ - **When it is safe to retry a message** — a refusal that did no work leaves
1083
+ its `Idempotency-Key` free: after a 409 whose `turn_in_progress` is set, a
1084
+ 429, a 402, a 403 about the plan's AI or the model, or a 502 whose
1085
+ `key_rejected` is false, send the same request again with the **same**
1086
+ `idempotency_key=`. Any other failure gets a new one. One message is one
1087
+ key, always.
1088
+ - **Typed refusals you can act on** — `ForbiddenError.requires_own_key` /
1089
+ `.model` (this model needs your own Anthropic key);
1090
+ `ConflictError.turn_in_progress`, `.session_status`, `.idempotency_status`,
1091
+ `.ai_control_unavailable`, `.phase`, `.tokens_consumed`, `.usage`,
1092
+ `.partial_results` and `.closed_reason` (why a closed session ended,
1093
+ without a second call); `FeatureUnavailableError.stop_unconfirmed` (the one
1094
+ `stop()` 503 worth calling again); and
1095
+ `ByokAnthropicRequiredError.key_rejected` / `.key_source` /
1096
+ `.key_rejected_reason` (your own key was refused, which key, and why).
1097
+ - **Send your own Anthropic key** — `byok_api_key=` on `create()`, so a
1098
+ session can run on a model that requires one without storing anything.
1099
+ - **Bound the whole call** — `timeout_s=` on `message()` (default 50
1100
+ minutes).
1101
+ - **Why a step failed** — a failed step's `Diagnosis.category` explains it,
1102
+ including `"target_unverified"` (the tap was not made because its target
1103
+ could not be checked first — not retryable as the same step; the agent
1104
+ re-plans).
1105
+ - **`examples/agent_chat.py`** is the complete flow end to end: create, wait
1106
+ until ready, send a task with a fresh idempotency key and live progress,
1107
+ handle every result kind, close in `finally`.
1108
+
1109
+ #### Watch one live, or take the wheel
1110
+
1111
+ - **`livekit_token(id)`** — a token for the live video view of a running
1112
+ session, so a person can watch it work. Returns `LiveKitInfo`.
1113
+ - **`set_mode(id, body)`**, **`takeover(id, client_id)`** and
1114
+ **`handback(id)`** — hand control of a running session between the agent
1115
+ and a person, and hand it back. **`send_input_event(id, body)`** drives it
1116
+ while a person holds it, and **`resume(id)`** picks a session back up.
1117
+ - **`set_egress(id, body)`** changes which of your proxies a running session
1118
+ goes out through.
1119
+
1120
+ #### The rest of the API
1121
+
1122
+ `client.sessions`, `client.api_keys`, `client.usage` and `client.webhooks`
1123
+ were the whole client in 0.1.5, and each of the four gained methods:
1124
+ `sessions` gained `get` / `iterate` / `extract` (pull structured data off the
1125
+ page) / `search` / `login`; `usage` gained `series()` for usage over time;
1126
+ `api_keys` gained
1127
+ `rotate()` (issue the replacement and keep the old key working for a grace
1128
+ window); `webhooks` gained `update()` (partial update — it does not rotate
1129
+ the signing secret), `rotate_secret()` (fresh secret shown once, previous one
1130
+ valid for 24h, both signatures sent during the window), `send_test()` (a
1131
+ synthetic `test.ping` delivery so you can check your handler before depending
1132
+ on it), `replay_delivery()` and `iterate_deliveries()`.
1133
+
1134
+ And fourteen resources are new:
1135
+
1136
+ - **`client.profiles`** — create, list, iterate, get, update, delete, plus
1137
+ `clone(profile_id, body=None)` (pass `None` to let the server name it
1138
+ "(copy)", "(copy 2)", …) and `trim()`.
1139
+ - **`client.profile_snapshots`** — immutable point-in-time copies of a
1140
+ profile: `capture`, `list_for_profile`, `list`, `iterate`, `get`,
1141
+ `restore`, `delete`. `restore` creates a NEW profile; the original is never
1142
+ modified.
1143
+ - **`client.account`** — `me()` (the full account profile: timezone, slug,
1144
+ region, avatar, whether MFA is enrolled, team memberships),
1145
+ `update_me()`, `upload_avatar()` / `clear_avatar()`,
1146
+ `list_web_sessions()` / `revoke_web_session(id)` /
1147
+ `revoke_all_other_web_sessions()`, and `rate_limits()` for the limits
1148
+ actually in force on your account.
1149
+ - **`client.auth`** — sign-up, e-mail verification, log in, magic links,
1150
+ password reset, refresh, log out, and the three-call activation flow a CLI
1151
+ or desktop app uses instead of asking for a pasted key
1152
+ (`cli_authorize_initiate` → open the returned `browser_url` → poll
1153
+ `cli_authorize_exchange`, which delivers the key once, then reports
1154
+ `expired`).
1155
+ - **`client.mfa`** — `status`, `enroll`, `verify`, `disable`,
1156
+ `regenerate_recovery_codes`; plus `auth.mfa_challenge()` to exchange a
1157
+ login challenge for a session (the response says whether it was satisfied
1158
+ by `"totp"` or `"recovery"`) and `auth.mfa_step_up()` to refresh the
1159
+ freshness window an operation asked for.
1160
+ - **`client.team`** — members, invites, roles, and `list_owners()` for the
1161
+ workspaces your account has joined.
1162
+ - **`client.audit_log`** — `list` / `iterate`, and `export()`: a single-call
1163
+ JSON export of your account's audit log, up to 10,000 rows, with
1164
+ `truncated` set when there were more.
1165
+ - **`client.billing`** — current state, checkout, and the billing portal.
1166
+ - **`client.crypto_orders`** — `quote`, `create_checkout` (takes
1167
+ `idempotency_key=` so a retry cannot mint a second order), `list`,
1168
+ `iterate` (walks every page for you; narrow it with `status`,
1169
+ `created_after`, `created_before`), `get`, `update_note`, `cancel`,
1170
+ `receipt`. Crypto payments are not refundable, and cancelling only works
1171
+ while an order is pending.
1172
+ - **`client.egress`** and account proxies — manage saved proxies and route a
1173
+ session's traffic through one with `proxy_id` on create.
1174
+ - **`client.archetypes`** — the device archetypes your plan can use.
1175
+ - **`client.recipes`** — `create(agent_session_id=, label=, description=)`
1176
+ snapshots a finished agent session's steps and transcript into a recipe you
1177
+ can replay.
1178
+ - **`client.email_preferences`** — `list` / `set` / `opt_in` / `opt_out`.
1179
+ - **`client.legal`** — record acceptance of a document version.
1180
+
1181
+ #### Errors and retries
1182
+
1183
+ - **`is_retryable(err)`** is exported, so the predicate the built-in retry
1184
+ loop uses is one you can call yourself.
1185
+ - **New error classes**, all importable from `driftstack`:
1186
+ `BadRequestError`, `InternalError`, `FeatureUnavailableError`,
1187
+ `MfaStepUpRequiredError`, `EmailAlreadyRegisteredError`,
1188
+ `InvalidCredentialsError`, `InvalidAuthTokenError`,
1189
+ `EmailNotVerifiedError`, `ByokAnthropicRequiredError`,
1190
+ `ProxyValidationFailedError`, `StorageQuotaExceededError`,
1191
+ `ProfileInUseError`, `BundledLlmConsentRequiredError`,
1192
+ `BundledLlmBudgetExhaustedError`, `PairModeConflictError` and
1193
+ `PairModeStateInvalidTransitionError`.
1194
+ - **`verify_webhook_signature` accepts `header_prev=`** — an optional second
1195
+ signature header. You rarely need it: during a rotation grace window both
1196
+ signatures arrive inside the one `x-driftstack-signature` header, which the
1197
+ verifier already checks.
1198
+
1199
+ ### Changed
1200
+
1201
+ - **Too many AI turns is a `RateLimitError` now.** This refusal answers as
1202
+ `rate-limited` with `retry_after_seconds` 5, so `message()` raises
1203
+ `RateLimitError` where it raised `ConcurrencyLimitError`.
1204
+ `ConcurrencyLimitError` still means exactly what it always meant on
1205
+ `create()`: your plan's limit on sessions running at once.
1206
+ - **A generic 400 raises `BadRequestError` now**, not `ValidationError`. A
1207
+ 400 that carries field-level issues is still a `ValidationError`. Callers
1208
+ with `except ValidationError` around a generic 400 should switch to
1209
+ `except BadRequestError`; `except DriftstackError` catch-alls and
1210
+ `is_retryable` are unaffected.
1211
+ - **The agent-session docstrings describe what the API does** — result kinds,
1212
+ how an approval resumes paused steps, when a key may be reused, the
1213
+ progress event names — and no longer describe how the service is built. The
1214
+ README no longer claims every resource returns a Pydantic model
1215
+ (`agent_sessions` returns dicts) and now says that `ForbiddenError` is a
1216
+ subclass of `AuthError`.
1217
+
1218
+ ### Fixed
1219
+
1220
+ - **A failure category newer than the SDK no longer breaks parsing.**
1221
+ `Diagnosis.category` is `Literal[...] | str`, so a category the server adds
1222
+ after this release is kept as a plain string. ⚠️ **This is the one reason
1223
+ to upgrade before you need to.** In 0.1.5 the field is a closed `Literal`,
1224
+ so a response carrying a category that SDK has never seen raises a pydantic
1225
+ `ValidationError` for the **whole** response — not just that field. Treat a
1226
+ value you do not recognise as `"unknown"`; code comparing `category`
1227
+ against known strings needs no change.
1228
+ - **The account profile matches the full response** — `me()` returns every
1229
+ field the API sends, including the ones added since 0.1.5.
1230
+
1231
+ ### Pre-1.0 stability
1232
+
1233
+ The SDK is pre-1.0. The surface is stable enough to build against, but a
1234
+ MINOR bump may still carry additive changes — new methods, new fields, new
1235
+ error subclasses. **Patch** releases (0.2.x) are fixes and additive types
1236
+ only. Breaking changes that would stop shipping customer code are deferred to
1237
+ 1.0; until then pin `driftstack-sdk~=0.2.0` rather than an exact version, and
1238
+ read this file before bumping.
1239
+
1240
+ ## [0.1.5] - 2026-05-03
1241
+
1242
+ Written on 2026-09-20 from the published wheel: 0.1.5 went to PyPI without a
1243
+ CHANGELOG heading of its own.
1244
+
1245
+ ### Added
1246
+
1247
+ - **`LegalAcceptanceRequiredError`** — a 409 that asks you to accept a
1248
+ document version is its own exception now, carrying the pending
1249
+ acceptances (`document_key` + `current_version` for each) as
1250
+ `pending_acceptances`. Exported from the package root.
1251
+
1252
+ ## [0.1.4] - 2026-05-03
1253
+
1254
+ ### Added
1255
+
1256
+ - **`SessionTimeoutError`** — new typed error subclass mapping
1257
+ the `https://errors.driftstack.dev/session-timeout` problem type
1258
+ (status 504). Distinguished from `DriverError` so callers can
1259
+ react specifically to "the operation didn't finish within the
1260
+ per-call timeout I supplied" without conflating with downstream
1261
+ driver failures. Carries `timeout_ms: int | None` from the
1262
+ problem extension. Re-exported at `driftstack.SessionTimeoutError`
1263
+ for convenient `isinstance` checks.
1264
+
1265
+ ```python
1266
+ from driftstack import SessionTimeoutError
1267
+
1268
+ try:
1269
+ client.sessions.interact(sid, body)
1270
+ except SessionTimeoutError as e:
1271
+ # Retry with a longer timeout, or surface to the user.
1272
+ print(f"Op timed out after {e.timeout_ms} ms")
1273
+ ```
1274
+
1275
+ - Test coverage at `tests/test_errors.py::test_session_timeout_extracts_timeout_ms`.
1276
+
1277
+ ## [0.1.3] - 2026-05-03
1278
+
1279
+ ### Removed
1280
+
1281
+ - `tap.offset` field stripped from the public `InteractAction.tap`
1282
+ shape. Same reason as `tap_at`: a coordinate primitive on the
1283
+ customer-facing schema lets the customer bypass the behavioral
1284
+ simulation layer. Bounded coordinates are still coordinates.
1285
+
1286
+ ### Migration
1287
+
1288
+ If your code passes `offset={"x": ..., "y": ...}` to a `tap`
1289
+ action, the value is now silently dropped (Pydantic strips unknown
1290
+ keys by default). Re-express the intent through selector
1291
+ specificity — better selectors, child-element targeting, ARIA-role
1292
+ qualifiers, text-content matching:
1293
+
1294
+ ```python
1295
+ # Before (0.1.x):
1296
+ client.sessions.interact(
1297
+ session_id,
1298
+ InteractRequest(action={"kind": "tap", "selector": "button.cta", "offset": {"x": 0, "y": 50}}),
1299
+ )
1300
+
1301
+ # After (0.1.3+):
1302
+ client.sessions.interact(
1303
+ session_id,
1304
+ InteractRequest(action={"kind": "tap", "selector": "button.cta .icon-arrow"}),
1305
+ )
1306
+ ```
1307
+
1308
+ Coordinate-level addressing for screenshot-driven workflows is not
1309
+ part of the public API, and is not exposed in this SDK.
1310
+
1311
+ ## [0.1.2] - 2026-05-03
1312
+
1313
+ ### Added
1314
+
1315
+ - Wire-shape regression tests at `tests/test_wire_shape.py` (10
1316
+ tests). Locks the canonical JSON shape for `InteractRequest`,
1317
+ `WaitRequest`, `NavigateRequest`. Asserts rejection of `tap_at` /
1318
+ `type_focused` (they are not part of the public API).
1319
+
1320
+ ### Fixed
1321
+
1322
+ - `tests/test_client.py::test_version_string_matches_pyproject_default`
1323
+ was pinning `__version__ == "0.0.1"` (stale from the pre-publish
1324
+ era). Fixed to assert SemVer shape, not exact value.
1325
+
1326
+ ## [0.1.1] - 2026-05-02
1327
+
1328
+ ### Changed
1329
+
1330
+ - Re-cut: `tap_at` and `type_focused` removed from
1331
+ `InteractAction`. They were briefly added in 0.1.0+ for the
1332
+ self-hosted GUI's manual-control input forwarding, and reverted.
1333
+ Customer-facing schemas stay intent-only — coordinate primitives
1334
+ bypass the behavioral simulation layer. The desktop app now uses
1335
+ a separate endpoint that is not part of the public API.
1336
+
1337
+ ## [0.1.0] - 2026-05-02
1338
+
1339
+ ### Added
1340
+
1341
+ - Inaugural PyPI publish (under a maintainer account pre-entity;
1342
+ will transfer to a company-owned account once the legal entity
1343
+ is registered).
1344
+ - Pydantic models regenerated from updated OpenAPI spec.
1345
+
1346
+ ## [0.0.1] - 2026-05-02
1347
+
1348
+ ### Added
1349
+
1350
+ - `Driftstack` (sync) and `AsyncDriftstack` (async) clients.
1351
+ - Resource accessors mounted on the client: `sessions` (9 methods),
1352
+ `api_keys` (3), `usage` (1), `webhooks` (5).
1353
+ - Typed Pydantic v2 models generated from the API's OpenAPI 3.1 spec.
1354
+ - Error hierarchy: `DriftstackError` base + 14 subclasses covering
1355
+ every documented RFC 7807 problem type. `RateLimitError`,
1356
+ `ConcurrencyLimitError`, `QuotaExceededError` carry the relevant
1357
+ payload fields.
1358
+ - Retry policy (`RetryConfig` + `with_retry`) with exponential
1359
+ backoff and full jitter; honours `Retry-After`.
1360
+ - `verify_webhook_signature` helper (Stripe-style HMAC-SHA256,
1361
+ constant-time).
1362
+ - 85 tests covering errors, retry, webhook signatures, every
1363
+ resource method, and end-to-end customer-journey workflows.
1364
+ - Examples: `quickstart`, `error_handling`, `webhook_receiver`,
1365
+ `langchain_tool`, `pytest_fixture`.
1366
+
1367
+ ### Build
1368
+
1369
+ - Hatchling backend, `py.typed` marker for PEP 561.
1370
+ - Runtime deps: `httpx>=0.27,<1.0`, `pydantic[email]>=2.5,<3.0`.
1371
+ - Dev deps: `pytest`, `pytest-asyncio`, `respx`, `ruff`, `mypy`,
1372
+ `datamodel-code-generator`.
1373
+ - CI: lint + format + mypy + pytest on Ubuntu / Python 3.10.