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.
- driftstack_sdk-0.4.0/CHANGELOG.md +1184 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/PKG-INFO +67 -41
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/README.md +64 -38
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/pyproject.toml +7 -3
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/__init__.py +27 -17
- driftstack_sdk-0.4.0/src/driftstack/_generated/models.py +3054 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/_version.py +1 -1
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/client.py +7 -49
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/errors.py +281 -21
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/http.py +11 -4
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/__init__.py +0 -3
- driftstack_sdk-0.4.0/src/driftstack/resources/_session_search_login.py +85 -0
- driftstack_sdk-0.4.0/src/driftstack/resources/account.py +120 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/agent_sessions.py +115 -226
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/archetypes.py +0 -1
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/egress.py +74 -12
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/profiles.py +15 -47
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/recipes.py +5 -6
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/sessions.py +30 -13
- driftstack_sdk-0.4.0/src/driftstack/resources/support.py +201 -0
- driftstack_sdk-0.3.0/CHANGELOG.md +0 -438
- driftstack_sdk-0.3.0/src/driftstack/_generated/models.py +0 -2730
- driftstack_sdk-0.3.0/src/driftstack/resources/account.py +0 -171
- driftstack_sdk-0.3.0/src/driftstack/resources/api_keys.py +0 -119
- driftstack_sdk-0.3.0/src/driftstack/resources/audit_log.py +0 -98
- driftstack_sdk-0.3.0/src/driftstack/resources/auth.py +0 -187
- driftstack_sdk-0.3.0/src/driftstack/resources/billing.py +0 -54
- driftstack_sdk-0.3.0/src/driftstack/resources/crypto_orders.py +0 -271
- driftstack_sdk-0.3.0/src/driftstack/resources/email_preferences.py +0 -68
- driftstack_sdk-0.3.0/src/driftstack/resources/legal.py +0 -52
- driftstack_sdk-0.3.0/src/driftstack/resources/mfa.py +0 -74
- driftstack_sdk-0.3.0/src/driftstack/resources/team.py +0 -197
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/.gitignore +0 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/LICENSE +0 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/_generated/__init__.py +0 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/pagination.py +0 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/py.typed +0 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/_common.py +0 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/profile_snapshots.py +0 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/usage.py +0 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/resources/webhooks.py +0 -0
- {driftstack_sdk-0.3.0 → driftstack_sdk-0.4.0}/src/driftstack/retry.py +0 -0
- {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.
|