diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index f97891a67..c775f946f 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "1.26.0"
+ ".": "1.27.0"
}
diff --git a/.stats.yml b/.stats.yml
index 4e9c9d433..8b4084be5 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 119
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai/runloop-0b7cd0c2fc193b18189cd7f44cf45ece7726b5d485fb72577f7d235266432ea0.yml
-openapi_spec_hash: 78c340dbfb9d3d58b24ef318fc2a657b
-config_hash: 218b8d25038e627faab98532392ee9a0
+configured_endpoints: 120
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai/runloop-50686c7a06e432373c5dd7e771ab1483a6ca0531b1dd9a10fc31c0cf2833bcdf.yml
+openapi_spec_hash: a554b4d3815148c094778b53bfe20b21
+config_hash: 6d102da8b9fcc3d0deb42d7a707e78c6
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4daafd6b0..802696f52 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# Changelog
+## 1.27.0 (2026-07-24)
+
+Full Changelog: [v1.26.0...v1.27.0](https://github.com/runloopai/api-client-ts/compare/v1.26.0...v1.27.0)
+
+### Features
+
+* **mux:** wait_for_eviction endpoint for flex eviction notifications ([#10325](https://github.com/runloopai/api-client-ts/issues/10325)) ([429f2b1](https://github.com/runloopai/api-client-ts/commit/429f2b1ea76284ab6006e6e819457dfeddaaf5c9))
+
+
+### Bug Fixes
+
+* **mux:** field-level merge on network policy egress updates ([#10338](https://github.com/runloopai/api-client-ts/issues/10338)) ([835c772](https://github.com/runloopai/api-client-ts/commit/835c7720715b525a371c06745dc93aac249b01ee))
+* **mux:** move eviction-watch SSE route off the /{id} slot ([#10409](https://github.com/runloopai/api-client-ts/issues/10409)) ([80d82cf](https://github.com/runloopai/api-client-ts/commit/80d82cf7798ce423dff86876ff89bd752f80d0f0))
+
+
+### Chores
+
+* **stainless:** generate watch_evictions as an SSE stream ([#10404](https://github.com/runloopai/api-client-ts/issues/10404)) ([043ebbc](https://github.com/runloopai/api-client-ts/commit/043ebbc407067c3d7d620b9a00fa1937dd3bcb3b))
+
## 1.26.0 (2026-07-22)
Full Changelog: [v1.25.0...v1.26.0](https://github.com/runloopai/api-client-ts/compare/v1.25.0...v1.26.0)
diff --git a/api.md b/api.md
index 38fb50a45..3af4951ad 100644
--- a/api.md
+++ b/api.md
@@ -169,6 +169,7 @@ Methods:
Types:
- DevboxAsyncExecutionDetailView
+- DevboxEvictionEventView
- DevboxExecutionDetailView
- DevboxKillExecutionRequest
- DevboxListView
@@ -213,6 +214,7 @@ Methods:
- client.devboxes.suspend(id) -> DevboxView
- client.devboxes.uploadFile(id, { ...params }) -> unknown
- client.devboxes.waitForCommand(devboxId, executionId, { ...params }) -> DevboxAsyncExecutionDetailView
+- client.devboxes.watchEvictions() -> DevboxEvictionEventView
- client.devboxes.writeFileContents(id, { ...params }) -> DevboxExecutionDetailView
## DiskSnapshots
diff --git a/package.json b/package.json
index f69fc5911..34552f541 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@runloop/api-client",
- "version": "1.26.0",
+ "version": "1.27.0",
"description": "The official TypeScript library for the Runloop API",
"author": "Runloop ",
"types": "dist/sdk.d.ts",
diff --git a/packages/mcp-server/manifest.json b/packages/mcp-server/manifest.json
index f5194782b..478bd1dfb 100644
--- a/packages/mcp-server/manifest.json
+++ b/packages/mcp-server/manifest.json
@@ -1,7 +1,7 @@
{
"dxt_version": "0.2",
"name": "@runloop/api-client-mcp",
- "version": "1.26.0",
+ "version": "1.27.0",
"description": "The official MCP Server for the Runloop API",
"author": {
"name": "Runloop",
diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json
index 532a609fe..a759828a0 100644
--- a/packages/mcp-server/package.json
+++ b/packages/mcp-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@runloop/api-client-mcp",
- "version": "1.26.0",
+ "version": "1.27.0",
"description": "The official MCP Server for the Runloop API",
"author": "Runloop ",
"types": "dist/index.d.ts",
diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts
index c494e9f2d..099bbe38f 100644
--- a/packages/mcp-server/src/server.ts
+++ b/packages/mcp-server/src/server.ts
@@ -16,7 +16,7 @@ export const newMcpServer = async (stainlessApiKey: string | undefined) =>
new McpServer(
{
name: 'runloop_api_client_api',
- version: '1.26.0',
+ version: '1.27.0',
},
{
instructions: await getInstructions(stainlessApiKey),
diff --git a/src/index.ts b/src/index.ts
index 0aeaca948..b4ac078fd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -196,6 +196,7 @@ import {
DevboxDeleteDiskSnapshotResponse,
DevboxDownloadFileParams,
DevboxEnableTunnelParams,
+ DevboxEvictionEventView,
DevboxExecuteAsyncParams,
DevboxExecuteParams,
DevboxExecuteSyncParams,
@@ -673,6 +674,7 @@ export declare namespace Runloop {
export {
Devboxes as Devboxes,
type DevboxAsyncExecutionDetailView as DevboxAsyncExecutionDetailView,
+ type DevboxEvictionEventView as DevboxEvictionEventView,
type DevboxExecutionDetailView as DevboxExecutionDetailView,
type DevboxKillExecutionRequest as DevboxKillExecutionRequest,
type DevboxListView as DevboxListView,
diff --git a/src/resources/devboxes/devboxes.ts b/src/resources/devboxes/devboxes.ts
index d413e7ff2..a009f2beb 100644
--- a/src/resources/devboxes/devboxes.ts
+++ b/src/resources/devboxes/devboxes.ts
@@ -2,6 +2,7 @@
import { APIResource } from '../../resource';
import { isRequestOptions } from '../../core';
+import { APIPromise } from '../../core';
import * as Core from '../../core';
import * as Shared from '../shared';
import * as DiskSnapshotsAPI from './disk-snapshots';
@@ -32,6 +33,7 @@ import {
DiskSnapshotsCursorIDPage,
type DiskSnapshotsCursorIDPageParams,
} from '../../pagination';
+import { Stream } from '../../streaming';
import { type Response } from '../../_shims/index';
import {
longPollUntil,
@@ -538,6 +540,19 @@ export class Devboxes extends APIResource {
});
}
+ /**
+ * Subscribe, via server-sent events, to pending infrastructure evictions for every
+ * Devbox in the account. On connect the stream emits one event per Devbox that
+ * currently has a pending eviction, then one event as each further eviction is
+ * scheduled. Best-effort and advisory: a Devbox stays running until its deadline,
+ * and delivery is not guaranteed.
+ */
+ watchEvictions(options?: Core.RequestOptions): APIPromise> {
+ return this._client.get('/v1/devboxes/evictions/watch', { ...options, stream: true }) as APIPromise<
+ Stream
+ >;
+ }
+
/**
* Write UTF-8 string contents to a file at path on the Devbox. Note for large
* files (larger than 100MB), the upload_file endpoint must be used.
@@ -619,6 +634,19 @@ export interface DevboxAsyncExecutionDetailView {
stdout_truncated?: boolean | null;
}
+export interface DevboxEvictionEventView {
+ /**
+ * The ID of the Devbox with a pending eviction.
+ */
+ devbox_id: string;
+
+ /**
+ * Unix timestamp (milliseconds) after which the Devbox will be suspended. Advisory
+ * and best-effort.
+ */
+ eviction_deadline_ms: number;
+}
+
export interface DevboxExecutionDetailView {
/**
* Devbox id where command was executed.
@@ -1539,6 +1567,7 @@ Devboxes.Executions = Executions;
export declare namespace Devboxes {
export {
type DevboxAsyncExecutionDetailView as DevboxAsyncExecutionDetailView,
+ type DevboxEvictionEventView as DevboxEvictionEventView,
type DevboxExecutionDetailView as DevboxExecutionDetailView,
type DevboxKillExecutionRequest as DevboxKillExecutionRequest,
type DevboxListView as DevboxListView,
diff --git a/src/resources/devboxes/index.ts b/src/resources/devboxes/index.ts
index 016a2b2e6..f52683f68 100644
--- a/src/resources/devboxes/index.ts
+++ b/src/resources/devboxes/index.ts
@@ -5,6 +5,7 @@ export {
DevboxViewsDevboxesCursorIDPage,
Devboxes,
type DevboxAsyncExecutionDetailView,
+ type DevboxEvictionEventView,
type DevboxExecutionDetailView,
type DevboxKillExecutionRequest,
type DevboxListView,
diff --git a/src/resources/index.ts b/src/resources/index.ts
index 5e4e2439d..fe0f43691 100644
--- a/src/resources/index.ts
+++ b/src/resources/index.ts
@@ -86,6 +86,7 @@ export {
DevboxViewsDevboxesCursorIDPage,
Devboxes,
type DevboxAsyncExecutionDetailView,
+ type DevboxEvictionEventView,
type DevboxExecutionDetailView,
type DevboxKillExecutionRequest,
type DevboxListView,
diff --git a/src/resources/network-policies.ts b/src/resources/network-policies.ts
index fdf8ad8ae..5ffff2f5c 100644
--- a/src/resources/network-policies.ts
+++ b/src/resources/network-policies.ts
@@ -23,7 +23,8 @@ export class NetworkPolicies extends APIResource {
}
/**
- * Update an existing NetworkPolicy. All fields are optional.
+ * Update an existing NetworkPolicy. All fields are optional - null fields preserve
+ * existing values, provided fields replace entirely.
*/
update(
id: string,
diff --git a/src/sdk/devbox.ts b/src/sdk/devbox.ts
index e34a938d3..6d8627f58 100644
--- a/src/sdk/devbox.ts
+++ b/src/sdk/devbox.ts
@@ -22,6 +22,7 @@ import { LongPollRequestOptions, PollingOptions } from '../lib/polling';
import { Snapshot } from './snapshot';
import { Execution } from './execution';
import { ExecutionResult } from './execution-result';
+import { EvictionCallback, getEvictionMonitor } from './eviction';
import { uuidv7 } from 'uuidv7';
// Re-export Execution and ExecutionResult for Devbox namespace
@@ -1029,6 +1030,35 @@ export class Devbox {
async keepAlive(options?: Core.RequestOptions): Promise {
return this.client.devboxes.keepAlive(this._id, options);
}
+
+ /**
+ * Register a callback fired once if this devbox gets a pending infrastructure eviction.
+ *
+ * The first `onEvict` across any devbox on this client opens a single account-wide
+ * notification stream; it closes automatically once every registered devbox has been
+ * notified. The callback runs at most once and is invoked as
+ * `callback(devbox, evictionDeadlineMs)` — this devbox and the Unix millisecond
+ * deadline by which it will be suspended. Use it to run cleanup before suspend.
+ *
+ * @param {EvictionCallback} callback - Invoked with this devbox and its eviction deadline (ms).
+ *
+ * @example
+ * ```typescript
+ * devbox.onEvict((devbox, evictionDeadlineMs) => {
+ * console.log(`${devbox.id} will be suspended at ${evictionDeadlineMs}`);
+ * });
+ * ```
+ */
+ onEvict(callback: EvictionCallback): void {
+ getEvictionMonitor(this.client).register(this, callback);
+ }
+
+ /**
+ * Withdraw this devbox's eviction interest registered via {@link onEvict}.
+ */
+ cancelOnEvict(): void {
+ getEvictionMonitor(this.client).unregister(this._id);
+ }
}
/**
diff --git a/src/sdk/eviction.ts b/src/sdk/eviction.ts
new file mode 100644
index 000000000..60eb93017
--- /dev/null
+++ b/src/sdk/eviction.ts
@@ -0,0 +1,126 @@
+import { Runloop } from '../index';
+import { Stream } from '../streaming';
+import type { DevboxEvictionEventView } from '../resources/devboxes/devboxes';
+import type { Devbox } from './devbox';
+
+/**
+ * Invoked once when the devbox it was registered for has a pending eviction.
+ *
+ * @param devbox - The devbox with a pending eviction.
+ * @param evictionDeadlineMs - Unix timestamp (ms) by which the devbox will be suspended.
+ */
+export type EvictionCallback = (devbox: Devbox, evictionDeadlineMs: number) => void;
+
+/**
+ * Fans account-wide eviction notifications out to per-devbox callbacks.
+ *
+ * One monitor is shared by every {@link Devbox} built from the same generated
+ * client (see {@link getEvictionMonitor}), so all registered devboxes are served
+ * by a single SSE connection. The stream opens the moment the first devbox
+ * registers interest and closes as soon as the last interested devbox has been
+ * notified.
+ *
+ * Delivery contract:
+ * - The server replays every currently-pending eviction on connect, so a devbox
+ * that registers after its eviction was scheduled is still notified.
+ * - Notifications for devboxes not in the interest set are discarded.
+ * - A devbox is removed from the interest set *before* its callback runs, so the
+ * callback fires at most once even if the server repeats the notification.
+ */
+export class EvictionMonitor {
+ private entries = new Map();
+ private stream: Stream | null = null;
+ private running = false;
+
+ constructor(private client: Runloop) {}
+
+ /** Add `devbox` to the interest set, opening the stream if idle. */
+ register(devbox: Devbox, callback: EvictionCallback): void {
+ this.entries.set(devbox.id, { devbox, callback });
+ if (!this.running) {
+ this.running = true;
+ void this.run();
+ }
+ }
+
+ /** Drop `devboxId`; close the stream if it was the last interested devbox. */
+ unregister(devboxId: string): void {
+ this.entries.delete(devboxId);
+ if (this.entries.size === 0) {
+ this.close();
+ }
+ }
+
+ /** Clear all interest and tear down the stream. */
+ close(): void {
+ this.entries.clear();
+ this.stream?.controller.abort();
+ this.stream = null;
+ this.running = false;
+ }
+
+ private async run(): Promise {
+ // The server force-closes the stream on purpose (leader change / slow consumer) and a
+ // long-lived HTTP/2 stream can drop; the client is expected to reconnect and re-read the
+ // snapshot, which re-delivers anything missed. So reconnect (with backoff) until no devbox is
+ // still interested.
+ const INITIAL_BACKOFF_MS = 500;
+ const MAX_BACKOFF_MS = 30_000;
+ let backoff = INITIAL_BACKOFF_MS;
+ try {
+ while (this.entries.size > 0) {
+ try {
+ // Force the SSE Accept header: the endpoint only streams for text/event-stream; the
+ // generated client's default (application/json) gets an empty text/plain response, so the
+ // feed would silently deliver nothing.
+ const stream = await this.client.devboxes.watchEvictions({
+ headers: { Accept: 'text/event-stream' },
+ });
+ this.stream = stream;
+ for await (const event of stream) {
+ this.dispatch(event);
+ if (this.entries.size === 0) return;
+ }
+ // Clean end: reset backoff and reconnect if still interested.
+ backoff = INITIAL_BACKOFF_MS;
+ } catch (error) {
+ // Aborting the stream on close surfaces as an AbortError; that is an intentional teardown.
+ if (error instanceof Error && error.name === 'AbortError') return;
+ if (this.entries.size === 0) return;
+ // Otherwise a routine disconnect — fall through to backoff + reconnect.
+ }
+ if (this.entries.size === 0) return;
+ await new Promise((resolve) => setTimeout(resolve, backoff));
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
+ }
+ } finally {
+ this.stream = null;
+ this.running = false;
+ }
+ }
+
+ private dispatch(event: DevboxEvictionEventView): void {
+ const entry = this.entries.get(event.devbox_id);
+ if (!entry) return;
+ // Remove before signaling so a duplicate notification for the same devbox is
+ // discarded and the callback fires at most once.
+ this.entries.delete(event.devbox_id);
+ try {
+ entry.callback(entry.devbox, event.eviction_deadline_ms);
+ } catch (error) {
+ console.error(`Error in eviction callback for devbox ${event.devbox_id}:`, error);
+ }
+ }
+}
+
+const monitors = new WeakMap();
+
+/** Return the shared {@link EvictionMonitor} for `client`, creating it once. */
+export function getEvictionMonitor(client: Runloop): EvictionMonitor {
+ let monitor = monitors.get(client);
+ if (!monitor) {
+ monitor = new EvictionMonitor(client);
+ monitors.set(client, monitor);
+ }
+ return monitor;
+}
diff --git a/src/sdk/index.ts b/src/sdk/index.ts
index 088063279..4c8da4af8 100644
--- a/src/sdk/index.ts
+++ b/src/sdk/index.ts
@@ -1,4 +1,5 @@
export { Devbox, DevboxCmdOps, DevboxFileOps, DevboxNetOps, type ExecuteStreamingCallbacks } from './devbox';
+export { EvictionMonitor, getEvictionMonitor, type EvictionCallback } from './eviction';
export { Blueprint } from './blueprint';
export { Snapshot } from './snapshot';
export { StorageObject } from './storage-object';
diff --git a/src/version.ts b/src/version.ts
index a58c5e122..f7cc16593 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const VERSION = '1.26.0'; // x-release-please-version
+export const VERSION = '1.27.0'; // x-release-please-version
diff --git a/tests/api-resources/devboxes/devboxes.test.ts b/tests/api-resources/devboxes/devboxes.test.ts
index fdd846d80..9f547d3bf 100644
--- a/tests/api-resources/devboxes/devboxes.test.ts
+++ b/tests/api-resources/devboxes/devboxes.test.ts
@@ -596,6 +596,17 @@ describe('resource devboxes', () => {
});
});
+ test('watchEvictions', async () => {
+ const responsePromise = client.devboxes.watchEvictions();
+ const rawResponse = await responsePromise.asResponse();
+ expect(rawResponse).toBeInstanceOf(Response);
+ const response = await responsePromise;
+ expect(response).not.toBeInstanceOf(Response);
+ const dataAndResponse = await responsePromise.withResponse();
+ expect(dataAndResponse.data).toBe(response);
+ expect(dataAndResponse.response).toBe(rawResponse);
+ });
+
test('writeFileContents: only required params', async () => {
const responsePromise = client.devboxes.writeFileContents('id', {
contents: 'contents',
diff --git a/tests/smoketests/object-oriented/eviction.test.ts b/tests/smoketests/object-oriented/eviction.test.ts
new file mode 100644
index 000000000..6cae58f2e
--- /dev/null
+++ b/tests/smoketests/object-oriented/eviction.test.ts
@@ -0,0 +1,105 @@
+import { Devbox } from '@runloop/api-client/sdk';
+import type { DevboxEvictionEventView } from '@runloop/api-client/resources/devboxes/devboxes';
+
+// Hermetic tests: they drive the shared EvictionMonitor through an in-memory fake
+// SSE stream instead of a live devbox eviction, so `devbox.onEvict` / `cancelOnEvict`
+// (and the monitor's dispatch + teardown) are exercised deterministically.
+
+// The generated watch_evictions stream: an async-iterable of eviction events plus an
+// AbortController the monitor aborts on close().
+type FakeStream = {
+ controller: AbortController;
+ [Symbol.asyncIterator](): AsyncIterator;
+};
+
+function streamOf(events: DevboxEvictionEventView[]): FakeStream {
+ return {
+ controller: new AbortController(),
+ async *[Symbol.asyncIterator]() {
+ for (const event of events) {
+ yield event;
+ }
+ },
+ };
+}
+
+// A stream that emits nothing until the monitor aborts it (i.e. on cancelOnEvict).
+function blockingStream(): FakeStream {
+ const controller = new AbortController();
+ return {
+ controller,
+ async *[Symbol.asyncIterator]() {
+ await new Promise((resolve) => {
+ if (controller.signal.aborted) {
+ resolve();
+ } else {
+ controller.signal.addEventListener('abort', () => resolve(), { once: true });
+ }
+ });
+ },
+ };
+}
+
+// Type the fake as whatever Devbox.fromId expects, without importing the generated client.
+type Client = Parameters[0];
+function fakeClient(watchEvictions: () => Promise): Client {
+ return { devboxes: { watchEvictions } } as unknown as Client;
+}
+
+const tick = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms));
+
+(process.env['RUN_SMOKETESTS'] ? describe : describe.skip)('object-oriented eviction notifications', () => {
+ test('onEvict fires once for a matching devbox and ignores others', async () => {
+ const events: DevboxEvictionEventView[] = [
+ { devbox_id: 'dbx_other', eviction_deadline_ms: 1 },
+ { devbox_id: 'dbx_match', eviction_deadline_ms: 1_720_000_000_000 },
+ ];
+ const devbox = Devbox.fromId(
+ fakeClient(async () => streamOf(events)),
+ 'dbx_match',
+ );
+
+ let receivedDevbox: Devbox | undefined;
+ const calls: Array<{ id: string; deadline: number }> = [];
+ const fired = new Promise((resolve) => {
+ devbox.onEvict((evicted, evictionDeadlineMs) => {
+ receivedDevbox = evicted;
+ calls.push({ id: evicted.id, deadline: evictionDeadlineMs });
+ resolve();
+ });
+ });
+
+ let guard: ReturnType;
+ const timeout = new Promise((_resolve, reject) => {
+ guard = setTimeout(() => reject(new Error('onEvict callback never fired')), 2000);
+ });
+ try {
+ await Promise.race([fired, timeout]);
+ } finally {
+ clearTimeout(guard!);
+ }
+ await tick();
+
+ // Fired once, with the devbox object and its deadline; the unmatched id was discarded.
+ expect(receivedDevbox).toBe(devbox);
+ expect(calls).toEqual([{ id: 'dbx_match', deadline: 1_720_000_000_000 }]);
+ });
+
+ test('cancelOnEvict stops watching and aborts the stream', async () => {
+ const stream = blockingStream();
+ const devbox = Devbox.fromId(
+ fakeClient(async () => stream),
+ 'dbx_cancel',
+ );
+
+ const callback = jest.fn();
+ devbox.onEvict(callback);
+ await tick(); // let the monitor open the stream
+
+ devbox.cancelOnEvict();
+
+ expect(stream.controller.signal.aborted).toBe(true);
+ await tick();
+ expect(callback).not.toHaveBeenCalled();
+ });
+});