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