@driftstack/sdk
v0.5.0
Published
Official TypeScript SDK for the Driftstack API
Maintainers
Readme
@driftstack/sdk
Official TypeScript SDK for the Driftstack API.
Status: pre-1.0. Stable surface for the API contract; the SDK API may shift before 1.0. While the SDK is
0.x, a minor version can change the surface and a patch never does.
Install
npm install @driftstack/sdk
# or
pnpm add @driftstack/sdk
# or
yarn add @driftstack/sdkRequires Node.js ≥ 18 (uses native fetch). Works in any modern runtime that exposes fetch and node:crypto.
Both module systems work: import gives you the ESM build and require() gives
you the CommonJS one, each with its own type declarations. The package carries
its own copy of the API contract types, so there is nothing else to install —
zod is the single runtime dependency, and it is shared rather than duplicated,
so a ZodError the SDK throws is the same class your own zod catches.
Versions
Install normally. The line above writes a caret range (^0.2.0), and for a
0.x package npm resolves a caret to PATCH releases only — which is what you
want, because while the SDK is 0.x a minor version can change the surface and
a patch never does. Read the CHANGELOG before moving to a new
minor. Pin an exact version only if you need a byte-for-byte reproducible
build, and note that your lockfile already gives you one.
Quickstart
An agent session is an iPhone Safari browser in the cloud that an AI drives
for you. Every agent session goes out through one of your saved proxies, so
the create names its id (list them with client.egress.listProxies(), using a key with the
account_owner scope).
import { randomUUID } from 'node:crypto';
import { Driftstack } from '@driftstack/sdk';
const client = new Driftstack({ apiKey: process.env.DRIFTSTACK_API_KEY! });
const agent = await client.agentSessions.create({ mode: 'ai', proxy_id: '<proxy id>' });
try {
while ((await client.agentSessions.get(agent.id)).status === 'provisioning') {
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
const reply = await client.agentSessions.message(
agent.id,
'Open https://example.com and tell me the page title.',
{ idempotencyKey: randomUUID() },
);
console.log(reply.kind, reply.kind === 'plan-executed' ? reply.answer : undefined);
} finally {
await client.agentSessions.close(agent.id);
}Configuration
new Driftstack({
apiKey: 'ds_live_…', // required
baseUrl: 'https://api.driftstack.dev', // optional override
timeoutMs: 30_000, // per-request timeout
retry: {
// retry policy (defaults shown)
maxAttempts: 3,
initialDelayMs: 200,
maxDelayMs: 10_000,
},
});Resources
client.sessions.create(body?)
client.sessions.list(query?)
client.sessions.navigate(id, body)
client.sessions.interact(id, body)
client.sessions.wait(id, body)
client.sessions.getState(id)
client.sessions.capture(id, body)
client.sessions.extract(id, body)
client.sessions.search(id, body) // not available yet — 503 on every deployment, not in the API reference
client.sessions.login(id, body) // not available yet — 503 on every deployment, not in the API reference
client.sessions.destroy(id)
client.egress.attachToSession(sessionId, config) // retired — 410 on every deployment; pass proxy_id at create instead
client.egress.getSessionProxy(sessionId) // not available yet — 404 on every deployment
client.egress.listProxies() // saved reusable proxy library (metadata only)
client.egress.createProxy(body) // secret fields are write-only (never echoed back)
client.egress.updateProxy(id, body)
client.egress.deleteProxy(id)
client.egress.testProxy(id) // server-side reachability probe (SSRF-guarded)
client.agentSessions.create(body, opts?) // start an AI session; body.proxy_id is required; opts: idempotencyKey, byokApiKey
client.agentSessions.get(id)
client.agentSessions.list(query?) // cursor-paginated, newest first
client.agentSessions.iterate(opts?)
client.agentSessions.message(id, userMessage, opts?) // send a task; returns plan-executed | clarify | refuse | stopped | logged-manual — branch on kind
client.agentSessions.getCapture(id, captureId) // a screenshot the agent took: { contentType, bytes }
client.agentSessions.transcript(id, opts?) // async generator: the conversation so far, then live; opts: lastEventId, signal, timeoutMs
client.agentSessions.stop(id) // stop the running task; its message() returns kind 'stopped'
client.agentSessions.close(id)
client.agentSessions.livekitToken(id) // subscriber token for the live view (LiveKitInfo); the create response carries one too
client.agentSessions.setEgress(id, proxyId, applyPoint?) // not available yet — returns status 'unavailable'
client.agentSessions.resume(id, body?) // resume after a resolved bot-challenge
client.profiles.create(body)
client.profiles.list(query?)
client.profiles.iterate(opts?)
client.profiles.get(id)
client.profiles.update(id, body)
client.profiles.delete(id)
client.profiles.clone(id, body?) // auto-derives "(copy)" name when omitted
client.profileSnapshots.capture(profileId, body) // immutable point-in-time copy
client.profileSnapshots.listForProfile(profileId, query?)
client.profileSnapshots.list(query?) // cross-account
client.profileSnapshots.iterate(opts?)
client.profileSnapshots.get(id)
client.profileSnapshots.restore(id, body) // creates a NEW profile
client.profileSnapshots.delete(id)
client.recipes.create(body) // snapshot an agent-session's intent_log
client.recipes.list(query?) // cursor-paginated, newest first
client.recipes.iterate(opts?)
client.recipes.get(id) // recipe + public intent_log; secret type values omitted
client.recipes.delete(id) // recipe management only; no execute method
client.webhooks.create(body) // requires admin scope
client.webhooks.list()
client.webhooks.get(id)
client.webhooks.delete(id)
client.webhooks.listDeliveries(id, query?)
client.webhooks.iterateDeliveries(id, opts?)
client.webhooks.replayDelivery(id) // re-fire a failed/DLQ delivery
client.account.whoami() // the account, key, tier and scopes behind this key
client.account.rateLimits() // per-bucket limits
client.account.getBundledLlmStatus() // month-to-date Driftstack-provided AI spend
client.usage.current()API keys, team members, billing and orders, sign-in, two-factor, email
preferences and legal acceptance are managed in the
Driftstack dashboard, not through the SDK. The API
refuses the dashboard-only actions when they are asked for with an API key
(DashboardOnlyError, a 403).
Every method is fully typed against the public OpenAPI 3.1 contract. Those types ship inside this package, so hover works in your editor with nothing else installed.
Errors
Every error thrown by the SDK extends DriftstackError. Catch the base class to handle anything, or specific subclasses for granular logic:
import {
Driftstack,
RateLimitError,
ConcurrencyLimitError,
ValidationError,
DriftstackError,
} from '@driftstack/sdk';
try {
const session = await client.sessions.create();
// ...
} catch (err) {
if (err instanceof RateLimitError) {
console.warn(`Rate limited; retry after ${err.retryAfterSeconds}s`);
} else if (err instanceof ConcurrencyLimitError) {
console.warn(`At ${err.currentSessions}/${err.limit} concurrent sessions`);
} else if (err instanceof ValidationError) {
console.error('Validation failed:', err.issues);
} else if (err instanceof DriftstackError) {
console.error(`API error ${err.status}: ${err.title}`, err.detail);
} else {
throw err;
}
}Retry behaviour
By default the SDK retries automatically on:
- network failures (
TransportError) - 5xx server errors
- 429 rate-limit responses (honours
Retry-Afterheader /retry_after_secondsbody field)
It does not retry 4xx responses other than 429. Backoff is exponential with full jitter, capped at maxDelayMs. Set retry: { maxAttempts: 0 } to disable retries entirely.
Idempotency on retried writes. Because the SDK retries network failures and 5xx, and a request can reach the server, be processed, and then have its response lost (or a 500 fire after the write commits), an automatically-retried create or charge can execute twice. Pass an
idempotencyKeyon non-idempotent calls — e.g.client.agentSessions.create(body, { idempotencyKey })— and the server collapses the retry onto the first request.
Agent browser turns need the same protection even though they stream: pass
client.agentSessions.message(id, text, { idempotencyKey }) and reuse that key
only for an ambiguous retry of the exact same session/message/approvals/BYOK
request. A completed turn is replayed without executing its browser actions
again; changed or still-running turns fail closed.
A turn is never retried automatically. A refusal raised before the turn did
any work gives the key back, so the same key runs the turn once the cause
is gone: a 409 turnInProgress, a 429, a 402, a 403 about the plan's AI or the
model (requiresOwnKey), and a 502 whose keyRejected is false. So does a
ConflictError whose idempotencyStatus is 'in_progress' — the first
attempt is still being resolved. Every other answer is final for its key:
a completed turn, a failure after the turn started, a rejected own key
(keyRejected), a 500, a 'refuse' result, and the 409 for a closed or paused
session. Those need a new key.
Run an AI task
import { randomUUID } from 'node:crypto';
// Every agent session goes out through one of your saved proxies: pass its id.
// `client.egress.listProxies()` lists them. Without it the create is refused
// with a ProxyRequiredError and nothing is created.
const session = await client.agentSessions.create(
{ mode: 'ai', proxy_id: process.env.DRIFTSTACK_PROXY_ID! },
{ idempotencyKey: randomUUID() },
);
try {
// Poll get(id) while session.status is 'provisioning' before sending.
const resp = await client.agentSessions.message(
session.id,
'Open https://example.com and tell me the main heading.',
{
idempotencyKey: randomUUID(),
onStep: ({ index, result }) => console.log(index, result.kind), // live progress
},
);
if (resp.kind === 'plan-executed') {
console.log(resp.answer, resp.notice); // notice set = the task is not finished yet
const paused = resp.results.filter((r) => r.kind === 'confirmation_required');
// To approve a purchase / payment / account deletion the agent stopped on,
// send the next message with { approveConsequentialActions: paused }.
}
} finally {
await client.agentSessions.close(session.id);
}onEvent receives the other progress events (phase, plan, step_start,
answer, notice; ignore names you do not recognise). When you asked for
information and none could be produced, resp.answer_unavailable says why.
When a turn ends before the task is finished, resp.notice is the sentence to
show a person and resp.notice_reason is the one word to branch on:
step_limit, time_limit, budget_low, no_progress, repeated_step,
ai_unavailable, question or declined — or, from a newer server, a value
this SDK has never heard of, so show notice for anything you do not know.
A capture step's result carries a captureId; fetch the image as soon as the
turn ends, because screenshots are kept only briefly:
import { writeFile } from 'node:fs/promises';
for (const r of resp.results) {
if (r.kind === 'success' && r.captureId !== undefined) {
const shot = await client.agentSessions.getCapture(session.id, r.captureId);
await writeFile(shot.contentType === 'image/jpeg' ? 'shot.jpg' : 'shot.png', shot.bytes);
}
}transcript(id) yields the conversation so far and then follows it live, so
leave the loop when you have what you need (that closes the connection). To
read only what is there now:
const { transcript_length } = await client.agentSessions.get(session.id);
if (transcript_length > 0) {
for await (const { index, entry } of client.agentSessions.transcript(session.id)) {
console.log(index, entry.role, entry.body);
if (index === transcript_length - 1) break;
}
}Pass { lastEventId } (the last index you saw) to carry on from there.
AI refusals are typed: ForbiddenError.requiresOwnKey (an Opus model needs
your own Anthropic key), ConflictError.turnInProgress / .sessionStatus /
.closedReason, RateLimitError (the message rate, or too many AI turns
running at once — wait retryAfterSeconds, then send the same request again,
the same idempotency key and all),
BundledLlmBudgetExhaustedError, BundledLlmConsentRequiredError and
ByokAnthropicRequiredError (.keyRejected, .keySource,
.keyRejectedReason when Anthropic refused your own key). On stop(),
FeatureUnavailableError.stopUnconfirmed means the stop could not be confirmed:
call stop() again. See examples/agent-chat.ts
for the complete flow.
Webhook signature verification
When you wire up Driftstack webhooks, verify each delivery before processing:
import { verifyWebhookSignature } from '@driftstack/sdk';
app.post('/driftstack-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const ok = await verifyWebhookSignature({
body: req.body, // raw bytes — DO NOT use a parsed body
header: req.headers['x-driftstack-signature'],
secret: process.env.DRIFTSTACK_WEBHOOK_SECRET!,
});
if (!ok) return res.status(401).end();
// ... process the event ...
res.status(204).end();
});The verifier uses HMAC-SHA256 with constant-time comparison and rejects timestamps older than 5 minutes (configurable via toleranceSec). It's browser-isomorphic — verifyWebhookSignature works the same way in Node 20+, every modern browser, Tauri WebViews, Cloudflare Workers, Deno, and Bun (uses Web Crypto API under the hood, hence the await).
Examples
See examples/ for complete runnable demos:
quickstart.ts— happiest path, end-to-endagent-chat.ts— run an AI task: create, wait until ready, send a task with live progress, handle each result kind (answer, notice, approvals), closeprofile-management.ts— persistent profiles: create, update, clone, iterate, deletepagination.ts— cursor pagination over list endpointsegress-flow.ts— per-session SOCKS5 proxy configegress-openvpn.ts— OpenVPN egress variantwebhook-receiver.ts— signature-verified webhook receivererror-handling.ts— every documented error classrate-limit-handling.ts— explicit retry-after honouring on top of the built-in retry
License
MIT
