diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d48357c6..42e9c1e9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,7 +26,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
- node-version: '20'
+ node-version: '22'
- name: Bootstrap
run: ./scripts/bootstrap
@@ -55,7 +55,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
- node-version: '20'
+ node-version: '22'
- name: Bootstrap
run: ./scripts/bootstrap
@@ -95,7 +95,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
- node-version: '20'
+ node-version: '22'
- name: Bootstrap
run: ./scripts/bootstrap
@@ -129,7 +129,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: '22'
- name: Install dependencies
run: yarn install
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index e284d65c..ff827d8d 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,7 +1,7 @@
{
- ".": "0.96.0",
- "packages/vertex-sdk": "0.16.0",
- "packages/bedrock-sdk": "0.29.1",
+ ".": "0.97.0",
+ "packages/vertex-sdk": "0.16.1",
+ "packages/bedrock-sdk": "0.29.2",
"packages/foundry-sdk": "0.2.3",
"packages/aws-sdk": "0.3.0"
}
diff --git a/.stats.yml b/.stats.yml
index 0997c882..c59f1a4b 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 97
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-9bc52c052beb11ccfff68e9d96335774c8377f914bcf36278e5774c68aa84e69.yml
-openapi_spec_hash: 3a5f6e11b9fda1c165c6f9edbdee7d90
-config_hash: ed200254fa6776c7b124706c91c80475
+configured_endpoints: 106
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-56896df83861385b03a49bdf36623a811f5dedd599fdacf5680c73f9e73e1546.yml
+openapi_spec_hash: e1812c6c53a1029d12b5d83ca50f4b78
+config_hash: 45b88a8e434814b9e6f4258be5804047
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 092553bd..9508a464 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
# Changelog
+## 0.97.0 (2026-05-19)
+
+Full Changelog: [sdk-v0.96.0...sdk-v0.97.0](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.96.0...sdk-v0.97.0)
+
+### Features
+
+* **client:** Add support for self-hosted sandboxes in CMA with sandbox helpers ([659a343](https://github.com/anthropics/anthropic-sdk-typescript/commit/659a343c820e316229715466b64e420428ee762b))
+
+
+### Bug Fixes
+
+* **typescript:** upgrade tsc-multi so that it works with Node 26 ([623f71c](https://github.com/anthropics/anthropic-sdk-typescript/commit/623f71c848ce9b3b88eb08e009b6b3d08a6e5c1c))
+
+
+### Chores
+
+* **tests:** remove redundant File import ([cf821fc](https://github.com/anthropics/anthropic-sdk-typescript/commit/cf821fcc06f84cb2150cc0ed4ddb862b5d67f633))
+
## 0.96.0 (2026-05-13)
Full Changelog: [sdk-v0.95.2...sdk-v0.96.0](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.95.2...sdk-v0.96.0)
diff --git a/MIGRATION.md b/MIGRATION.md
index 1aa57cdd..cb71a154 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -52,8 +52,15 @@ client.parents.children.retrieve('p_123', 'c_456');
client.parents.children.retrieve('c_456', { parent_id: 'p_123' });
```
-This affects the following methods:
+
+
+This affects the following methods
+- `client.beta.environments.work.retrieve()`
+- `client.beta.environments.work.update()`
+- `client.beta.environments.work.ack()`
+- `client.beta.environments.work.heartbeat()`
+- `client.beta.environments.work.stop()`
- `client.beta.sessions.resources.retrieve()`
- `client.beta.sessions.resources.update()`
- `client.beta.sessions.resources.delete()`
@@ -73,6 +80,9 @@ This affects the following methods:
- `client.beta.memoryStores.memoryVersions.redact()`
- `client.beta.skills.versions.retrieve()`
- `client.beta.skills.versions.delete()`
+- `client.beta.skills.versions.download()`
+
+
### URI encoded path parameters
@@ -123,6 +133,9 @@ client.example.list(undefined, { headers: { ... } });
- `client.beta.environments.list()`
- `client.beta.environments.delete()`
- `client.beta.environments.archive()`
+- `client.beta.environments.work.list()`
+- `client.beta.environments.work.poll()`
+- `client.beta.environments.work.stats()`
- `client.beta.sessions.retrieve()`
- `client.beta.sessions.list()`
- `client.beta.sessions.delete()`
diff --git a/api.md b/api.md
index c9203782..b712525a 100644
--- a/api.md
+++ b/api.md
@@ -532,7 +532,13 @@ Types:
- BetaManagedAgentsAgentToolsetDefaultConfig
- BetaManagedAgentsAgentToolsetDefaultConfigParams
- BetaManagedAgentsAgentToolset20260401
+- BetaManagedAgentsAgentToolset20260401BashInput
+- BetaManagedAgentsAgentToolset20260401EditInput
+- BetaManagedAgentsAgentToolset20260401GlobInput
+- BetaManagedAgentsAgentToolset20260401GrepInput
- BetaManagedAgentsAgentToolset20260401Params
+- BetaManagedAgentsAgentToolset20260401ReadInput
+- BetaManagedAgentsAgentToolset20260401WriteInput
- BetaManagedAgentsAlwaysAllowPolicy
- BetaManagedAgentsAlwaysAskPolicy
- BetaManagedAgentsAnthropicSkill
@@ -555,6 +561,7 @@ Types:
- BetaManagedAgentsMultiagentCoordinator
- BetaManagedAgentsMultiagentCoordinatorParams
- BetaManagedAgentsMultiagentSelfParams
+- BetaManagedAgentsSessionThreadAgent
- BetaManagedAgentsSkillParams
- BetaManagedAgentsURLMCPServerParams
@@ -576,24 +583,49 @@ Methods:
Types:
-- BetaCloudConfig
-- BetaCloudConfigParams
-- BetaEnvironment
-- BetaEnvironmentDeleteResponse
-- BetaLimitedNetwork
-- BetaLimitedNetworkParams
-- BetaPackages
-- BetaPackagesParams
-- BetaUnrestrictedNetwork
+- BetaCloudConfig
+- BetaCloudConfigParams
+- BetaEnvironment
+- BetaEnvironmentDeleteResponse
+- BetaLimitedNetwork
+- BetaLimitedNetworkParams
+- BetaPackages
+- BetaPackagesParams
+- BetaSelfHostedConfig
+- BetaSelfHostedConfigParams
+- BetaUnrestrictedNetwork
Methods:
-- client.beta.environments.create({ ...params }) -> BetaEnvironment
-- client.beta.environments.retrieve(environmentID, { ...params }) -> BetaEnvironment
-- client.beta.environments.update(environmentID, { ...params }) -> BetaEnvironment
-- client.beta.environments.list({ ...params }) -> BetaEnvironmentsPageCursor
-- client.beta.environments.delete(environmentID, { ...params }) -> BetaEnvironmentDeleteResponse
-- client.beta.environments.archive(environmentID, { ...params }) -> BetaEnvironment
+- client.beta.environments.create({ ...params }) -> BetaEnvironment
+- client.beta.environments.retrieve(environmentID, { ...params }) -> BetaEnvironment
+- client.beta.environments.update(environmentID, { ...params }) -> BetaEnvironment
+- client.beta.environments.list({ ...params }) -> BetaEnvironmentsPageCursor
+- client.beta.environments.delete(environmentID, { ...params }) -> BetaEnvironmentDeleteResponse
+- client.beta.environments.archive(environmentID, { ...params }) -> BetaEnvironment
+
+### Work
+
+Types:
+
+- BetaSelfHostedWork
+- BetaSelfHostedWorkHeartbeatResponse
+- BetaSelfHostedWorkListResponse
+- BetaSelfHostedWorkQueueStats
+- BetaSelfHostedWorkStopRequest
+- BetaSelfHostedWorkUpdateRequest
+- BetaSessionWorkData
+
+Methods:
+
+- client.beta.environments.work.retrieve(workID, { ...params }) -> BetaSelfHostedWork
+- client.beta.environments.work.update(workID, { ...params }) -> BetaSelfHostedWork
+- client.beta.environments.work.list(environmentID, { ...params }) -> BetaSelfHostedWorksPageCursor
+- client.beta.environments.work.ack(workID, { ...params }) -> BetaSelfHostedWork
+- client.beta.environments.work.heartbeat(workID, { ...params }) -> BetaSelfHostedWorkHeartbeatResponse
+- client.beta.environments.work.poll(environmentID, { ...params }) -> BetaSelfHostedWork | null
+- client.beta.environments.work.stats(environmentID, { ...params }) -> BetaSelfHostedWorkQueueStats
+- client.beta.environments.work.stop(workID, { ...params }) -> BetaSelfHostedWork
## Sessions
@@ -613,9 +645,12 @@ Types:
- BetaManagedAgentsOutcomeEvaluationResource
- BetaManagedAgentsSession
- BetaManagedAgentsSessionAgent
+- BetaManagedAgentsSessionAgentUpdate
- BetaManagedAgentsSessionMultiagentCoordinator
- BetaManagedAgentsSessionStats
+- BetaManagedAgentsSessionUpdatedEvent
- BetaManagedAgentsSessionUsage
+- BetaManagedAgentsUserToolResultEvent
Methods:
@@ -701,6 +736,7 @@ Types:
- BetaManagedAgentsUserMessageEventParams
- BetaManagedAgentsUserToolConfirmationEvent
- BetaManagedAgentsUserToolConfirmationEventParams
+- BetaManagedAgentsUserToolResultEventParams
Methods:
@@ -733,7 +769,6 @@ Methods:
Types:
- BetaManagedAgentsSessionThread
-- BetaManagedAgentsSessionThreadAgent
- BetaManagedAgentsSessionThreadStats
- BetaManagedAgentsSessionThreadStatus
- BetaManagedAgentsSessionThreadUsage
@@ -911,6 +946,7 @@ Methods:
- client.beta.skills.versions.retrieve(version, { ...params }) -> VersionRetrieveResponse
- client.beta.skills.versions.list(skillID, { ...params }) -> VersionListResponsesPageCursor
- client.beta.skills.versions.delete(version, { ...params }) -> VersionDeleteResponse
+- client.beta.skills.versions.download(version, { ...params }) -> Response
## Webhooks
diff --git a/bin/migration-config.json b/bin/migration-config.json
index 9d443b46..3b800638 100644
--- a/bin/migration-config.json
+++ b/bin/migration-config.json
@@ -4,6 +4,191 @@
"clientClass": "Anthropic",
"baseClientClass": "BaseAnthropic",
"methods": [
+ {
+ "base": "beta.environments.work",
+ "name": "retrieve",
+ "params": [
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": false
+ },
+ {
+ "type": "options"
+ }
+ ],
+ "oldParams": [
+ {
+ "type": "param",
+ "key": "environment_id",
+ "location": "path"
+ },
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": true
+ },
+ {
+ "type": "options"
+ }
+ ]
+ },
+ {
+ "base": "beta.environments.work",
+ "name": "update",
+ "params": [
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": false
+ },
+ {
+ "type": "options"
+ }
+ ],
+ "oldParams": [
+ {
+ "type": "param",
+ "key": "environment_id",
+ "location": "path"
+ },
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": false
+ },
+ {
+ "type": "options"
+ }
+ ]
+ },
+ {
+ "base": "beta.environments.work",
+ "name": "ack",
+ "params": [
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": false
+ },
+ {
+ "type": "options"
+ }
+ ],
+ "oldParams": [
+ {
+ "type": "param",
+ "key": "environment_id",
+ "location": "path"
+ },
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": true
+ },
+ {
+ "type": "options"
+ }
+ ]
+ },
+ {
+ "base": "beta.environments.work",
+ "name": "heartbeat",
+ "params": [
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": false
+ },
+ {
+ "type": "options"
+ }
+ ],
+ "oldParams": [
+ {
+ "type": "param",
+ "key": "environment_id",
+ "location": "path"
+ },
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": true
+ },
+ {
+ "type": "options"
+ }
+ ]
+ },
+ {
+ "base": "beta.environments.work",
+ "name": "stop",
+ "params": [
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": false
+ },
+ {
+ "type": "options"
+ }
+ ],
+ "oldParams": [
+ {
+ "type": "param",
+ "key": "environment_id",
+ "location": "path"
+ },
+ {
+ "type": "param",
+ "key": "work_id",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": false
+ },
+ {
+ "type": "options"
+ }
+ ]
+ },
{
"base": "beta.sessions.resources",
"name": "retrieve",
@@ -706,6 +891,43 @@
"type": "options"
}
]
+ },
+ {
+ "base": "beta.skills.versions",
+ "name": "download",
+ "params": [
+ {
+ "type": "param",
+ "key": "version",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": false
+ },
+ {
+ "type": "options"
+ }
+ ],
+ "oldParams": [
+ {
+ "type": "param",
+ "key": "skill_id",
+ "location": "path"
+ },
+ {
+ "type": "param",
+ "key": "version",
+ "location": "path"
+ },
+ {
+ "type": "params",
+ "maybeOverload": true
+ },
+ {
+ "type": "options"
+ }
+ ]
}
]
}
diff --git a/examples/agents-comprehensive.ts b/examples/agents-comprehensive.ts
index 14df092c..d437fcd7 100644
--- a/examples/agents-comprehensive.ts
+++ b/examples/agents-comprehensive.ts
@@ -102,12 +102,12 @@ async function main() {
const stream = await client.beta.sessions.events.stream(session.id);
for await (const event of stream) {
console.log(JSON.stringify(event, null, 2));
- if (event.type === 'agent.custom_tool_use' && event.name === 'get_weather') {
+ if (event.type === 'agent.tool_use' && event.name === 'get_weather') {
await client.beta.sessions.events.send(session.id, {
events: [
{
- type: 'user.custom_tool_result',
- custom_tool_use_id: event.id,
+ type: 'user.tool_result',
+ tool_use_id: event.id,
content: [{ type: 'text', text: '{"temperature_c": 14}' }],
},
],
diff --git a/examples/managed-agents-observe-tool-calls.ts b/examples/managed-agents-observe-tool-calls.ts
new file mode 100644
index 00000000..c74fa0ab
--- /dev/null
+++ b/examples/managed-agents-observe-tool-calls.ts
@@ -0,0 +1,269 @@
+#!/usr/bin/env -S npm run tsn -T
+
+// Self-hosted runner, "observe every tool call" flavor: the low-level
+// `client.beta.sessions.events.toolRunner(...)` path. It is an async iterable
+// that dispatches a session's `agent.tool_use` / `agent.custom_tool_use` events
+// to your local tools, posts each result back, and yields one
+// `DispatchedToolCall` per completed call — so you can watch every dispatch
+// (name, input, error flag, whether the result posted). Unlike
+// `EnvironmentWorker`, it does NOT poll for work and does NOT manage a
+// work-item lease.
+//
+// Two scenarios, two functions in this file:
+//
+// main() — PRIMARY. A session you created and drive yourself: no work queue,
+// no lease. `toolRunner` just dispatches tools against the session's
+// events, so it works the same whether or not the session's environment is
+// self-hosted. Reach for this when you want per-call visibility on a
+// session you own.
+//
+// observeAsSelfHostedWorker() — SECONDARY (not called by default). If you ARE
+// a self-hosted worker but want per-call visibility, you have to compose the
+// pieces `EnvironmentWorker` would otherwise compose for you: the work
+// poller, the per-session agent tool context, AND your own lease heartbeat
+// running in parallel with the `toolRunner` loop. Reach for
+// `EnvironmentWorker` instead unless you specifically need to see each call.
+//
+// Requires:
+// ANTHROPIC_API_KEY - your API key (read by the SDK client)
+// ANTHROPIC_ENVIRONMENT_ID - the environment the session runs in
+// ANTHROPIC_ENVIRONMENT_KEY - the environment key, the runner's single
+// credential (only the secondary scenario needs it)
+//
+// Security model: the tools execute bash and file operations directly on the
+// host. Run inside a container or other isolation boundary you control.
+
+import Anthropic from '@anthropic-ai/sdk';
+import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod';
+import type { DispatchedToolCall } from '@anthropic-ai/sdk/helpers/beta/environments';
+import {
+ betaAgentToolset20260401,
+ setupSkills,
+ type AgentToolContext,
+} from '@anthropic-ai/sdk/tools/agent-toolset/node';
+import { z } from 'zod/v4';
+
+// Required for sessions to accept a self-hosted environment_id.
+const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01';
+
+const client = new Anthropic();
+
+const environmentId = requireEnv('ANTHROPIC_ENVIRONMENT_ID');
+const workdir = process.env['ANTHROPIC_WORKDIR'] ?? '.';
+
+// A custom tool built with the `betaZodTool` helper — the same
+// `BetaRunnableTool` shape `client.beta.messages.toolRunner` accepts, with the
+// input schema derived from the Zod schema and `run`'s args validated against
+// it. `toolRunner` dispatches it alongside the defaults whenever the model
+// emits a matching `agent.tool_use` event.
+const CURRENT_TIME_DESCRIPTION = 'Get the current time in ISO 8601 format.';
+const currentTime = betaZodTool({
+ name: 'current_time',
+ description: CURRENT_TIME_DESCRIPTION,
+ inputSchema: z.object({}),
+ run: async () => new Date().toISOString(),
+});
+
+// ===== PRIMARY: observe a session you created and drive yourself =====
+
+async function main() {
+ // 1. Create an agent that exposes both the default toolset and our custom
+ // tool, then a session and the initial prompt.
+ const agent = await client.beta.agents.create({
+ name: 'observe-tool-calls-example',
+ model: 'claude-haiku-4-5',
+ system: 'You are running in a sandbox. Use the available tools to answer.',
+ tools: [
+ { type: 'agent_toolset_20260401' },
+ {
+ type: 'custom',
+ name: currentTime.name,
+ description: CURRENT_TIME_DESCRIPTION,
+ input_schema: { type: 'object', properties: {} },
+ },
+ ],
+ });
+ console.log('created agent', agent.id);
+
+ const session = await client.beta.sessions.create({
+ agent: agent.id,
+ environment_id: environmentId,
+ title: 'observe-tool-calls-example',
+ betas: [MANAGED_AGENTS_BETA],
+ });
+ console.log('created session', session.id);
+
+ // 2. Build the per-session agent tool context: the workdir the file tools
+ // confine to, plus the session id `setupSkills` uses to download the
+ // agent's skills into `{workdir}/skills/`. `cleanupSkills` removes them.
+ const ctx: AgentToolContext = { workdir, client, sessionId: session.id };
+ const cleanupSkills = await setupSkills(ctx);
+
+ try {
+ await client.beta.sessions.events.send(session.id, {
+ events: [
+ {
+ type: 'user.message',
+ content: [
+ {
+ type: 'text',
+ text: 'What is the current time? Also run `pwd` to show me the working directory.',
+ },
+ ],
+ },
+ ],
+ betas: [MANAGED_AGENTS_BETA],
+ });
+
+ // 3. Iterate `toolRunner`: it attaches to the session, runs each tool call
+ // locally, posts the result back, and yields one `DispatchedToolCall` per
+ // completed call. `tools` is the standard set bound to `ctx` plus our
+ // custom tool. The runner stops on its own once the session goes idle
+ // (`maxIdleMs` after an `end_turn`); the timeout signal is just a hard
+ // cap for the demo. `toolRunner` does NOT touch any work-item lease.
+ console.log('\n--- tool calls ---');
+ for await (const call of client.beta.sessions.events.toolRunner(session.id, {
+ tools: [...betaAgentToolset20260401(ctx), currentTime],
+ maxIdleMs: 10_000,
+ signal: AbortSignal.timeout(120_000),
+ })) {
+ printCall(call);
+ }
+ } finally {
+ // 4. Clean up the downloaded skills, the session, and the agent.
+ await cleanupSkills().catch(() => {});
+ await client.beta.sessions.delete(session.id, { betas: [MANAGED_AGENTS_BETA] }).catch(() => {});
+ await client.beta.agents.archive(agent.id).catch(() => {});
+ }
+}
+
+// ===== SECONDARY: observe each call while ALSO being a self-hosted worker =====
+
+// NOT called by `main()`. This is the shape you reach for only if you are a
+// self-hosted worker AND you want per-call visibility.
+//
+// IMPORTANT: `toolRunner` does NOT manage the work-item lease — `EnvironmentWorker`
+// is what normally does. `EnvironmentWorker` polls for work, runs the equivalent
+// of the `toolRunner` loop, AND heartbeats the lease (force-stopping on exit),
+// all composed together. Drop down to `toolRunner` for per-call visibility and
+// you give up that lease management — so you have to roll it back yourself: the
+// heartbeat task below runs in parallel with the `toolRunner` loop for exactly
+// that reason. It is a SIMPLIFIED shape (fixed interval, minimal error
+// handling); `EnvironmentWorker`'s internal heartbeat loop is the careful
+// reference — it adapts the interval to the server's `ttl_seconds` and tolerates
+// transient failures with backoff. Rolling your own heartbeat is the cost of
+// getting per-call visibility AND lease management together.
+async function observeAsSelfHostedWorker(): Promise {
+ const environmentKey = requireEnv('ANTHROPIC_ENVIRONMENT_KEY');
+
+ // Every per-session call (the `toolRunner` event stream, the lease heartbeat,
+ // the force-stop) authenticates with the environment key. `apiKey: null`
+ // clears the parent client's `X-Api-Key` — without it, both `X-Api-Key`
+ // AND `Authorization: Bearer …` would land on the wire and the server
+ // rejects the dual auth on the events stream with 401.
+ const sessionClient = client.withOptions({
+ apiKey: null,
+ authToken: environmentKey,
+ credentials: undefined,
+ });
+
+ // The work poller claims items and yields them; it ack's each one and posts
+ // `work.stop` after the loop body returns.
+ for await (const work of client.beta.environments.work.poller({ environmentId, environmentKey })) {
+ const sessionId = work.data.id;
+ console.log('claimed work', work.id, 'for session', sessionId);
+
+ // Per-session agent tool context + skills, same as the primary scenario.
+ const ctx: AgentToolContext = { workdir, client, sessionId };
+ const cleanupSkills = await setupSkills(ctx);
+
+ // A controller shared by the heartbeat task and the `toolRunner` loop:
+ // whichever finishes first aborts the other.
+ const ctrl = new AbortController();
+ const heartbeatTask = heartbeatLease(sessionClient, work, ctrl.signal).finally(() => ctrl.abort());
+
+ try {
+ for await (const call of sessionClient.beta.sessions.events.toolRunner(sessionId, {
+ tools: [...betaAgentToolset20260401(ctx), currentTime],
+ signal: ctrl.signal,
+ })) {
+ printCall(call);
+ }
+ } finally {
+ ctrl.abort();
+ await heartbeatTask;
+ await cleanupSkills().catch(() => {});
+ // Force-stop the work item — `toolRunner` will not do it for you.
+ await sessionClient.beta.environments.work
+ .stop(work.id, { environment_id: work.environment_id, force: true })
+ .catch(() => {});
+ }
+ }
+}
+
+/**
+ * A SIMPLIFIED lease heartbeat — see the comment block on
+ * {@link observeAsSelfHostedWorker}. Beats on a fixed 30s interval; the first
+ * beat uses the `NO_HEARTBEAT` sentinel, each later one echoes the server's
+ * previous `last_heartbeat`. Returns (letting the shared controller abort the
+ * `toolRunner` loop) as soon as the control plane reports the work is
+ * stopping/stopped or the lease was not extended.
+ */
+async function heartbeatLease(
+ client: Anthropic,
+ work: { id: string; environment_id: string },
+ signal: AbortSignal,
+): Promise {
+ let expectedLastHeartbeat = 'NO_HEARTBEAT';
+ while (!signal.aborted) {
+ const resp = await client.beta.environments.work.heartbeat(work.id, {
+ environment_id: work.environment_id,
+ expected_last_heartbeat: expectedLastHeartbeat,
+ });
+ expectedLastHeartbeat = resp.last_heartbeat;
+ if (resp.state === 'stopping' || resp.state === 'stopped' || !resp.lease_extended) return;
+ await delay(30_000, signal);
+ }
+}
+
+/** Print one observed tool call: name, input, error flag, and whether the result posted. */
+function printCall(call: DispatchedToolCall): void {
+ const input = truncate(JSON.stringify(call.event.input));
+ const status = call.isError ? 'error' : 'ok';
+ const posted = call.posted ? '' : ' [result post failed]';
+ console.log(`tool ${call.name}(${input}) -> ${status}${posted}`);
+}
+
+function requireEnv(name: string): string {
+ const v = process.env[name];
+ if (!v) throw new Error(`${name} is required`);
+ return v;
+}
+
+/** Resolve after `ms`, or early when `signal` aborts. */
+function delay(ms: number, signal: AbortSignal): Promise {
+ return new Promise((resolve) => {
+ if (signal.aborted) {
+ resolve();
+ return;
+ }
+ const timer = setTimeout(() => resolve(), ms);
+ signal.addEventListener(
+ 'abort',
+ () => {
+ clearTimeout(timer);
+ resolve();
+ },
+ { once: true },
+ );
+ });
+}
+
+function truncate(s: string, n = 120): string {
+ return s.length <= n ? s : s.slice(0, n) + '…';
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/examples/managed-agents-private-sandbox-worker.ts b/examples/managed-agents-private-sandbox-worker.ts
new file mode 100644
index 00000000..2e925f05
--- /dev/null
+++ b/examples/managed-agents-private-sandbox-worker.ts
@@ -0,0 +1,154 @@
+#!/usr/bin/env -S npm run tsn -T
+
+// Self-contained demo: create an agent and session that target a self-hosted
+// environment, then run an `EnvironmentWorker` locally to serve the default
+// agent_toolset_20260401 tools plus one custom tool.
+//
+// Requires:
+// ANTHROPIC_API_KEY - your API key (read by the SDK client)
+// ANTHROPIC_ENVIRONMENT_ID - a self-hosted environment to poll
+// ANTHROPIC_ENVIRONMENT_KEY - the environment key (the runner's single credential)
+//
+// Security model: the worker executes bash and file operations directly on the
+// host. Run inside a container or other isolation boundary you control.
+
+import Anthropic from '@anthropic-ai/sdk';
+import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool';
+import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node';
+import type { BetaManagedAgentsSessionEvent } from '@anthropic-ai/sdk/resources/beta/sessions/events';
+
+// Required for sessions to accept a self-hosted environment_id.
+const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01';
+
+const client = new Anthropic();
+
+const environmentId = requireEnv('ANTHROPIC_ENVIRONMENT_ID');
+const environmentKey = requireEnv('ANTHROPIC_ENVIRONMENT_KEY');
+const workdir = process.env['ANTHROPIC_WORKDIR'] ?? '.';
+
+// A custom tool, in the same BetaRunnableTool shape that
+// `client.beta.messages.toolRunner` accepts. The worker will execute it
+// alongside the defaults whenever the model emits a matching
+// `agent.tool_use` event.
+const CURRENT_TIME_DESCRIPTION = 'Get the current time in ISO 8601 format.';
+const currentTime: BetaRunnableTool> = {
+ type: 'custom',
+ name: 'current_time',
+ description: CURRENT_TIME_DESCRIPTION,
+ input_schema: { type: 'object', properties: {} },
+ parse: (x) => x as Record,
+ run: async () => new Date().toISOString(),
+};
+
+async function main() {
+ // 1. Create an agent that exposes both the default toolset and our custom
+ // tool. The custom tool definition here tells the model the tool exists;
+ // the implementation lives in `currentTime` above.
+ const agent = await client.beta.agents.create({
+ name: 'self-hosted-runner-example',
+ model: 'claude-haiku-4-5',
+ system: 'You are running in a self-hosted sandbox. Use the available tools to answer.',
+ tools: [
+ { type: 'agent_toolset_20260401' },
+ {
+ type: 'custom',
+ name: currentTime.name,
+ description: CURRENT_TIME_DESCRIPTION,
+ input_schema: { type: 'object', properties: {} },
+ },
+ ],
+ });
+ console.log('created agent', agent.id);
+
+ // 2. Create a session against the self-hosted environment.
+ const session = await client.beta.sessions.create({
+ agent: agent.id,
+ environment_id: environmentId,
+ title: 'self-hosted-runner-example',
+ betas: [MANAGED_AGENTS_BETA],
+ });
+ console.log('created session', session.id);
+
+ try {
+ // 3. Send the initial prompt.
+ await client.beta.sessions.events.send(session.id, {
+ events: [
+ {
+ type: 'user.message',
+ content: [
+ {
+ type: 'text',
+ text: 'What is the current time? Also run `pwd` to show me the working directory.',
+ },
+ ],
+ },
+ ],
+ betas: [MANAGED_AGENTS_BETA],
+ });
+
+ // 4. Run the environment worker. It polls for work, and for each claimed
+ // session sets up the workdir + downloads the agent's skills, then runs
+ // the local tools against the session's `agent.tool_use` events while
+ // heartbeating the work-item lease, force-stopping on exit. `tools` is a
+ // factory so `betaAgentToolset20260401` is bound to each session's
+ // workdir/id. The 60s deadline ends the demo; `run()` returns when it
+ // fires.
+ //
+ // If you already hold a single claimed work item, use `handleItem()`
+ // instead of `run()` — it runs the per-item flow once and returns.
+ // Called with no arguments, `handleItem()` reads the work item from the
+ // `ANTHROPIC_WORK_ID` / `ANTHROPIC_ENVIRONMENT_ID` / `ANTHROPIC_SESSION_ID` /
+ // `ANTHROPIC_ENVIRONMENT_KEY` environment variables, so `environmentId` /
+ // `environmentKey` aren't needed:
+ // await client.beta.environments.work.worker({ workdir, tools }).handleItem();
+ await client.beta.environments.work
+ .worker({
+ environmentId,
+ environmentKey,
+ workdir,
+ tools: (ctx) => [...betaAgentToolset20260401(ctx), currentTime],
+ })
+ .run(AbortSignal.timeout(60_000));
+
+ // 5. Print the resulting transcript.
+ console.log('\n--- transcript ---');
+ for await (const ev of client.beta.sessions.events.list(session.id, { betas: [MANAGED_AGENTS_BETA] })) {
+ console.log(summarise(ev));
+ }
+ } finally {
+ // 6. Clean up.
+ await client.beta.sessions.delete(session.id, { betas: [MANAGED_AGENTS_BETA] }).catch(() => {});
+ await client.beta.agents.archive(agent.id).catch(() => {});
+ }
+}
+
+function requireEnv(name: string): string {
+ const v = process.env[name];
+ if (!v) throw new Error(`${name} is required`);
+ return v;
+}
+
+function summarise(ev: BetaManagedAgentsSessionEvent): string {
+ switch (ev.type) {
+ case 'user.message':
+ case 'agent.message':
+ return `${ev.type}: ${truncate(ev.content.map((b) => (b.type === 'text' ? b.text : '')).join(''))}`;
+ case 'agent.tool_use':
+ return `${ev.type}: ${ev.name} ${truncate(JSON.stringify(ev.input))}`;
+ case 'user.tool_result': {
+ const text = ev.content?.map((b) => (b.type === 'text' ? b.text : '')).join('') ?? '';
+ return `${ev.type}: ${truncate(text)}${ev.is_error ? ' [error]' : ''}`;
+ }
+ default:
+ return ev.type;
+ }
+}
+
+function truncate(s: string, n = 120): string {
+ return s.length <= n ? s : s.slice(0, n) + '…';
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/examples/managed-agents-worker-dispatch.ts b/examples/managed-agents-worker-dispatch.ts
new file mode 100644
index 00000000..10349d2e
--- /dev/null
+++ b/examples/managed-agents-worker-dispatch.ts
@@ -0,0 +1,69 @@
+#!/usr/bin/env -S npm run tsn -T
+
+// Self-hosted runner, "worker-dispatch" flavor: this process was handed ONE
+// already-claimed work item by an upstream poller/orchestrator — e.g. an
+// `ant worker poll --on-work ` loop, or your own dispatcher that
+// spawns a fresh sandbox per work item. It does NOT create an agent or session,
+// and it does NOT poll for work: something else did that and claimed the item.
+//
+// `EnvironmentWorker.handleItem()` with no arguments reads the claimed item's
+// identity from the environment variables the upstream poller sets:
+// ANTHROPIC_WORK_ID - the claimed work item to serve
+// ANTHROPIC_ENVIRONMENT_ID - the self-hosted environment it belongs to
+// ANTHROPIC_SESSION_ID - the session to run tools for
+// ANTHROPIC_ENVIRONMENT_KEY - the environment key (the runner's single credential)
+// It then runs the session's tools while heartbeating the work-item lease,
+// force-stops the item on exit, and returns — one item, then this process exits.
+//
+// Also required:
+// ANTHROPIC_API_KEY - your API key (read by the SDK client)
+//
+// Security model: the worker executes bash and file operations directly on the
+// host. This is the "sandbox process" shape — the upstream orchestrator is
+// expected to have spawned it inside a container or other isolation boundary.
+
+import Anthropic from '@anthropic-ai/sdk';
+import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool';
+import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node';
+
+const client = new Anthropic();
+
+// Base directory for the per-session AgentToolContext. An orchestrator typically
+// points this at the sandbox's scratch space.
+const workdir = process.env['ANTHROPIC_WORKDIR'] ?? '.';
+
+// A custom tool, in the same BetaRunnableTool shape that
+// `client.beta.messages.toolRunner` accepts. The worker executes it alongside
+// the defaults whenever the model emits a matching `agent.tool_use` event.
+const CURRENT_TIME_DESCRIPTION = 'Get the current time in ISO 8601 format.';
+const currentTime: BetaRunnableTool> = {
+ type: 'custom',
+ name: 'current_time',
+ description: CURRENT_TIME_DESCRIPTION,
+ input_schema: { type: 'object', properties: {} },
+ parse: (x) => x as Record,
+ run: async () => new Date().toISOString(),
+};
+
+async function main() {
+ // Build the worker with a `tools` factory — the standard agent_toolset_20260401
+ // set plus our one custom tool — and nothing else. No `environmentId` /
+ // `environmentKey` here: `handleItem()` resolves the work item (and the
+ // environment key) from the `ANTHROPIC_*` env vars the upstream poller set.
+ const worker = client.beta.environments.work.worker({
+ workdir,
+ tools: (ctx) => [...betaAgentToolset20260401(ctx), currentTime],
+ });
+
+ // Service the single claimed item to completion: set up the workdir + download
+ // the session agent's skills, run the local tools against the session's
+ // `agent.tool_use` events while heartbeating the lease, then force-stop the
+ // work item. Returns when the session is done — then this process exits.
+ await worker.handleItem();
+ console.log('work item handled');
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/helpers.md b/helpers.md
index 7d3acaed..66f36d23 100644
--- a/helpers.md
+++ b/helpers.md
@@ -108,7 +108,7 @@ The SDK provides helpers for parsing structured JSON outputs from Claude using J
```ts
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod';
import Anthropic from '@anthropic-ai/sdk';
-import { z } from 'zod/v4';
+import { z } from 'zod';
const client = new Anthropic();
@@ -183,7 +183,7 @@ The SDK provides helper functions to create runnable tools that can be automatic
```ts
import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod';
-import { z } from 'zod/v4';
+import { z } from 'zod';
const weatherTool = betaZodTool({
name: 'get_weather',
@@ -505,3 +505,99 @@ See the following example files for more usage patterns:
- [`examples/tools-helpers-zod.ts`](examples/tools-helpers-zod.ts) - Zod-based tools
- [`examples/tools-helpers-json-schema.ts`](examples/tools-helpers-json-schema.ts) - JSON Schema tools
- [`examples/tools.ts`](examples/tools.ts) - Basic tool usage
+
+# Self-Hosted Environment Runner
+
+The SDK exposes a few building blocks for serving managed-agents sessions from a self-hosted environment:
+
+- `client.beta.environments.work.worker({ ... })` (returns an `EnvironmentWorker`, also exported from `@anthropic-ai/sdk/helpers/beta/environments`) — the full worker: polls for work, and for each claimed session sets up the workdir + downloads the agent's skills, runs the local tools against the session's `agent.tool_use` events while heartbeating the work-item lease, force-stops the work on exit, cleans up the downloaded skills, and loops. `worker.handleItem(...)` runs that same per-item flow for a single work item you've already claimed; with no arguments it reads the work id / environment id / session id from `ANTHROPIC_WORK_ID` / `ANTHROPIC_ENVIRONMENT_ID` / `ANTHROPIC_SESSION_ID` (the env vars `ant worker poll --on-work` sets) and the environment key from `ANTHROPIC_ENVIRONMENT_KEY`. `environmentId` / `environmentKey` are only needed by `run()`'s poll loop — `handleItem()` works without them. Composed from the two pieces below.
+- `client.beta.environments.work.poller(...)` — control-plane only (a `WorkPoller`): claims work items from an environment, ack's each one before yielding it, and posts `stop` automatically when the consumer's loop body returns or the iteration ends.
+- `client.beta.sessions.events.toolRunner(...)` — the sessions-side counterpart to `client.beta.messages.toolRunner` (a `SessionToolRunner`): for each `agent.tool_use` event the agent emits during a session, runs the matching tool from your registry, posts the result back, and yields a `DispatchedToolCall` so you can observe what happened. Internally drives event-stream reconnect and result posting; it does not touch the work-item lease.
+
+The tool implementations themselves live in a separate Node-only module — `@anthropic-ai/sdk/tools/agent-toolset/node` — alongside `@anthropic-ai/sdk/tools/memory/node`. `betaAgentToolset20260401(ctx)` returns the standard `agent_toolset_20260401` set (`bash`, `read`, `write`, `edit`, `glob`, `grep`) as `BetaRunnableTool` objects, the same shape `client.beta.messages.toolRunner` accepts. The individual factories — `betaBashTool`, `betaReadTool`, `betaWriteTool`, `betaEditTool`, `betaGlobTool`, `betaGrepTool` — are exported too.
+
+> **Node 22+ required.** The agent toolset uses the native `fs.glob` (added in Node 22) for its `glob` tool, so `@anthropic-ai/sdk/tools/agent-toolset/node` requires Node 22 or newer. The rest of the SDK still supports Node 18+.
+
+```ts
+import Anthropic from '@anthropic-ai/sdk';
+import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node';
+
+const client = new Anthropic();
+
+// One-stop worker: poll → run the toolset for each session → force-stop → loop.
+// `tools` is a factory so `betaAgentToolset20260401` is bound to each session's workdir/id.
+// `environmentKey` is the runner's single credential — it authenticates both the
+// work-poll calls and every per-session call (event stream, heartbeat, force-stop).
+await client.beta.environments.work
+ .worker({
+ environmentId: process.env.ANTHROPIC_ENVIRONMENT_ID!,
+ environmentKey: process.env.ANTHROPIC_ENVIRONMENT_KEY!,
+ workdir: '/workspace',
+ tools: (ctx) => [...betaAgentToolset20260401(ctx), myCustomTool],
+ })
+ .run(AbortSignal.timeout(60 * 60_000));
+```
+
+If you already hold a claimed work item — e.g. an `ant worker poll --on-work` script handed one to a fresh process — call `handleItem` to run just the per-item flow (build the workdir + skills, run the session's tools while heartbeating the lease, force-stop on exit). Inside that command the work id / environment id / session id / environment key are already in the environment, so the sandbox case is just:
+
+```ts
+await client.beta.environments.work.worker({ workdir: '/workspace', tools }).handleItem();
+```
+
+Pass the values explicitly when you have the objects in hand (e.g. you iterate the poller yourself):
+
+```ts
+await client.beta.environments.work.worker({ workdir: '/workspace', tools }).handleItem({
+ workId: work.id,
+ environmentId: work.environment_id,
+ sessionId: work.data.id,
+ environmentKey: process.env.ANTHROPIC_ENVIRONMENT_KEY!,
+});
+```
+
+`betaAgentToolset20260401(ctx)` returns a plain array — filter or extend it to customise:
+
+```ts
+const tools = betaAgentToolset20260401(ctx).filter((t) => t.name !== 'grep'); // remove
+const tools = [...betaAgentToolset20260401(ctx), myCustomTool]; // extend with any BetaRunnableTool
+```
+
+If you want the pieces separately — e.g. to observe each tool call, or to manage the work lifecycle yourself — drive the poller and the session tool runner directly:
+
+```ts
+import {
+ betaAgentToolset20260401,
+ setupSkills,
+ type AgentToolContext,
+} from '@anthropic-ai/sdk/tools/agent-toolset/node';
+
+const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY!;
+// The environment key authenticates the per-session calls too — scope a client to it.
+const sessionClient = client.withOptions({ authToken: environmentKey });
+
+for await (const work of client.beta.environments.work.poller({
+ environmentId: process.env.ANTHROPIC_ENVIRONMENT_ID!,
+ environmentKey,
+})) {
+ if (work.data.type !== 'session') continue;
+ // Setting `client` + `sessionId` makes `setupSkills` fetch the session's
+ // resolved agent and download each of its skills into `{workdir}/skills//`
+ // (via `client.beta.skills.versions.download`). Call it before the tool runner;
+ // it returns a cleanup function to call once the work item is done.
+ const ctx: AgentToolContext = { workdir: '/workspace', client, sessionId: work.data.id };
+ const cleanupSkills = await setupSkills(ctx);
+ try {
+ for await (const call of sessionClient.beta.sessions.events.toolRunner(work.data.id, {
+ tools: betaAgentToolset20260401(ctx),
+ })) {
+ console.log(`${call.name} -> ${call.isError ? 'error' : 'ok'}`);
+ }
+ } finally {
+ await cleanupSkills();
+ }
+}
+```
+
+The toolset executes shell and file operations directly on the host. Run it inside a container or other isolation boundary you control.
+
+See [`examples/managed-agents-private-sandbox-worker.ts`](examples/managed-agents-private-sandbox-worker.ts) for a complete example.
diff --git a/package.json b/package.json
index 9cb4484f..a0a17fe3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@anthropic-ai/sdk",
- "version": "0.96.0",
+ "version": "0.97.0",
"description": "The official TypeScript library for the Anthropic API",
"author": "Anthropic ",
"types": "dist/index.d.ts",
@@ -57,7 +57,7 @@
"publint": "^0.2.12",
"ts-jest": "^29.1.0",
"ts-node": "^10.5.0",
- "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz",
+ "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz",
"tsconfig-paths": "^4.0.0",
"tslib": "^2.8.1",
"typescript": "5.8.3",
diff --git a/packages/bedrock-sdk/CHANGELOG.md b/packages/bedrock-sdk/CHANGELOG.md
index 093b32f1..bb70e40d 100644
--- a/packages/bedrock-sdk/CHANGELOG.md
+++ b/packages/bedrock-sdk/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 0.29.2 (2026-05-19)
+
+Full Changelog: [bedrock-sdk-v0.29.1...bedrock-sdk-v0.29.2](https://github.com/anthropics/anthropic-sdk-typescript/compare/bedrock-sdk-v0.29.1...bedrock-sdk-v0.29.2)
+
+### Bug Fixes
+
+* align @types/node in sub-packages to fix CI build ([#1017](https://github.com/anthropics/anthropic-sdk-typescript/issues/1017)) ([9888c76](https://github.com/anthropics/anthropic-sdk-typescript/commit/9888c7691913de036fa85d25cd6707036c167a99))
+
## 0.29.1 (2026-04-30)
Full Changelog: [bedrock-sdk-v0.29.0...bedrock-sdk-v0.29.1](https://github.com/anthropics/anthropic-sdk-typescript/compare/bedrock-sdk-v0.29.0...bedrock-sdk-v0.29.1)
diff --git a/packages/bedrock-sdk/package.json b/packages/bedrock-sdk/package.json
index 0050c3ac..cfc4c513 100644
--- a/packages/bedrock-sdk/package.json
+++ b/packages/bedrock-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@anthropic-ai/bedrock-sdk",
- "version": "0.29.1",
+ "version": "0.29.2",
"description": "The official TypeScript library for the Anthropic Bedrock API",
"author": "Anthropic ",
"types": "dist/index.d.ts",
diff --git a/packages/bedrock-sdk/yarn.lock b/packages/bedrock-sdk/yarn.lock
index c1a1aa6c..0be36acb 100644
--- a/packages/bedrock-sdk/yarn.lock
+++ b/packages/bedrock-sdk/yarn.lock
@@ -16,9 +16,10 @@
"@jridgewell/trace-mapping" "^0.3.9"
"@anthropic-ai/sdk@file:../../dist":
- version "0.86.0"
+ version "0.96.0"
dependencies:
json-schema-to-ts "^3.1.1"
+ standardwebhooks "^1.0.0"
"@aws-crypto/crc32@3.0.0":
version "3.0.0"
@@ -1990,6 +1991,11 @@
"@smithy/util-buffer-from" "^4.0.0"
tslib "^2.6.2"
+"@stablelib/base64@^1.0.0":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@stablelib/base64/-/base64-1.0.1.tgz#bdfc1c6d3a62d7a3b7bbc65b6cce1bb4561641be"
+ integrity sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==
+
"@swc/core-darwin-arm64@1.11.24":
version "1.11.24"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.24.tgz#c9fcc9c4bad0511fed26210449556d2b33fb2d9a"
@@ -2190,11 +2196,11 @@
undici-types "~5.26.4"
"@types/node@^20.17.6":
- version "20.17.24"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.24.tgz#2325476954e6fc8c2f11b9c61e26ba6eb7d3f5b6"
- integrity sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA==
+ version "20.19.41"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.41.tgz#bb266a1e0aaa2f4537d14ae8ebf238dd9ca73ce6"
+ integrity sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==
dependencies:
- undici-types "~6.19.2"
+ undici-types "~6.21.0"
"@types/semver@^7.5.0":
version "7.5.6"
@@ -2966,6 +2972,11 @@ fast-levenshtein@^2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
+fast-sha256@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6"
+ integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==
+
fast-xml-parser@4.4.1:
version "4.4.1"
resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f"
@@ -4257,6 +4268,14 @@ stack-utils@^2.0.3:
dependencies:
escape-string-regexp "^2.0.0"
+standardwebhooks@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz#5faa23ceacbf9accd344361101d9e3033b64324f"
+ integrity sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==
+ dependencies:
+ "@stablelib/base64" "^1.0.0"
+ fast-sha256 "^1.3.0"
+
string-length@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
@@ -4507,10 +4526,10 @@ undici-types@~5.26.4:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
-undici-types@~6.19.2:
- version "6.19.8"
- resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
- integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
+undici-types@~6.21.0:
+ version "6.21.0"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
+ integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
update-browserslist-db@^1.0.13:
version "1.0.13"
diff --git a/packages/vertex-sdk/CHANGELOG.md b/packages/vertex-sdk/CHANGELOG.md
index e70b6397..9505073f 100644
--- a/packages/vertex-sdk/CHANGELOG.md
+++ b/packages/vertex-sdk/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 0.16.1 (2026-05-19)
+
+Full Changelog: [vertex-sdk-v0.16.0...vertex-sdk-v0.16.1](https://github.com/anthropics/anthropic-sdk-typescript/compare/vertex-sdk-v0.16.0...vertex-sdk-v0.16.1)
+
+### Bug Fixes
+
+* align @types/node in sub-packages to fix CI build ([#1017](https://github.com/anthropics/anthropic-sdk-typescript/issues/1017)) ([9888c76](https://github.com/anthropics/anthropic-sdk-typescript/commit/9888c7691913de036fa85d25cd6707036c167a99))
+
## 0.16.0 (2026-04-10)
Full Changelog: [vertex-sdk-v0.15.0...vertex-sdk-v0.16.0](https://github.com/anthropics/anthropic-sdk-typescript/compare/vertex-sdk-v0.15.0...vertex-sdk-v0.16.0)
diff --git a/packages/vertex-sdk/package.json b/packages/vertex-sdk/package.json
index d7209c35..c72bfc22 100644
--- a/packages/vertex-sdk/package.json
+++ b/packages/vertex-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@anthropic-ai/vertex-sdk",
- "version": "0.16.0",
+ "version": "0.16.1",
"description": "The official TypeScript library for the Anthropic Vertex API",
"author": "Anthropic ",
"types": "dist/index.d.ts",
diff --git a/packages/vertex-sdk/yarn.lock b/packages/vertex-sdk/yarn.lock
index a4ed3490..df624c40 100644
--- a/packages/vertex-sdk/yarn.lock
+++ b/packages/vertex-sdk/yarn.lock
@@ -16,11 +16,10 @@
"@jridgewell/trace-mapping" "^0.3.9"
"@anthropic-ai/sdk@file:../../dist":
- # x-release-please-start-version
version "0.96.0"
- # x-release-please-end-version
dependencies:
json-schema-to-ts "^3.1.1"
+ standardwebhooks "^1.0.0"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.23.5":
version "7.23.5"
@@ -665,6 +664,11 @@
dependencies:
"@sinonjs/commons" "^3.0.0"
+"@stablelib/base64@^1.0.0":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@stablelib/base64/-/base64-1.0.1.tgz#bdfc1c6d3a62d7a3b7bbc65b6cce1bb4561641be"
+ integrity sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==
+
"@ts-morph/common@~0.20.0":
version "0.20.0"
resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.20.0.tgz#3f161996b085ba4519731e4d24c35f6cba5b80af"
@@ -775,11 +779,11 @@
undici-types "~5.26.4"
"@types/node@^20.17.6":
- version "20.17.24"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.24.tgz#2325476954e6fc8c2f11b9c61e26ba6eb7d3f5b6"
- integrity sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA==
+ version "20.19.41"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.41.tgz#bb266a1e0aaa2f4537d14ae8ebf238dd9ca73ce6"
+ integrity sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==
dependencies:
- undici-types "~6.19.2"
+ undici-types "~6.21.0"
"@types/semver@^7.5.0":
version "7.5.6"
@@ -1575,6 +1579,11 @@ fast-levenshtein@^2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
+fast-sha256@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6"
+ integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==
+
fastq@^1.6.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320"
@@ -2931,6 +2940,14 @@ stack-utils@^2.0.3:
dependencies:
escape-string-regexp "^2.0.0"
+standardwebhooks@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz#5faa23ceacbf9accd344361101d9e3033b64324f"
+ integrity sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==
+ dependencies:
+ "@stablelib/base64" "^1.0.0"
+ fast-sha256 "^1.3.0"
+
string-length@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
@@ -3181,10 +3198,10 @@ undici-types@~5.26.4:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
-undici-types@~6.19.2:
- version "6.19.8"
- resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
- integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
+undici-types@~6.21.0:
+ version "6.21.0"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
+ integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
update-browserslist-db@^1.0.13:
version "1.0.13"
diff --git a/scripts/detect-breaking-changes b/scripts/detect-breaking-changes
index c13daedd..5a97d3b3 100755
--- a/scripts/detect-breaking-changes
+++ b/scripts/detect-breaking-changes
@@ -17,7 +17,8 @@ TEST_PATHS=(
tests/api-resources/beta/messages/batches.test.ts
tests/api-resources/beta/agents/agents.test.ts
tests/api-resources/beta/agents/versions.test.ts
- tests/api-resources/beta/environments.test.ts
+ tests/api-resources/beta/environments/environments.test.ts
+ tests/api-resources/beta/environments/work.test.ts
tests/api-resources/beta/sessions/sessions.test.ts
tests/api-resources/beta/sessions/events.test.ts
tests/api-resources/beta/sessions/resources.test.ts
diff --git a/src/core/streaming.ts b/src/core/streaming.ts
index e8821db3..febbcb3d 100644
--- a/src/core/streaming.ts
+++ b/src/core/streaming.ts
@@ -71,6 +71,7 @@ export class Stream- implements AsyncIterable
- {
sse.event === 'user.interrupt' ||
sse.event === 'user.tool_confirmation' ||
sse.event === 'user.custom_tool_result' ||
+ sse.event === 'user.tool_result' ||
sse.event === 'agent.message' ||
sse.event === 'agent.thinking' ||
sse.event === 'agent.tool_use' ||
@@ -85,6 +86,7 @@ export class Stream
- implements AsyncIterable
- {
sse.event === 'session.status_terminated' ||
sse.event === 'session.error' ||
sse.event === 'session.deleted' ||
+ sse.event === 'session.updated' ||
sse.event === 'span.model_request_start' ||
sse.event === 'span.model_request_end' ||
sse.event === 'span.outcome_evaluation_start' ||
diff --git a/src/helpers/beta/environments.ts b/src/helpers/beta/environments.ts
new file mode 100644
index 00000000..c9d7bbfd
--- /dev/null
+++ b/src/helpers/beta/environments.ts
@@ -0,0 +1,33 @@
+/**
+ * Self-hosted environment runner helpers.
+ *
+ * - {@link WorkPoller} (`client.beta.environments.work.poller`) — control-plane
+ * only: claims work items and yields each one.
+ * - {@link SessionToolRunner} (`client.beta.sessions.events.toolRunner`) — the
+ * sessions-side counterpart to `client.beta.messages.toolRunner`: dispatches
+ * local tools against a session's `agent.tool_use` events.
+ * - {@link EnvironmentWorker} (`client.beta.environments.work.worker`) — the full
+ * composition: poll → set up the workdir + skills → run a
+ * {@link SessionToolRunner} while heartbeating the work-item lease →
+ * force-stop on exit → loop. Use `handleItem()` for the per-item flow when you
+ * already hold a claimed work item — with no arguments it reads the
+ * `ANTHROPIC_*` env vars that `ant worker poll --on-work` sets. The class is
+ * also exported here if you prefer `new EnvironmentWorker({ client, ... })`.
+ *
+ * The tool implementations themselves (`betaAgentToolset20260401` and the
+ * per-tool factories) live in their own Node-only module — import them directly
+ * from `@anthropic-ai/sdk/tools/agent-toolset/node`.
+ */
+export {
+ WorkPoller,
+ EnvironmentWorker,
+ SessionToolRunner,
+ MANAGED_AGENTS_BETA,
+ DEFAULT_MAX_IDLE_MS,
+ type DispatchedToolCall,
+ type WorkPollerOptions,
+ type EnvironmentWorkerOptions,
+ type EnvironmentWorkerTools,
+ type HandleItemOptions,
+ type SessionToolRunnerOptions,
+} from '../../lib/environments/index';
diff --git a/src/helpers/beta/json-schema.ts b/src/helpers/beta/json-schema.ts
index edcc7027..ca475082 100644
--- a/src/helpers/beta/json-schema.ts
+++ b/src/helpers/beta/json-schema.ts
@@ -20,6 +20,12 @@ export function betaTool & { t
args: NoInfer>,
context?: BetaToolRunContext,
) => Promisable>;
+ /**
+ * Optional cleanup hook for tools that hold process-level resources (e.g. a
+ * persistent shell). `client.beta.sessions.events.toolRunner` calls it once
+ * when iteration ends.
+ */
+ close?: () => void | Promise;
}): BetaRunnableTool>> {
if (options.inputSchema.type !== 'object') {
throw new Error(
@@ -34,6 +40,7 @@ export function betaTool & { t
description: options.description,
run: options.run,
parse: (content: unknown) => content as FromSchema,
+ ...(options.close ? { close: options.close } : {}),
} as any;
}
diff --git a/src/helpers/beta/zod.ts b/src/helpers/beta/zod.ts
index c1acf524..1872445a 100644
--- a/src/helpers/beta/zod.ts
+++ b/src/helpers/beta/zod.ts
@@ -53,6 +53,12 @@ export function betaZodTool(options: {
args: z.infer,
context?: BetaToolRunContext,
) => Promisable>;
+ /**
+ * Optional cleanup hook for tools that hold process-level resources (e.g. a
+ * persistent shell). `client.beta.sessions.events.toolRunner` calls it once
+ * when iteration ends.
+ */
+ close?: () => void | Promise;
}): BetaRunnableTool> {
const jsonSchema = z.toJSONSchema(options.inputSchema, { reused: 'ref' });
@@ -70,5 +76,6 @@ export function betaZodTool(options: {
description: options.description,
run: options.run,
parse: (args: unknown) => options.inputSchema.parse(args) as z.infer,
+ ...(options.close ? { close: options.close } : {}),
};
}
diff --git a/src/internal/utils/abort.ts b/src/internal/utils/abort.ts
new file mode 100644
index 00000000..851be716
--- /dev/null
+++ b/src/internal/utils/abort.ts
@@ -0,0 +1,21 @@
+/**
+ * Chain an external {@link AbortSignal} into a local {@link AbortController}:
+ * the controller aborts whenever `external` aborts (synchronously if it is
+ * already aborted).
+ *
+ * Returns a cleanup function that detaches the listener. Callers MUST invoke it
+ * on their normal teardown path — `{ once: true }` only removes the listener if
+ * abort actually fires, so a long-lived `external` signal (e.g. a daemon-wide
+ * signal reused across many short-lived controllers) would otherwise leak one
+ * listener per controller.
+ */
+export function linkAbort(external: AbortSignal | null | undefined, controller: AbortController): () => void {
+ if (!external) return () => {};
+ if (external.aborted) {
+ controller.abort();
+ return () => {};
+ }
+ const onAbort = () => controller.abort();
+ external.addEventListener('abort', onAbort);
+ return () => external.removeEventListener('abort', onAbort);
+}
diff --git a/src/internal/utils/async-queue.ts b/src/internal/utils/async-queue.ts
new file mode 100644
index 00000000..f84536b4
--- /dev/null
+++ b/src/internal/utils/async-queue.ts
@@ -0,0 +1,66 @@
+export type AsyncQueueResult = { done: false; value: T } | { done: true; value: undefined };
+
+/**
+ * Single-consumer async queue that bridges background producers to an
+ * `AsyncIterator`-style reader. Producers `push()` items; the consumer awaits
+ * `next()`. `close()` is idempotent and wakes any pending `next()` with
+ * `done: true`. `tryShift()` synchronously drains remaining items after
+ * iteration has been signalled to stop.
+ */
+export class AsyncQueue {
+ #items: T[] = [];
+ #waiters: Array<(r: AsyncQueueResult) => void> = [];
+ #closed = false;
+
+ /** Enqueue an item, or hand it directly to a waiting reader. Returns `false` once closed. */
+ push(item: T): boolean {
+ if (this.#closed) return false;
+ const w = this.#waiters.shift();
+ if (w) w({ done: false, value: item });
+ else this.#items.push(item);
+ return true;
+ }
+
+ /** Mark the queue done. Idempotent; wakes every pending reader with `done: true`. */
+ close(): void {
+ if (this.#closed) return;
+ this.#closed = true;
+ while (this.#waiters.length > 0) {
+ const w = this.#waiters.shift()!;
+ w({ done: true, value: undefined });
+ }
+ }
+
+ /**
+ * Resolve with the next item, or `done: true` once the queue is closed and
+ * drained. When `signal` is supplied, aborting it resolves a pending read
+ * with `done: true` (cancellation is pushed down here rather than handled by
+ * an outer `Promise.race`).
+ */
+ next(signal?: AbortSignal): Promise> {
+ if (this.#items.length > 0) {
+ return Promise.resolve({ done: false, value: this.#items.shift()! });
+ }
+ if (this.#closed || signal?.aborted) {
+ return Promise.resolve({ done: true, value: undefined });
+ }
+ return new Promise>((resolve) => {
+ const waiter = (r: AsyncQueueResult) => {
+ signal?.removeEventListener('abort', onAbort);
+ resolve(r);
+ };
+ const onAbort = () => {
+ const idx = this.#waiters.indexOf(waiter);
+ if (idx >= 0) this.#waiters.splice(idx, 1);
+ resolve({ done: true, value: undefined });
+ };
+ this.#waiters.push(waiter);
+ signal?.addEventListener('abort', onAbort, { once: true });
+ });
+ }
+
+ /** Synchronously remove and return the next buffered item, or `undefined` if empty. */
+ tryShift(): T | undefined {
+ return this.#items.shift();
+ }
+}
diff --git a/src/internal/utils/backoff.ts b/src/internal/utils/backoff.ts
new file mode 100644
index 00000000..9bd50e43
--- /dev/null
+++ b/src/internal/utils/backoff.ts
@@ -0,0 +1,41 @@
+import { APIError } from '../../core/error';
+
+/** True when `e` is an {@link APIError} whose HTTP status equals `code`. */
+export function isStatus(e: unknown, code: number): boolean {
+ return e instanceof APIError && e.status === code;
+}
+
+/** True when `e` is an {@link APIError} with a 4xx status. */
+export function is4xx(e: unknown): boolean {
+ return e instanceof APIError && typeof e.status === 'number' && e.status >= 400 && e.status < 500;
+}
+
+/**
+ * True for a 4xx that the core client's retry policy would *not* retry, i.e. a
+ * permanent client error. 408 (request timeout), 409 (lock timeout) and 429
+ * (rate limit) are retryable for the base client (`Anthropic.shouldRetry`), so
+ * they are not treated as fatal here — keeping helper retry behaviour aligned
+ * with the rest of the SDK.
+ */
+export function isFatal4xx(e: unknown): boolean {
+ return is4xx(e) && !isStatus(e, 408) && !isStatus(e, 409) && !isStatus(e, 429);
+}
+
+/** Exponential backoff: `baseMs * 2 ** attempt`, clamped to `capMs`. */
+export function backoff(attempt: number, baseMs: number, capMs: number): number {
+ return Math.min(baseMs * 2 ** attempt, capMs);
+}
+
+/** Uniform random delay in the half-open interval `[lowMs, highMs)`. */
+export function jitter(lowMs: number, highMs: number): number {
+ return lowMs + Math.random() * (highMs - lowMs);
+}
+
+/**
+ * Trim up to 25% off `ms` at random so a fleet of clients backing off after a
+ * shared outage does not retry in lockstep — mirrors the jitter the core client
+ * applies to its own retry timeout.
+ */
+export function applyJitter(ms: number): number {
+ return ms * (1 - Math.random() * 0.25);
+}
diff --git a/src/internal/utils/promise.ts b/src/internal/utils/promise.ts
new file mode 100644
index 00000000..e5b79f6c
--- /dev/null
+++ b/src/internal/utils/promise.ts
@@ -0,0 +1,18 @@
+/**
+ * A deferred: a `Promise` together with its `resolve` / `reject` functions.
+ * This is `Promise.withResolvers()`, which is not available in all supported
+ * runtimes.
+ */
+export function promiseWithResolvers(): {
+ promise: Promise;
+ resolve: (value: T | PromiseLike) => void;
+ reject: (reason?: unknown) => void;
+} {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
diff --git a/src/internal/utils/sleep.ts b/src/internal/utils/sleep.ts
index 65e52962..01e9bf99 100644
--- a/src/internal/utils/sleep.ts
+++ b/src/internal/utils/sleep.ts
@@ -1,3 +1,26 @@
-// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+/**
+ * Resolve after `ms`, or immediately when `signal` aborts.
+ *
+ * When a `signal` is passed the abort listener is always removed so repeated
+ * calls do not accumulate listeners on a long-lived signal. Resolves (rather
+ * than rejects) on abort — callers treat abort as "wake up early," not as a
+ * failure; callers that want to unwind should check the signal themselves.
+ */
+export const sleep = (ms: number, signal?: AbortSignal): Promise =>
+ new Promise((resolve) => {
+ if (signal?.aborted) return resolve();
-export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+ const onAbort = () => {
+ clearTimeout(timer);
+ resolve();
+ };
+
+ const timer = setTimeout(() => {
+ signal?.removeEventListener('abort', onAbort);
+ resolve();
+ }, ms);
+
+ // `{ once: true }` auto-removes the listener if abort fires first,
+ // so we only need an explicit remove on the timer-wins path above.
+ signal?.addEventListener('abort', onAbort, { once: true });
+ });
diff --git a/src/lib/environments/index.ts b/src/lib/environments/index.ts
new file mode 100644
index 00000000..277215a5
--- /dev/null
+++ b/src/lib/environments/index.ts
@@ -0,0 +1,23 @@
+export {
+ WorkPoller,
+ type WorkPollerOptions,
+ POLL_BLOCK_MS,
+ backoff,
+ jitter,
+ isStatus,
+ is4xx,
+ isFatal4xx,
+} from './poller';
+export {
+ EnvironmentWorker,
+ type EnvironmentWorkerOptions,
+ type EnvironmentWorkerTools,
+ type HandleItemOptions,
+} from './worker';
+export {
+ SessionToolRunner,
+ type SessionToolRunnerOptions,
+ type DispatchedToolCall,
+ MANAGED_AGENTS_BETA,
+ DEFAULT_MAX_IDLE_MS,
+} from '../tools/SessionToolRunner';
diff --git a/src/lib/environments/poller.ts b/src/lib/environments/poller.ts
new file mode 100644
index 00000000..2ee166e7
--- /dev/null
+++ b/src/lib/environments/poller.ts
@@ -0,0 +1,253 @@
+import { AnthropicError } from '../../core/error';
+import type { Anthropic } from '../../client';
+import type { BetaSelfHostedWork } from '../../resources/beta/environments/work';
+import { loggerFor } from '../../internal/utils/log';
+import { sleep } from '../../internal/utils/sleep';
+import { uuid4 } from '../../internal/utils/uuid';
+import { linkAbort } from '../../internal/utils/abort';
+import { buildHeaders } from '../../internal/headers';
+import type { BetaToolRunnerRequestOptions } from '../tools/BetaToolRunner';
+import {
+ applyJitter,
+ backoff as expBackoff,
+ isFatal4xx,
+ isStatus,
+ jitter,
+} from '../../internal/utils/backoff';
+import { copyClientForHelper } from '../helper-client';
+
+export { is4xx, isFatal4xx, isStatus, jitter } from '../../internal/utils/backoff';
+
+// API caps block_ms at 999; rely on client-side jitter between empty polls.
+export const POLL_BLOCK_MS = 999;
+const POLL_BACKOFF_BASE_MS = 1000;
+const POLL_BACKOFF_CAP_MS = 60_000;
+
+export interface WorkPollerOptions {
+ client: Anthropic;
+ environmentId: string;
+ /**
+ * The environment key — the single credential for the self-hosted runner. It
+ * authenticates the work-poll calls here and every per-session call the
+ * consumer makes afterwards.
+ */
+ environmentKey: string;
+ workerId?: string;
+ /** External abort signal. Aborting it ends the iteration. */
+ signal?: AbortSignal;
+ /**
+ * Whether the poller posts `work.stop` itself after the consumer's loop body
+ * returns. Defaults to `true`. Set `false` when the consumer already owns the
+ * stop (e.g. {@link EnvironmentWorker} force-stops every item) so the work
+ * item is not stopped twice.
+ *
+ * Orthogonal to {@link WorkPollerOptions.drain}: `autoStop` is a per-item
+ * lifecycle flag (does the poller `work.stop` each item), `drain` controls
+ * loop termination (does the poller return when the queue is empty). They are
+ * not two names for the same thing — `EnvironmentWorker.run` uses
+ * `autoStop: false` with `drain` defaulting `false`.
+ */
+ autoStop?: boolean;
+ /**
+ * When `true`, the poller returns (ends iteration) as soon as the work queue
+ * is empty instead of long-polling forever. Defaults to `false` (long-poll
+ * until aborted). Pair with `blockMs: null` for a single non-blocking pass
+ * over whatever is already queued.
+ */
+ drain?: boolean;
+ /**
+ * Block timeout in milliseconds passed through to `work.poll` — the server
+ * long-polls up to this long for an item before returning empty. Defaults to
+ * {@link POLL_BLOCK_MS} (the API cap, 999). Pass `null` to omit it entirely
+ * for a non-blocking single poll (useful with {@link WorkPollerOptions.drain}).
+ */
+ blockMs?: number | null;
+ /**
+ * Reclaim unacknowledged work items older than this many milliseconds, passed
+ * through to `work.poll`'s `reclaim_older_than_ms`. Defaults to `undefined`
+ * (omitted — the server applies its own default).
+ */
+ reclaimOlderThanMs?: number | null;
+ /**
+ * Extra per-request options merged into the poll/ack/stop calls. Custom
+ * `headers` (e.g. a proxy's auth/routing headers) are layered on top of the
+ * environment-key auth + helper telemetry headers; the poller owns the abort
+ * signal, so a `signal` here is ignored.
+ */
+ requestOptions?: BetaToolRunnerRequestOptions;
+}
+
+/**
+ * Async-iterable that long-polls a self-hosted environment for work, ack's
+ * each item, yields the {@link BetaSelfHostedWork} item, and posts `stop` after
+ * the consumer's loop body returns (or when the consumer `break`s).
+ *
+ * @example
+ * ```ts
+ * for await (const work of client.beta.environments.work.poller({
+ * environmentId,
+ * environmentKey,
+ * })) {
+ * // ...service the work...
+ * }
+ * ```
+ */
+export class WorkPoller implements AsyncIterable {
+ readonly client: Anthropic;
+ readonly environmentId: string;
+ readonly environmentKey: string;
+ readonly workerId: string;
+
+ // Sub-client scoped to the environment key. Every poll / ack / stop call
+ // is routed through this so the parent's `X-Api-Key` never lands on the
+ // wire alongside the bearer credential. The helper-telemetry header is
+ // attached as a default on this client; per-call plumbing is unnecessary.
+ readonly #runnerClient: Anthropic;
+ #consumed = false;
+ readonly #controller: AbortController;
+ readonly #detachExternal: () => void;
+ readonly #autoStop: boolean;
+ readonly #drain: boolean;
+ readonly #blockMs: number | null;
+ readonly #reclaimOlderThanMs: number | null;
+ readonly #requestOpts: BetaToolRunnerRequestOptions | undefined;
+
+ constructor(opts: WorkPollerOptions) {
+ this.client = opts.client;
+ this.environmentId = opts.environmentId;
+ this.environmentKey = opts.environmentKey;
+ this.workerId = opts.workerId ?? defaultWorkerId();
+ this.#runnerClient = copyClientForHelper(opts.client, {
+ authToken: opts.environmentKey,
+ helper: 'environments-work-poller',
+ });
+ this.#autoStop = opts.autoStop ?? true;
+ this.#drain = opts.drain ?? false;
+ // `undefined` => default to the API cap; an explicit `null` => omit
+ // `block_ms` for a non-blocking poll.
+ this.#blockMs = opts.blockMs === undefined ? POLL_BLOCK_MS : opts.blockMs;
+ this.#reclaimOlderThanMs = opts.reclaimOlderThanMs ?? null;
+ this.#requestOpts = opts.requestOptions;
+ this.#controller = new AbortController();
+ this.#detachExternal = linkAbort(opts.signal, this.#controller);
+ }
+
+ /** Read-only view of this iterator's abort signal. */
+ get signal(): AbortSignal {
+ return this.#controller.signal;
+ }
+
+ /** Abort the iterator. The current `for await` will exit cleanly. */
+ abort(): void {
+ this.#controller.abort();
+ }
+
+ async *[Symbol.asyncIterator](): AsyncIterator {
+ if (this.#consumed) {
+ throw new AnthropicError('Cannot iterate over a consumed WorkPoller');
+ }
+ this.#consumed = true;
+ const log = loggerFor(this.client);
+ log.info('poller starting', {
+ component: 'work-poller',
+ environment_id: this.environmentId,
+ });
+
+ try {
+ let attempt = 0;
+ while (!this.#controller.signal.aborted) {
+ let work: BetaSelfHostedWork | null;
+ try {
+ work = await this.#runnerClient.beta.environments.work.poll(
+ this.environmentId,
+ {
+ 'Anthropic-Worker-ID': this.workerId,
+ ...(this.#blockMs !== null ? { block_ms: this.#blockMs } : {}),
+ ...(this.#reclaimOlderThanMs !== null ?
+ { reclaim_older_than_ms: this.#reclaimOlderThanMs }
+ : {}),
+ },
+ { headers: buildHeaders([this.#requestOpts?.headers]), signal: this.#controller.signal },
+ );
+ } catch (e) {
+ if (this.#controller.signal.aborted) return;
+ // A bad environment key / missing environment never recovers — surface
+ // it instead of spinning forever at the backoff cap.
+ if (isFatal4xx(e)) {
+ log.error('poll failed permanently, stopping poller', { error: String(e) });
+ throw e;
+ }
+ // Jittered exponential backoff so a fleet of pollers doesn't retry in
+ // lockstep after a shared outage.
+ const wait = applyJitter(backoff(attempt));
+ log.warn('poll failed, backing off', { error: String(e), backoff_ms: wait });
+ attempt++;
+ await sleep(wait, this.#controller.signal);
+ continue;
+ }
+ attempt = 0;
+ if (work == null) {
+ // Queue empty: either return now (drain) or wait and poll again.
+ if (this.#drain) return;
+ await sleep(jitter(1000, 3000), this.#controller.signal);
+ continue;
+ }
+ log.info('claimed work', {
+ component: 'work-poller',
+ environment_id: this.environmentId,
+ work_id: work.id,
+ work_type: work.data.type,
+ });
+
+ try {
+ await this.#runnerClient.beta.environments.work.ack(
+ work.id,
+ { environment_id: work.environment_id },
+ { headers: buildHeaders([this.#requestOpts?.headers]), signal: this.#controller.signal },
+ );
+ } catch (e) {
+ log.error('ack failed', { work_id: work.id, error: String(e) });
+ continue;
+ }
+
+ try {
+ yield work;
+ } finally {
+ // Post-handler stop. Runs whether the consumer body returned
+ // normally, threw, or `break`d out of the loop — unless the consumer
+ // owns the stop itself (`autoStop: false`).
+ if (this.#autoStop) {
+ try {
+ await this.#runnerClient.beta.environments.work.stop(
+ work.id,
+ { environment_id: work.environment_id },
+ { headers: buildHeaders([this.#requestOpts?.headers]) },
+ );
+ } catch (e) {
+ if (!isStatus(e, 409)) log.warn('stop failed', { work_id: work.id, error: String(e) });
+ }
+ }
+ }
+ }
+ } finally {
+ // Detach from the external signal so the consumer can drop their
+ // signal reference without leaking this iterator instance.
+ this.#detachExternal();
+ }
+ }
+}
+
+/** Exponential poll backoff: 1s, 2s, 4s … clamped to a 60s cap. */
+export function backoff(attempt: number): number {
+ return expBackoff(attempt, POLL_BACKOFF_BASE_MS, POLL_BACKOFF_CAP_MS);
+}
+
+function defaultWorkerId(): string {
+ // The API documents the worker id as a *unique* identifier for Redis consumer
+ // groups, so the fallback must be unique even when several pollers share a
+ // host. Prefix with the hostname when one is exposed for readability, but rely
+ // on the uuid for uniqueness.
+ const env = (globalThis as { process?: { env?: Record } }).process?.env;
+ const host = env?.['HOSTNAME'];
+ return host ? `${host}-${uuid4()}` : uuid4();
+}
diff --git a/src/lib/environments/worker.ts b/src/lib/environments/worker.ts
new file mode 100644
index 00000000..7ec7c2d7
--- /dev/null
+++ b/src/lib/environments/worker.ts
@@ -0,0 +1,395 @@
+import { AnthropicError } from '../../core/error';
+import type { Anthropic } from '../../client';
+import type { BetaSelfHostedWork } from '../../resources/beta/environments/work';
+import { loggerFor, type Logger } from '../../internal/utils/log';
+import { readEnv } from '../../internal/utils/env';
+import { sleep } from '../../internal/utils/sleep';
+import { isFatal4xx, isStatus } from '../../internal/utils/backoff';
+import { linkAbort } from '../../internal/utils/abort';
+import { buildHeaders } from '../../internal/headers';
+import type { BetaRunnableTool } from '../tools/BetaRunnableTool';
+import type { BetaToolRunnerRequestOptions } from '../tools/BetaToolRunner';
+import { SessionToolRunner } from '../tools/SessionToolRunner';
+import { WorkPoller } from './poller';
+import { copyClientForHelper } from '../helper-client';
+// `tools/agent-toolset/node` is Node-only (node:child_process, node:fs, …).
+// Only the type is imported statically (erased at build); the module's values
+// (`setupSkills`, `betaAgentToolset20260401`) are loaded lazily inside the
+// per-item handler. That keeps this file free of Node-only deps in the static
+// import graph, which is what lets `client.beta.environments.work.worker()`
+// exist as a resource method without pulling Node built-ins into the SDK core.
+import type { AgentToolContext } from '../../tools/agent-toolset/node';
+
+const HEARTBEAT_DEFAULT_MS = 30_000;
+const NO_HEARTBEAT_SENTINEL = 'NO_HEARTBEAT';
+
+/**
+ * Either a fixed tool array or a factory invoked once per claimed session with
+ * that session's {@link AgentToolContext} — use the factory form to bind
+ * `betaAgentToolset20260401` (or any tool that needs the workdir / session
+ * id) to the right session.
+ */
+export type EnvironmentWorkerTools =
+ | Array
+ | ((ctx: AgentToolContext) => Array);
+
+export interface EnvironmentWorkerOptions {
+ client: Anthropic;
+ /**
+ * The self-hosted environment to poll for work. Required by
+ * {@link EnvironmentWorker.run}; not used by {@link EnvironmentWorker.handleItem}.
+ */
+ environmentId?: string;
+ /**
+ * The environment key — the single credential for the runner. It authenticates
+ * the work-poll calls and every per-session call (event stream, lease
+ * heartbeat, force-stop). Required by {@link EnvironmentWorker.run}; falls back
+ * to `ANTHROPIC_ENVIRONMENT_KEY` in {@link EnvironmentWorker.handleItem}.
+ */
+ environmentKey?: string;
+ /**
+ * Tools to expose to each claimed session. Defaults to
+ * `betaAgentToolset20260401(ctx)` (the standard `agent_toolset_20260401` set
+ * bound to the per-session {@link AgentToolContext}).
+ */
+ tools?: EnvironmentWorkerTools;
+ /** Base directory for the per-session {@link AgentToolContext}. Defaults to `process.cwd()`. */
+ workdir?: string;
+ /** Forwarded to the per-session {@link AgentToolContext}. */
+ unrestrictedPaths?: boolean;
+ /** Forwarded to {@link SessionToolRunner} (`maxIdleMs`). */
+ maxIdleMs?: number;
+ /** Forwarded to the {@link WorkPoller}. */
+ workerId?: string;
+ /** External abort signal; aborting it ends the run. */
+ signal?: AbortSignal;
+ /**
+ * Extra per-request options merged into every call this worker issues — the
+ * work poll/ack/heartbeat/stop control-plane calls and the per-session
+ * SessionToolRunner's stream/list/send. Mirrors what
+ * `client.beta.messages.toolRunner` accepts: custom `headers` (e.g. a proxy's
+ * auth/routing headers) reach all of them. The worker owns the abort signals,
+ * so a `signal` here is ignored — use {@link EnvironmentWorkerOptions.signal}.
+ */
+ requestOptions?: BetaToolRunnerRequestOptions;
+}
+
+/**
+ * Options for {@link EnvironmentWorker.handleItem}. Every field falls back to the
+ * matching `ANTHROPIC_*` environment variable — the ones the
+ * `ant worker poll --on-work` command sets for the process it spawns — when not
+ * passed explicitly.
+ */
+export interface HandleItemOptions {
+ /** Work item id. Falls back to `ANTHROPIC_WORK_ID`. */
+ workId?: string;
+ /** Self-hosted environment id. Falls back to `ANTHROPIC_ENVIRONMENT_ID`. */
+ environmentId?: string;
+ /** Session id. Falls back to `ANTHROPIC_SESSION_ID`. */
+ sessionId?: string;
+ /**
+ * The environment key used to authenticate every per-session call. Resolution
+ * order: this option, then the worker's own `environmentKey`, then
+ * `ANTHROPIC_ENVIRONMENT_KEY`.
+ */
+ environmentKey?: string;
+ /** External abort signal; aborting it ends the run. Defaults to the constructor's signal. */
+ signal?: AbortSignal;
+}
+
+/** The fields of {@link BetaSelfHostedWork} the per-item flow reads. */
+type ClaimedWork = Pick;
+
+/**
+ * The self-hosted environment runner, composed from the control-plane
+ * {@link WorkPoller} and the per-session {@link SessionToolRunner}.
+ *
+ * For each claimed `session` work item it: builds the per-session
+ * {@link AgentToolContext}, downloads the session agent's skills
+ * (`setupSkills`), then runs a {@link SessionToolRunner} for the session
+ * *while* heartbeating the work-item lease in parallel; on exit it force-stops
+ * the work item, cleans up the downloaded skills, and loops to the next one. The
+ * lease heartbeat reports `state === "stopping"` / a lost lease back into the run
+ * by aborting the session runner.
+ *
+ * Use {@link EnvironmentWorker.handleItem} if you already hold a claimed work
+ * item (e.g. a `worker poll --on-work` script handed one to a fresh process) and
+ * just want the per-item flow without the poll loop — with no arguments it reads
+ * the `ANTHROPIC_*` env vars that command sets.
+ *
+ * Construct it via `client.beta.environments.work.worker({ ... })` (or
+ * `new EnvironmentWorker({ client, ... })` directly).
+ *
+ * @example
+ * ```ts
+ * // Long-running daemon: poll for work, serve each session, loop.
+ * await client.beta.environments.work
+ * .worker({ environmentId, environmentKey, workdir: '/workspace' })
+ * .run(AbortSignal.timeout(60 * 60_000));
+ *
+ * // Already-claimed item (e.g. inside `ant worker poll --on-work ...`):
+ * await client.beta.environments.work.worker({ workdir: '/workspace' }).handleItem();
+ * ```
+ */
+export class EnvironmentWorker {
+ readonly client: Anthropic;
+ readonly environmentId: string | undefined;
+ readonly environmentKey: string | undefined;
+ readonly tools: EnvironmentWorkerTools | undefined;
+ readonly workdir: string;
+ readonly unrestrictedPaths: boolean | undefined;
+ readonly maxIdleMs: number | undefined;
+ readonly workerId: string | undefined;
+ readonly requestOptions: BetaToolRunnerRequestOptions | undefined;
+ readonly #signal: AbortSignal | undefined;
+
+ constructor(opts: EnvironmentWorkerOptions) {
+ this.client = opts.client;
+ this.environmentId = opts.environmentId;
+ this.environmentKey = opts.environmentKey;
+ this.tools = opts.tools;
+ this.workdir = opts.workdir ?? process.cwd();
+ this.unrestrictedPaths = opts.unrestrictedPaths;
+ this.maxIdleMs = opts.maxIdleMs;
+ this.workerId = opts.workerId;
+ this.requestOptions = opts.requestOptions;
+ this.#signal = opts.signal;
+ }
+
+ /**
+ * Poll the environment and service each claimed session until the supplied
+ * signal (or the one passed to the constructor) aborts. Throws if
+ * `environmentId` / `environmentKey` were not provided to the constructor.
+ */
+ async run(signal?: AbortSignal): Promise {
+ const { environmentId, environmentKey } = this;
+ if (environmentId === undefined || environmentKey === undefined) {
+ throw new AnthropicError(
+ 'EnvironmentWorker.run: environmentId and environmentKey are required to poll for work',
+ );
+ }
+ const externalSignal = signal ?? this.#signal;
+ const poller = new WorkPoller({
+ client: this.client,
+ environmentId,
+ environmentKey,
+ ...(this.workerId !== undefined ? { workerId: this.workerId } : {}),
+ ...(externalSignal ? { signal: externalSignal } : {}),
+ ...(this.requestOptions !== undefined ? { requestOptions: this.requestOptions } : {}),
+ // The per-item handler force-stops every work item on exit; let it be the
+ // single owner of `work.stop` rather than double-posting from the poller.
+ autoStop: false,
+ });
+
+ for await (const work of poller) {
+ await this.#handleItem(work, environmentKey, poller.signal);
+ }
+ }
+
+ /**
+ * Service a single, already-claimed work item without the poll loop: build the
+ * per-session {@link AgentToolContext} (workdir from this worker's options),
+ * download the session agent's skills (`setupSkills`), run a
+ * {@link SessionToolRunner} for the session while heartbeating the work-item
+ * lease in parallel, and force-stop the work item on exit (whether the runner
+ * finishes normally, throws, or the heartbeat loop signals shutdown).
+ *
+ * Use this when something else does the claiming — e.g. a `worker poll
+ * --on-work` script that hands an already-claimed item to a fresh process. The
+ * work id / environment id / session id each fall back to `ANTHROPIC_WORK_ID` /
+ * `ANTHROPIC_ENVIRONMENT_ID` / `ANTHROPIC_SESSION_ID` (the env vars that
+ * command sets) when not passed; the environment key resolves from this
+ * option, then the worker's own `environmentKey`, then
+ * `ANTHROPIC_ENVIRONMENT_KEY`. With no arguments inside that command it just
+ * works. Throws a clear error naming the first of the four required values
+ * still missing after resolution.
+ */
+ async handleItem(opts?: HandleItemOptions): Promise {
+ const workId = opts?.workId ?? readEnv('ANTHROPIC_WORK_ID');
+ const environmentId = opts?.environmentId ?? readEnv('ANTHROPIC_ENVIRONMENT_ID');
+ const sessionId = opts?.sessionId ?? readEnv('ANTHROPIC_SESSION_ID');
+ const environmentKey =
+ opts?.environmentKey ?? this.environmentKey ?? readEnv('ANTHROPIC_ENVIRONMENT_KEY');
+
+ if (!workId) {
+ throw new AnthropicError('handleItem: workId is required — pass it or set ANTHROPIC_WORK_ID');
+ }
+ if (!environmentId) {
+ throw new AnthropicError(
+ 'handleItem: environmentId is required — pass it or set ANTHROPIC_ENVIRONMENT_ID',
+ );
+ }
+ if (!sessionId) {
+ throw new AnthropicError('handleItem: sessionId is required — pass it or set ANTHROPIC_SESSION_ID');
+ }
+ if (!environmentKey) {
+ throw new AnthropicError(
+ 'handleItem: environmentKey is required — pass it, construct the worker with it, or set ANTHROPIC_ENVIRONMENT_KEY',
+ );
+ }
+
+ const work: ClaimedWork = {
+ id: workId,
+ environment_id: environmentId,
+ data: { type: 'session', id: sessionId },
+ };
+ await this.#handleItem(work, environmentKey, opts?.signal ?? this.#signal);
+ }
+
+ /**
+ * The per-item body shared by {@link EnvironmentWorker.run}'s poll loop and
+ * {@link EnvironmentWorker.handleItem}: run a {@link SessionToolRunner} for the
+ * work item's session while heartbeating its lease, force-stopping on exit.
+ * Non-session work items are ignored.
+ */
+ async #handleItem(
+ work: ClaimedWork,
+ environmentKey: string,
+ externalSignal: AbortSignal | undefined,
+ ): Promise {
+ const log = loggerFor(this.client);
+ // Every per-session call — the SessionToolRunner event stream/list/send, the
+ // lease heartbeat, and the work force-stop — authenticates with the
+ // environment key. Scope a client to it once and thread that through.
+ // `copyClientForHelper` also clears the parent's `apiKey`, so the sub-client
+ // emits *only* the bearer credential on the wire (a plain
+ // `withOptions({authToken})` would leave `X-Api-Key` set as well).
+ const sessionClient = copyClientForHelper(this.client, {
+ authToken: environmentKey,
+ helper: 'environments-worker',
+ });
+
+ // The poller runs with `autoStop: false`, so the per-item handler is the
+ // single owner of `work.stop` for every claimed item.
+ const sessionId = work.data.id;
+
+ const ctx: AgentToolContext = {
+ workdir: this.workdir,
+ client: this.client,
+ sessionId,
+ ...(this.unrestrictedPaths !== undefined ? { unrestrictedPaths: this.unrestrictedPaths } : {}),
+ };
+ // Lazily load the Node-only toolset module — see the import note at the top.
+ const agentToolset = await import('../../tools/agent-toolset/node');
+ let cleanupSkills: () => Promise = async () => {};
+ try {
+ cleanupSkills = await agentToolset.setupSkills(ctx);
+ } catch (e) {
+ log.warn('skill setup failed', { session_id: sessionId, work_id: work.id, error: String(e) });
+ }
+ const tools =
+ typeof this.tools === 'function' ?
+ this.tools(ctx)
+ : this.tools ?? agentToolset.betaAgentToolset20260401(ctx);
+
+ // A per-session controller: aborts when the supplied signal aborts, when the
+ // session runner finishes, or when the lease heartbeat says to stop.
+ const ctrl = new AbortController();
+ const detachExternal = linkAbort(externalSignal, ctrl);
+
+ const heartbeatPromise = heartbeatLoop(sessionClient, work, ctrl, log, this.requestOptions).catch((e) => {
+ if (!ctrl.signal.aborted) log.error('heartbeat loop failed', { work_id: work.id, error: String(e) });
+ ctrl.abort();
+ });
+
+ try {
+ const runner = new SessionToolRunner(sessionId, {
+ client: sessionClient,
+ tools,
+ ...(this.maxIdleMs !== undefined ? { maxIdleMs: this.maxIdleMs } : {}),
+ ...(this.requestOptions !== undefined ? { requestOptions: this.requestOptions } : {}),
+ signal: ctrl.signal,
+ });
+ for await (const _ of runner) {
+ // Drive the runner to completion; per-call observability is not part
+ // of this composition's surface — use `SessionToolRunner` directly
+ // (via `client.beta.sessions.events.toolRunner`) if you want it.
+ }
+ } finally {
+ ctrl.abort();
+ detachExternal();
+ await heartbeatPromise;
+ await cleanupSkills().catch((e) => {
+ log.warn('skill cleanup failed', { session_id: sessionId, work_id: work.id, error: String(e) });
+ });
+ await forceStop(sessionClient, work, log, this.requestOptions);
+ }
+ }
+}
+
+/** Force-stop a claimed work item, swallowing the 409 that means it's already stopped. */
+async function forceStop(
+ client: Anthropic,
+ work: Pick,
+ log: Logger,
+ requestOptions?: BetaToolRunnerRequestOptions,
+): Promise {
+ try {
+ await client.beta.environments.work.stop(
+ work.id,
+ { environment_id: work.environment_id, force: true },
+ // Caller's headers pass through; the helper-tag header is on the scoped
+ // sub-client's default_headers via copyClientForHelper, so no per-call
+ // re-stamping needed.
+ { ...requestOptions, headers: buildHeaders([requestOptions?.headers]) },
+ );
+ } catch (e) {
+ if (!isStatus(e, 409)) {
+ log.error('force-stop on exit failed', { work_id: work.id, error: String(e) });
+ }
+ }
+}
+
+/**
+ * Keep the work-item lease alive while a session is being served. Aborts `ctrl`
+ * when the control plane reports the work is `stopping`/`stopped`, when the
+ * lease is no longer extended, or on a permanent heartbeat failure.
+ */
+async function heartbeatLoop(
+ client: Anthropic,
+ work: Pick,
+ ctrl: AbortController,
+ logger: Logger,
+ requestOptions?: BetaToolRunnerRequestOptions,
+): Promise {
+ let intervalMs = HEARTBEAT_DEFAULT_MS;
+ let last = NO_HEARTBEAT_SENTINEL;
+ const beat = async (): Promise => {
+ try {
+ const resp = await client.beta.environments.work.heartbeat(
+ work.id,
+ { environment_id: work.environment_id, expected_last_heartbeat: last },
+ { ...requestOptions, headers: buildHeaders([requestOptions?.headers]), signal: ctrl.signal },
+ );
+ last = resp.last_heartbeat;
+ if (resp.ttl_seconds > 0) {
+ intervalMs = Math.max(1_000, Math.min((resp.ttl_seconds * 1000) / 2, HEARTBEAT_DEFAULT_MS));
+ }
+ if (resp.state === 'stopping' || resp.state === 'stopped') {
+ logger.info('heartbeat signals shutdown', { work_id: work.id, state: resp.state });
+ ctrl.abort();
+ }
+ if (!resp.lease_extended) {
+ logger.warn('lease not extended, shutting down', { work_id: work.id });
+ ctrl.abort();
+ }
+ } catch (e) {
+ // An abort throws to unwind the caller (the `heartbeatLoop(...).catch`
+ // in `#handleItem`) rather than returning early.
+ ctrl.signal.throwIfAborted();
+ if (isFatal4xx(e)) {
+ logger.error('permanent heartbeat failure', { work_id: work.id, error: String(e) });
+ ctrl.abort();
+ throw e;
+ }
+ logger.warn('transient heartbeat failure', { work_id: work.id, error: String(e) });
+ }
+ };
+
+ await beat();
+ while (!ctrl.signal.aborted) {
+ await sleep(intervalMs, ctrl.signal);
+ ctrl.signal.throwIfAborted();
+ await beat();
+ }
+}
diff --git a/src/lib/helper-client.ts b/src/lib/helper-client.ts
new file mode 100644
index 00000000..4fa69cfc
--- /dev/null
+++ b/src/lib/helper-client.ts
@@ -0,0 +1,89 @@
+import { AnthropicError } from '../core/error';
+import type { Anthropic } from '../client';
+import { buildHeaders, type HeadersLike, type NullableHeaders } from '../internal/headers';
+
+/**
+ * Shared util for building a runner-helper-bound sub-client.
+ *
+ * The work poller, the environment worker, and the session tool runner each
+ * need to issue requests authenticated by a per-helper credential (a
+ * self-hosted environment key, today) rather than the parent client's own
+ * `X-Api-Key`, *and* tagged with their own `x-stainless-helper` telemetry
+ * value. Each wants to inherit the parent's full configuration — `timeout`,
+ * `maxRetries`, `fetch`, `fetchOptions`, custom `defaultHeaders`,
+ * `defaultQuery` — and override only the auth + telemetry bits.
+ *
+ * {@link copyClientForHelper} is the one shared construction.
+ */
+
+/**
+ * The closed set of `x-stainless-helper` telemetry tags the runner helpers
+ * stamp on outgoing requests. Constrained as a string union so a typo at any
+ * call site is a type error rather than silently mistagged telemetry.
+ */
+export type HelperTag = 'environments-work-poller' | 'environments-worker' | 'session-tool-runner';
+
+interface ClientInternalAccess {
+ _options: { defaultHeaders?: HeadersLike };
+ _authState?: { extraHeaders?: Record };
+}
+
+/**
+ * Return a `withOptions()` clone of `client` set up for use *by* one of the
+ * runner helpers: authenticated with `authToken` as Bearer credentials, with
+ * the parent's `X-Api-Key` cleared, and tagged with the helper's
+ * `x-stainless-helper` value on every outgoing request.
+ *
+ * The returned sub-client inherits the parent's full configuration
+ * (`baseURL`, `timeout`, `maxRetries`, `fetch`, `fetchOptions`, custom
+ * `defaultHeaders`, `defaultQuery`). Overrides applied:
+ *
+ * - `authToken: authToken` — the new credential.
+ * - `apiKey: null` — the parent's `X-Api-Key` is cleared. `withOptions`
+ * inherits the parent's `apiKey` by default; without this, both
+ * `X-Api-Key` *and* `Authorization: Bearer …` would land on the wire.
+ * `client.ts` only triggers the env-var fallback when `apiKey === undefined`,
+ * so explicit `null` is honored.
+ * - `credentials: undefined` — opts the clone out of any inherited
+ * credentials/config/profile so the explicit bearer is the unambiguous auth.
+ * - `baseURL: client.baseURL` — pins the parent's resolved host (auth override otherwise resets it).
+ * - `defaultHeaders` is rebuilt as `parent._authState.extraHeaders ⊕ parent.defaultHeaders ⊕
+ * {'x-stainless-helper': helper}`. `withOptions` *replaces* (does not
+ * merge) `defaultHeaders`, so we merge here so any custom headers the
+ * caller set on the parent client survive on the sub-client.
+ */
+export function copyClientForHelper(
+ client: T,
+ { authToken, helper }: { authToken: string; helper: HelperTag },
+): T {
+ if (!authToken) {
+ throw new AnthropicError(
+ `copyClientForHelper: expected a non-empty authToken but received ${JSON.stringify(authToken)}`,
+ );
+ }
+ const internal = client as unknown as ClientInternalAccess;
+ const parentDefaults = internal._options.defaultHeaders;
+ // Carry the parent's credential/profile headers; strip the auth ones (we re-auth below).
+ const parentAuthExtraHeaders = internal._authState?.extraHeaders;
+ const inheritedAuthExtraHeaders: Record | undefined =
+ parentAuthExtraHeaders ?
+ Object.fromEntries(
+ Object.entries(parentAuthExtraHeaders).filter(([name]) => {
+ const lower = name.toLowerCase();
+ return lower !== 'authorization' && lower !== 'x-api-key';
+ }),
+ )
+ : undefined;
+ const defaultHeaders: NullableHeaders = buildHeaders([
+ inheritedAuthExtraHeaders,
+ parentDefaults,
+ { 'x-stainless-helper': helper },
+ ]);
+ return client.withOptions({
+ apiKey: null,
+ authToken,
+ baseURL: client.baseURL,
+ credentials: undefined,
+ defaultHeaders,
+ }) as T;
+}
diff --git a/src/lib/tools/BetaRunnableTool.ts b/src/lib/tools/BetaRunnableTool.ts
index dd8f3e26..54a481d9 100644
--- a/src/lib/tools/BetaRunnableTool.ts
+++ b/src/lib/tools/BetaRunnableTool.ts
@@ -11,8 +11,14 @@ import {
BetaToolTextEditor20250124,
BetaToolTextEditor20250429,
BetaToolTextEditor20250728,
+ BetaToolUnion,
BetaToolUseBlock,
} from '../../resources/beta';
+import type {
+ BetaManagedAgentsAgentCustomToolUseEvent,
+ BetaManagedAgentsAgentToolUseEvent,
+} from '../../resources/beta/sessions/events';
+import { ToolError } from './ToolError';
export type Promisable = T | Promise;
@@ -33,8 +39,32 @@ export type BetaClientRunnableToolType =
| BetaToolTextEditor20250429
| BetaToolTextEditor20250728;
+/**
+ * The tool-use that triggered a {@link BetaRunnableTool.run}:
+ *
+ * - from `client.beta.messages.toolRunner`, a Messages `tool_use` content block;
+ * - from `client.beta.sessions.events.toolRunner`, the `agent.tool_use` /
+ * `agent.custom_tool_use` session event.
+ *
+ * The shapes overlap on the common fields (`id`, `name`, `input`), so code that
+ * only reads those works without narrowing; narrow on the shape (e.g. `'type' in
+ * x`) when you need surface-specific properties.
+ */
+export type BetaToolUse =
+ | BetaToolUseBlock
+ | BetaManagedAgentsAgentToolUseEvent
+ | BetaManagedAgentsAgentCustomToolUseEvent;
+
export type BetaToolRunContext = {
- toolUseBlock: BetaToolUseBlock;
+ /** The tool-use that triggered this run. See {@link BetaToolUse}. */
+ toolUse: BetaToolUse;
+ /**
+ * @deprecated Renamed to `toolUse`. Also note that for
+ * `client.beta.sessions.events.toolRunner` this is the `agent.tool_use` /
+ * `agent.custom_tool_use` *event*, not a Messages content block, despite the
+ * name — which is why it was renamed.
+ */
+ toolUseBlock: BetaToolUse;
signal?: AbortSignal | null | undefined;
};
@@ -46,4 +76,55 @@ export type BetaRunnableTool = BetaClientRunnableToolType & {
context?: BetaToolRunContext,
) => Promisable>;
parse: (content: unknown) => Input;
+ /**
+ * Optional cleanup hook for tools that hold process-level resources (e.g. a
+ * persistent shell). `SessionToolRunner` (`client.beta.sessions.events.toolRunner`)
+ * calls it once when iteration ends.
+ */
+ close?: () => Promisable;
};
+
+/**
+ * Resolve the registry key for a tool — the name the model addresses it by.
+ * MCP toolsets are keyed on `mcp_server_name`; every other tool on `name`.
+ * Shared so the tool-name lookup is identical across `toolRunner()` surfaces.
+ */
+export function toolName(tool: BetaToolUnion | BetaRunnableTool): string {
+ return 'name' in tool ? tool.name : tool.mcp_server_name;
+}
+
+/**
+ * Format a thrown value into tool-result content: a {@link ToolError} carries
+ * its own structured content, anything else becomes an `Error: `
+ * string. Shared so every `toolRunner()` surface reports tool failures the
+ * same way to the model.
+ */
+export function toolErrorContent(e: unknown): string | Array {
+ return e instanceof ToolError ? e.content : `Error: ${e instanceof Error ? e.message : String(e)}`;
+}
+
+/** Outcome of {@link runRunnableTool}: the content to post back and whether it is an error. */
+export interface RunnableToolOutcome {
+ content: string | Array;
+ isError: boolean;
+}
+
+/**
+ * Run a {@link BetaRunnableTool} end-to-end: parse the raw input, invoke `run`,
+ * and format any thrown value via {@link toolErrorContent}. Shared so the
+ * parse → run → catch → format pipeline is identical across `toolRunner()`
+ * surfaces.
+ */
+export async function runRunnableTool(
+ tool: BetaRunnableTool,
+ rawInput: unknown,
+ context: BetaToolRunContext,
+): Promise {
+ try {
+ const input = tool.parse ? tool.parse(rawInput) : rawInput;
+ const content = await tool.run(input, context);
+ return { content, isError: false };
+ } catch (e) {
+ return { content: toolErrorContent(e), isError: true };
+ }
+}
diff --git a/src/lib/tools/BetaToolRunner.ts b/src/lib/tools/BetaToolRunner.ts
index 3ca54ac1..0b45863e 100644
--- a/src/lib/tools/BetaToolRunner.ts
+++ b/src/lib/tools/BetaToolRunner.ts
@@ -6,26 +6,10 @@ import { BetaMessage, BetaMessageParam, BetaToolUnion, MessageCreateParams } fro
import { BetaMessageStream } from '../BetaMessageStream';
import { RequestOptions } from '../../internal/request-options';
import { buildHeaders } from '../../internal/headers';
+import { promiseWithResolvers } from '../../internal/utils/promise';
import { CompactionControl, DEFAULT_SUMMARY_PROMPT, DEFAULT_TOKEN_THRESHOLD } from './CompactionControl';
import { collectStainlessHelpers } from '../stainless-helper-header';
-/**
- * Just Promise.withResolvers(), which is not available in all environments.
- */
-function promiseWithResolvers(): {
- promise: Promise;
- resolve: (value: T) => void;
- reject: (reason?: any) => void;
-} {
- let resolve: (value: T) => void;
- let reject: (reason?: any) => void;
- const promise = new Promise((res, rej) => {
- resolve = res;
- reject = rej;
- });
- return { promise, resolve: resolve!, reject: reject! };
-}
-
/**
* A ToolRunner handles the automatic conversation loop between the assistant and tools.
*
@@ -484,6 +468,7 @@ async function generateToolResponse(
}
const result = await tool.run(input, {
+ toolUse: toolUse,
toolUseBlock: toolUse,
signal: requestOptions?.signal,
});
diff --git a/src/lib/tools/SessionToolRunner.ts b/src/lib/tools/SessionToolRunner.ts
new file mode 100644
index 00000000..b233c748
--- /dev/null
+++ b/src/lib/tools/SessionToolRunner.ts
@@ -0,0 +1,591 @@
+import { AnthropicError } from '../../core/error';
+import type { Anthropic } from '../../client';
+import type {
+ BetaManagedAgentsAgentCustomToolUseEvent,
+ BetaManagedAgentsAgentToolUseEvent,
+ BetaManagedAgentsSessionEvent,
+ BetaManagedAgentsStreamSessionEvents,
+ BetaManagedAgentsUserCustomToolResultEventParams,
+ BetaManagedAgentsUserToolResultEventParams,
+} from '../../resources/beta/sessions/events';
+import type { BetaToolResultContentBlockParam } from '../../resources/beta';
+import { loggerFor, type Logger } from '../../internal/utils/log';
+import { sleep } from '../../internal/utils/sleep';
+import { isFatal4xx } from '../../internal/utils/backoff';
+import { linkAbort } from '../../internal/utils/abort';
+import { AsyncQueue } from '../../internal/utils/async-queue';
+import { buildHeaders } from '../../internal/headers';
+import type { RequestOptions } from '../../internal/request-options';
+import { runRunnableTool, toolName, type BetaRunnableTool } from './BetaRunnableTool';
+import type { BetaToolRunnerRequestOptions } from './BetaToolRunner';
+
+/** Beta header for the managed-agents API. */
+export const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01';
+
+/** `x-stainless-helper` value identifying this helper in SDK telemetry. */
+const HELPER_NAME = 'SessionToolRunner';
+
+const STREAM_BACKOFF_START_MS = 500;
+const STREAM_BACKOFF_CAP_MS = 10_000;
+const TOOL_TIMEOUT_MS = 120_000;
+const DRAIN_TIMEOUT_MS = 30_000;
+const SEND_RETRIES = 3;
+
+/** Block type accepted in a `user.tool_result` event's content — codegen'd, stays in sync with the API. */
+type SessionContentBlock = NonNullable[number];
+
+/**
+ * A tool-call event the runner dispatches against the local registry: either a
+ * builtin `agent.tool_use` (answered with `user.tool_result`) or a custom
+ * `agent.custom_tool_use` (answered with `user.custom_tool_result`). Server-side
+ * `agent.mcp_tool_use` calls are intentionally excluded — the runner does not
+ * handle them.
+ */
+type DispatchedToolUseEvent = BetaManagedAgentsAgentToolUseEvent | BetaManagedAgentsAgentCustomToolUseEvent;
+
+/**
+ * The result-event params paired with a {@link DispatchedToolUseEvent}: a
+ * `user.tool_result` answers an `agent.tool_use`, a `user.custom_tool_result`
+ * answers an `agent.custom_tool_use`. The two pairs must be matched exactly.
+ */
+type DispatchedToolResultParams =
+ | BetaManagedAgentsUserToolResultEventParams
+ | BetaManagedAgentsUserCustomToolResultEventParams;
+
+export interface SessionToolRunnerOptions {
+ client: Anthropic;
+ /**
+ * Tools to expose to the session, in the same {@link BetaRunnableTool} shape
+ * `client.beta.messages.toolRunner` accepts. Use
+ * `betaAgentToolset20260401({ workdir })` from
+ * `@anthropic-ai/sdk/tools/agent-toolset/node` for the standard
+ * `agent_toolset_20260401` set; filter or extend the array to customise.
+ */
+ tools: Array;
+ /**
+ * Once the session goes idle with `stop_reason.type === "end_turn"`, the
+ * runner keeps running for this many milliseconds before stopping; any new
+ * event resets the countdown and it re-arms on the next `end_turn` idle.
+ * Defaults to {@link DEFAULT_MAX_IDLE_MS} (60s). `0` (or negative) disables
+ * it — the runner then only stops on session termination or the consumer
+ * breaking out / aborting.
+ */
+ maxIdleMs?: number;
+ /** External abort signal. Aborting it ends the iteration. */
+ signal?: AbortSignal;
+ /**
+ * Extra per-request options merged into every call this runner issues
+ * (event stream / list / send). Mirrors what `client.beta.messages.toolRunner`
+ * accepts: custom `headers` (e.g. a proxy's auth/routing headers) reach the
+ * poll/heartbeat/stop/stream/list/send calls. The runner always owns the abort
+ * signal, so a `signal` here is ignored — pass {@link SessionToolRunnerOptions.signal}
+ * to abort externally.
+ */
+ requestOptions?: BetaToolRunnerRequestOptions;
+}
+
+/** Default {@link SessionToolRunnerOptions.maxIdleMs}: 60 seconds. */
+export const DEFAULT_MAX_IDLE_MS = 60_000;
+
+/**
+ * Outcome of a single tool execution dispatched by {@link SessionToolRunner}.
+ *
+ * Yielded after the tool ran (or failed) and after the result was posted back
+ * to the session as a `user.tool_result` event. Consumers can read either the
+ * embedded {@link DispatchedToolCall.event} / {@link DispatchedToolCall.result}
+ * blocks or the flat top-level convenience fields.
+ */
+export interface DispatchedToolCall {
+ /**
+ * The `agent.tool_use` or `agent.custom_tool_use` event that triggered this
+ * dispatch. Read `event.input` for the raw tool input and `event.name` for the
+ * tool name; `event.type` distinguishes a builtin tool call from a custom one.
+ */
+ readonly event: DispatchedToolUseEvent;
+ /**
+ * The result event posted (or attempted) back to the session for this call: a
+ * `user.tool_result` for an `agent.tool_use`, a `user.custom_tool_result` for
+ * an `agent.custom_tool_use`. Read `result.content` for the tool's output
+ * blocks and `result.is_error` for the error flag.
+ */
+ readonly result: DispatchedToolResultParams;
+ /**
+ * Flat convenience for `event.id` — the id of the tool-use event this result
+ * answers (echoed back as `tool_use_id` / `custom_tool_use_id` on the result).
+ */
+ readonly toolUseId: string;
+ /** Flat convenience for `event.name` — the dispatched tool's name. */
+ readonly name: string;
+ /**
+ * Flat convenience for `result.is_error` — `true` when the tool threw or was
+ * not found, `false` on success.
+ */
+ readonly isError: boolean;
+ /**
+ * Whether the `user.tool_result` was successfully posted back to the session.
+ * `false` when the post itself failed — typically a permanent 4xx or
+ * send-retry exhaustion.
+ */
+ readonly posted: boolean;
+}
+
+/** Returns true if `ev` is a `session.status_idle` with `stop_reason` `end_turn`. */
+function isEndTurnIdle(ev: { type?: string; stop_reason?: { type?: string } }): boolean {
+ return ev.type === 'session.status_idle' && ev.stop_reason?.type === 'end_turn';
+}
+
+/**
+ * The sessions-side counterpart to `client.beta.messages.toolRunner`: an
+ * async-iterable that attaches to a managed-agents session, executes every
+ * incoming `agent.tool_use` and `agent.custom_tool_use` event against a local
+ * tool registry, posts the matching result back (`user.tool_result` for the
+ * former, `user.custom_tool_result` for the latter), and yields one
+ * {@link DispatchedToolCall} per completed call. Server-side `agent.mcp_tool_use`
+ * calls are not dispatched. Internally drives event-stream reconnect and result
+ * posting.
+ *
+ * Iteration ends when the session terminates (`session.status_terminated` /
+ * `session.deleted`), when the consumer `break`s out of the loop or aborts the
+ * supplied signal, or — once the session has gone idle with
+ * `stop_reason.type === "end_turn"` — when `maxIdleMs` elapses with no new
+ * event (any new event resets that countdown; it re-arms on the next `end_turn`
+ * idle; `maxIdleMs <= 0` disables it). The `finally` branch drains any in-flight
+ * tool calls and runs each tool's `close()` cleanup hook. It does *not* touch
+ * the work-item lease — wrap it in an `EnvironmentWorker` if you need
+ * heartbeating / force-stop.
+ *
+ * @example
+ * ```ts
+ * import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node';
+ *
+ * for await (const call of client.beta.sessions.events.toolRunner(work.data.id, {
+ * tools: [...betaAgentToolset20260401({ workdir }), myTool],
+ * })) {
+ * console.log(`${call.name} -> ${call.isError ? 'error' : 'ok'}`);
+ * }
+ * ```
+ */
+export class SessionToolRunner implements AsyncIterable {
+ readonly client: Anthropic;
+ readonly sessionId: string;
+ readonly tools: ReadonlyArray;
+ readonly maxIdleMs: number;
+
+ #consumed = false;
+ readonly #controller: AbortController;
+ readonly #detachExternal: () => void;
+ readonly #requestOpts: BetaToolRunnerRequestOptions | undefined;
+ readonly #toolByName: Map;
+ readonly #logger: Logger;
+ readonly #seen = new Set();
+ readonly #answered = new Set();
+ readonly #results = new AsyncQueue();
+ #inFlightCount = 0;
+ #onIdle: (() => void) | null = null;
+ // When the session is idle past an `end_turn`, the pending stop timer; cleared
+ // by any new event. Event-driven — there is no polling watchdog.
+ #idleTimer: ReturnType | undefined;
+
+ constructor(sessionId: string, opts: SessionToolRunnerOptions) {
+ this.client = opts.client;
+ this.sessionId = sessionId;
+ this.tools = opts.tools;
+ this.maxIdleMs = opts.maxIdleMs ?? DEFAULT_MAX_IDLE_MS;
+ this.#logger = loggerFor(opts.client);
+ this.#toolByName = new Map(opts.tools.map((t) => [toolName(t), t]));
+ this.#controller = new AbortController();
+ this.#detachExternal = linkAbort(opts.signal, this.#controller);
+ this.#requestOpts = opts.requestOptions;
+ }
+
+ /** Read-only view of this runner's abort signal. */
+ get signal(): AbortSignal {
+ return this.#controller.signal;
+ }
+
+ /** Abort the runner. Background tasks will wind down and `for await` will exit cleanly. */
+ abort(): void {
+ this.#controller.abort();
+ }
+
+ async *[Symbol.asyncIterator](): AsyncIterator {
+ if (this.#consumed) {
+ throw new AnthropicError('Cannot iterate over a consumed SessionToolRunner');
+ }
+ this.#consumed = true;
+ this.#logger.info('session tool runner starting', {
+ component: 'session-tool-runner',
+ session_id: this.sessionId,
+ });
+
+ // The one background promise: drives the event stream and dispatches tools.
+ // Its `.catch` aborts the controller so the main loop unwinds.
+ const streamPromise = this.#streamLoop().catch((e) => {
+ if (!this.#controller.signal.aborted) {
+ this.#logger.error('stream loop failed', { error: String(e) });
+ }
+ this.#controller.abort();
+ });
+
+ try {
+ // Phase 1: yield results as they arrive. `next(signal)` resolves
+ // `done: true` when the controller aborts — cancellation is handled in
+ // the queue read, no outer `Promise.race` needed.
+ while (true) {
+ const next = await this.#results.next(this.#controller.signal);
+ if (next.done) break;
+ yield next.value;
+ }
+
+ // Phase 2: let the stream loop settle (and push any final results), then
+ // drain whatever is still queued before closing.
+ await streamPromise;
+ let pending: DispatchedToolCall | undefined;
+ while ((pending = this.#results.tryShift()) !== undefined) {
+ yield pending;
+ }
+ } finally {
+ this.#controller.abort();
+ this.#disarmIdleTimer();
+ // Re-await defensively in case the consumer broke out of phase 1 before
+ // phase 2 ran — a no-op if it already settled.
+ await streamPromise;
+ try {
+ await this.#drain();
+ } catch (e) {
+ this.#logger.warn('drain failed', { error: String(e) });
+ }
+ this.#results.close();
+ for (const t of this.tools) {
+ try {
+ // `close` is typed `() => Promisable`, so a single `await`
+ // covers both the sync and async return.
+ await t.close?.();
+ } catch (e) {
+ this.#logger.warn('tool.close failed', { tool: toolName(t), error: String(e) });
+ }
+ }
+ // Detach from the external signal so the consumer can drop their signal
+ // reference without leaking this iterator instance.
+ this.#detachExternal();
+ }
+ }
+
+ // ===== request options =====
+
+ /**
+ * Request options for every helper-issued call: the caller's `requestOptions`
+ * (custom proxy headers etc.) with the helper telemetry header stamped on and
+ * the runner's own abort signal forced last so it always owns cancellation.
+ */
+ #requestOptions(): RequestOptions {
+ return {
+ ...this.#requestOpts,
+ headers: buildHeaders([{ 'x-stainless-helper': HELPER_NAME }, this.#requestOpts?.headers]),
+ signal: this.#controller.signal,
+ };
+ }
+
+ // ===== event stream =====
+
+ async #streamLoop(): Promise {
+ const ctrl = this.#controller;
+ let backoff = STREAM_BACKOFF_START_MS;
+ while (!ctrl.signal.aborted) {
+ try {
+ // Establish the event stream *before* reconciling history, so an event
+ // emitted in the gap between listing and attaching is buffered on the
+ // stream rather than lost. `seen`/`answered` dedup any event that shows
+ // up both in the reconcile pass and on the live stream.
+ const stream = await this.client.beta.sessions.events.stream(
+ this.sessionId,
+ {},
+ this.#requestOptions(),
+ );
+ await this.#reconcile();
+ for await (const ev of stream) {
+ backoff = STREAM_BACKOFF_START_MS;
+ if (await this.#handleStreamEvent(ev)) return;
+ }
+ } catch (e) {
+ // An abort throws to unwind the caller (the iterator's `streamPromise`
+ // `.catch`) rather than returning early and letting it carry on.
+ ctrl.signal.throwIfAborted();
+ if (isFatal4xx(e)) {
+ this.#logger.error('permanent stream failure, shutting down', { error: String(e) });
+ ctrl.abort();
+ throw e;
+ }
+ this.#logger.warn('stream disconnected, reconnecting', {
+ error: String(e),
+ backoff_ms: backoff,
+ });
+ }
+ ctrl.signal.throwIfAborted();
+ await sleep(backoff, ctrl.signal);
+ backoff = Math.min(backoff * 2, STREAM_BACKOFF_CAP_MS);
+ }
+ }
+
+ /**
+ * Read full history before dispatching so a `tool_use` whose result appears
+ * later in the same history is not re-executed. Runs after the live stream is
+ * already attached (see {@link SessionToolRunner.#streamLoop}).
+ */
+ async #reconcile(): Promise {
+ const ctrl = this.#controller;
+ const pending: DispatchedToolUseEvent[] = [];
+ let lastWasEndTurn = false;
+ try {
+ for await (const ev of this.client.beta.sessions.events.list(
+ this.sessionId,
+ { limit: 1000 },
+ this.#requestOptions(),
+ )) {
+ this.#ingestHistory(ev, pending);
+ lastWasEndTurn = isEndTurnIdle(ev);
+ }
+ } catch (e) {
+ // An abort throws to unwind the caller; a real list failure is
+ // non-fatal — undo the speculative `seen` entries and let `#streamLoop`
+ // carry on with the live stream.
+ ctrl.signal.throwIfAborted();
+ this.#logger.warn('reconcile list failed', { error: String(e) });
+ // If list itself failed, undo the speculative `seen` entries so the next
+ // reconcile pass (or the live stream) can pick them up. Leave the idle
+ // timer untouched — the history we read may be incomplete.
+ for (const ev of pending) this.#seen.delete(ev.id);
+ return;
+ }
+ const unanswered = pending.filter((ev) => !this.#answered.has(ev.id));
+ // If the most recent event in history is an `end_turn` idle and there's no
+ // outstanding tool work, the session is done — arm the idle timer so the
+ // runner stops even if that `end_turn` arrived during a disconnect.
+ if (lastWasEndTurn && unanswered.length === 0) this.#armIdleTimer();
+ else this.#disarmIdleTimer();
+ for (const ev of unanswered) await this.#execute(ev);
+ }
+
+ #ingestHistory(ev: BetaManagedAgentsSessionEvent, pending: DispatchedToolUseEvent[]): void {
+ if (ev.type === 'agent.tool_use' || ev.type === 'agent.custom_tool_use') {
+ // Mark the event seen so a replay on the live stream is not dispatched
+ // twice, but decide whether it still needs executing from `answered`, not
+ // `seen`: a call whose result post failed is seen-but-unanswered, and must
+ // be retried on the next reconcile pass rather than silently dropped.
+ this.#seen.add(ev.id);
+ if (!this.#answered.has(ev.id)) pending.push(ev);
+ } else if (ev.type === 'user.tool_result') {
+ this.#answered.add(ev.tool_use_id);
+ } else if (ev.type === 'user.custom_tool_result') {
+ this.#answered.add(ev.custom_tool_use_id);
+ }
+ }
+
+ /** Returns true when the runner should exit. */
+ async #handleStreamEvent(ev: BetaManagedAgentsStreamSessionEvents): Promise {
+ // Arm/disarm the idle timer: an `end_turn` idle starts the grace countdown;
+ // any other event cancels it.
+ if (isEndTurnIdle(ev)) this.#armIdleTimer();
+ else this.#disarmIdleTimer();
+ switch (ev.type) {
+ case 'agent.tool_use':
+ case 'agent.custom_tool_use':
+ if (!this.#seen.has(ev.id)) {
+ this.#seen.add(ev.id);
+ await this.#execute(ev);
+ }
+ return false;
+ case 'user.tool_result':
+ this.#answered.add(ev.tool_use_id);
+ return false;
+ case 'user.custom_tool_result':
+ this.#answered.add(ev.custom_tool_use_id);
+ return false;
+ case 'session.status_terminated':
+ case 'session.deleted':
+ this.#logger.info('session terminated', {
+ component: 'session-tool-runner',
+ session_id: this.sessionId,
+ });
+ this.#controller.abort();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ // ===== idle timer =====
+
+ /** (Re)start the grace countdown that stops the runner after `maxIdleMs` of idle. */
+ #armIdleTimer(): void {
+ this.#disarmIdleTimer();
+ if (this.maxIdleMs <= 0) return;
+ this.#idleTimer = setTimeout(() => {
+ this.#logger.info('session idle after end_turn; stopping', {
+ component: 'session-tool-runner',
+ session_id: this.sessionId,
+ max_idle_ms: this.maxIdleMs,
+ });
+ this.#controller.abort();
+ }, this.maxIdleMs);
+ }
+
+ /** Cancel a pending idle countdown, if any. */
+ #disarmIdleTimer(): void {
+ if (this.#idleTimer !== undefined) {
+ clearTimeout(this.#idleTimer);
+ this.#idleTimer = undefined;
+ }
+ }
+
+ // ===== tool execution =====
+
+ async #execute(ev: DispatchedToolUseEvent): Promise {
+ if (this.#answered.has(ev.id)) return;
+ this.#logger.info('executing tool', {
+ component: 'session-tool-runner',
+ session_id: this.sessionId,
+ tool: ev.name,
+ tool_use_id: ev.id,
+ });
+ this.#inFlightCount++;
+ try {
+ const tool = this.#toolByName.get(ev.name);
+ let content: string | Array;
+ let isError: boolean;
+ if (!tool) {
+ // Match `BetaToolRunner`'s wording — the string lands in the model's
+ // context, so the two `toolRunner()` surfaces should agree.
+ content = `Error: Tool '${ev.name}' not found`;
+ isError = true;
+ } else {
+ // Per-tool controller: aborts on the runner's own signal *or* the
+ // per-tool timeout, so an in-flight tool stops promptly when the runner
+ // is aborted instead of running until the timeout.
+ const toolCtrl = new AbortController();
+ const detachTool = linkAbort(this.#controller.signal, toolCtrl);
+ const timer = setTimeout(() => toolCtrl.abort(), TOOL_TIMEOUT_MS);
+ try {
+ // Pass the source `agent.tool_use` / `agent.custom_tool_use` event
+ // straight through as the run context's `toolUse` — it is a union
+ // member of `BetaToolUse`, no Messages-block adapter needed.
+ const outcome = await runRunnableTool(tool, ev.input, {
+ toolUse: ev,
+ toolUseBlock: ev,
+ signal: toolCtrl.signal,
+ });
+ content = outcome.content;
+ isError = outcome.isError;
+ } finally {
+ clearTimeout(timer);
+ detachTool();
+ }
+ }
+ // Answer with the result event that matches the call kind: a
+ // `user.tool_result` for an `agent.tool_use`, a `user.custom_tool_result`
+ // for an `agent.custom_tool_use`. Posting the wrong one leaves the call
+ // unanswered and the session stuck.
+ const result = buildResultEvent(ev, isError, toSessionContent(content));
+ const posted = await this.#sendResult(result, ev.id);
+ this.#results.push({
+ event: ev,
+ result,
+ toolUseId: ev.id,
+ name: ev.name,
+ isError,
+ posted,
+ });
+ } finally {
+ this.#inFlightCount--;
+ if (this.#inFlightCount === 0) this.#onIdle?.();
+ }
+ }
+
+ async #sendResult(result: DispatchedToolResultParams, toolUseId: string): Promise {
+ const ctrl = this.#controller;
+ let lastErr: unknown;
+ for (let i = 0; i < SEND_RETRIES; i++) {
+ // An abort throws to unwind the caller rather than returning a
+ // `posted: false` result the iterator would carry on past.
+ ctrl.signal.throwIfAborted();
+ try {
+ await this.client.beta.sessions.events.send(
+ this.sessionId,
+ { events: [result] },
+ this.#requestOptions(),
+ );
+ this.#answered.add(toolUseId);
+ return true;
+ } catch (e) {
+ lastErr = e;
+ // Only short-circuit on a permanent 4xx; 408/409/429 deserve the
+ // remaining retries (aligned with the core client's retry policy).
+ if (isFatal4xx(e)) break;
+ // Back off only *between* attempts — never after the final one, since
+ // there is no further try left to wait for.
+ if (i < SEND_RETRIES - 1) await sleep((i + 1) * 1000, ctrl.signal);
+ }
+ }
+ this.#logger.error('failed to send tool result', {
+ tool_use_id: toolUseId,
+ error: String(lastErr),
+ });
+ return false;
+ }
+
+ /** Wait (bounded) for in-flight tool executions to finish during teardown. */
+ async #drain(): Promise {
+ if (this.#inFlightCount === 0) return;
+ await Promise.race([new Promise((r) => (this.#onIdle = r)), sleep(DRAIN_TIMEOUT_MS)]);
+ this.#onIdle = null;
+ if (this.#inFlightCount > 0) {
+ this.#logger.warn('drain timeout exceeded');
+ }
+ }
+}
+
+/**
+ * Build the result event that answers `ev`: a `user.tool_result` for a builtin
+ * `agent.tool_use`, a `user.custom_tool_result` for a custom
+ * `agent.custom_tool_use`. The two `(use, result)` pairs are distinct API event
+ * types and must be matched exactly — a `user.tool_result` does not answer a
+ * custom tool call.
+ */
+function buildResultEvent(
+ ev: DispatchedToolUseEvent,
+ isError: boolean,
+ content: SessionContentBlock[],
+): DispatchedToolResultParams {
+ if (ev.type === 'agent.custom_tool_use') {
+ return { type: 'user.custom_tool_result', custom_tool_use_id: ev.id, is_error: isError, content };
+ }
+ return { type: 'user.tool_result', tool_use_id: ev.id, is_error: isError, content };
+}
+
+// The Messages-API tool-result block union is wider than the Sessions-API
+// tool_result content union; pass through text/image/document and stringify
+// anything else so a BetaRunnableTool authored for toolRunner still works here.
+function toSessionContent(content: string | Array): SessionContentBlock[] {
+ if (typeof content === 'string') return [{ type: 'text', text: content || '(no output)' }];
+ const out = content.map((b): SessionContentBlock => {
+ if (b.type === 'text') return { type: 'text', text: b.text || '(no output)' };
+ if (b.type === 'image' || b.type === 'document') return b as SessionContentBlock;
+ if (b.type === 'search_result') {
+ // The Messages `search_result` block param maps field-for-field onto the
+ // Sessions `BetaManagedAgentsSearchResultBlock`; map it explicitly rather
+ // than letting it fall through to the JSON.stringify branch (which would
+ // bury a structured result inside a text block). `citations` is required
+ // on the Sessions side and optional on the Messages side — default the
+ // flag to `false` when the producer left it unset.
+ return {
+ type: 'search_result',
+ source: b.source,
+ title: b.title,
+ content: b.content.map((c) => ({ type: 'text', text: c.text })),
+ citations: { enabled: b.citations?.enabled ?? false },
+ };
+ }
+ return { type: 'text', text: JSON.stringify(b) };
+ });
+ return out.length > 0 ? out : [{ type: 'text', text: '(no output)' }];
+}
diff --git a/src/resources/beta/agents/agents.ts b/src/resources/beta/agents/agents.ts
index 3bb35a06..888ad2f4 100644
--- a/src/resources/beta/agents/agents.ts
+++ b/src/resources/beta/agents/agents.ts
@@ -292,6 +292,92 @@ export interface BetaManagedAgentsAgentToolset20260401 {
type: 'agent_toolset_20260401';
}
+/**
+ * Input payload for the `bash` tool of the `agent_toolset_20260401` toolset. All
+ * fields are optional; a normal invocation supplies `command`, while
+ * `restart=true` (with no `command`) reboots the runner-side bash session.
+ */
+export interface BetaManagedAgentsAgentToolset20260401BashInput {
+ /**
+ * Shell command to execute. Omit only when `restart` is true.
+ */
+ command?: string;
+
+ /**
+ * When true, restart the persistent bash session instead of running a command.
+ * Subsequent calls without `restart` will run against the fresh session.
+ */
+ restart?: boolean;
+
+ /**
+ * Per-call timeout in milliseconds. Defaults to the runner-wide tool timeout when
+ * omitted or zero.
+ */
+ timeout_ms?: number;
+}
+
+/**
+ * Input payload for the `edit` tool. Performs a string replacement in the named
+ * file; by default `old_string` must occur exactly once.
+ */
+export interface BetaManagedAgentsAgentToolset20260401EditInput {
+ /**
+ * Path of the file to edit.
+ */
+ file_path: string;
+
+ /**
+ * Replacement text.
+ */
+ new_string: string;
+
+ /**
+ * Substring to find and replace.
+ */
+ old_string: string;
+
+ /**
+ * When true, replace every occurrence of `old_string` instead of requiring a
+ * unique match.
+ */
+ replace_all?: boolean;
+}
+
+/**
+ * Input payload for the `glob` tool. Returns paths matching a doublestar glob
+ * pattern, newest first.
+ */
+export interface BetaManagedAgentsAgentToolset20260401GlobInput {
+ /**
+ * Doublestar glob pattern (e.g. `** /*.go`). Absolute patterns are only permitted
+ * when the runner is configured to allow them.
+ */
+ pattern: string;
+
+ /**
+ * Optional directory root to search under. Defaults to the runner's working
+ * directory.
+ */
+ path?: string;
+}
+
+/**
+ * Input payload for the `grep` tool. Searches file contents for a regular
+ * expression, returning matching lines.
+ */
+export interface BetaManagedAgentsAgentToolset20260401GrepInput {
+ /**
+ * Regular expression to search for.
+ */
+ pattern: string;
+
+ /**
+ * Optional directory root to search under. Defaults to the runner's working
+ * directory.
+ */
+ path?: string;
+}
+
/**
* Configuration for built-in agent tools. Use this to enable or disable groups of
* tools available to the agent.
@@ -310,6 +396,39 @@ export interface BetaManagedAgentsAgentToolset20260401Params {
default_config?: BetaManagedAgentsAgentToolsetDefaultConfigParams | null;
}
+/**
+ * Input payload for the `read` tool. Reads file contents relative to the runner's
+ * working directory (or absolute when the runner permits).
+ */
+export interface BetaManagedAgentsAgentToolset20260401ReadInput {
+ /**
+ * Path of the file to read.
+ */
+ file_path: string;
+
+ /**
+ * Optional `[start_line, end_line]` 1-indexed inclusive range. When omitted the
+ * entire file is returned. `end_line` of 0 or negative means "to end of file".
+ */
+ view_range?: Array;
+}
+
+/**
+ * Input payload for the `write` tool. Writes (overwriting) the entire file
+ * contents.
+ */
+export interface BetaManagedAgentsAgentToolset20260401WriteInput {
+ /**
+ * Full file contents to write.
+ */
+ content: string;
+
+ /**
+ * Path of the file to write.
+ */
+ file_path: string;
+}
+
/**
* Tool calls are automatically approved without user confirmation.
*/
@@ -645,6 +764,38 @@ export interface BetaManagedAgentsMultiagentSelfParams {
type: 'self';
}
+/**
+ * Resolved `agent` definition for a single `session_thread`. Snapshot of the agent
+ * at thread creation time. The multiagent roster is not repeated here; read it
+ * from `Session.agent`.
+ */
+export interface BetaManagedAgentsSessionThreadAgent {
+ id: string;
+
+ description: string | null;
+
+ mcp_servers: Array;
+
+ /**
+ * Model identifier and configuration.
+ */
+ model: BetaManagedAgentsModelConfig;
+
+ name: string;
+
+ skills: Array;
+
+ system: string | null;
+
+ tools: Array<
+ BetaManagedAgentsAgentToolset20260401 | BetaManagedAgentsMCPToolset | BetaManagedAgentsCustomTool
+ >;
+
+ type: 'agent';
+
+ version: number;
+}
+
/**
* Skill to load in the session container.
*/
@@ -864,7 +1015,13 @@ export declare namespace Agents {
type BetaManagedAgentsAgentToolsetDefaultConfig as BetaManagedAgentsAgentToolsetDefaultConfig,
type BetaManagedAgentsAgentToolsetDefaultConfigParams as BetaManagedAgentsAgentToolsetDefaultConfigParams,
type BetaManagedAgentsAgentToolset20260401 as BetaManagedAgentsAgentToolset20260401,
+ type BetaManagedAgentsAgentToolset20260401BashInput as BetaManagedAgentsAgentToolset20260401BashInput,
+ type BetaManagedAgentsAgentToolset20260401EditInput as BetaManagedAgentsAgentToolset20260401EditInput,
+ type BetaManagedAgentsAgentToolset20260401GlobInput as BetaManagedAgentsAgentToolset20260401GlobInput,
+ type BetaManagedAgentsAgentToolset20260401GrepInput as BetaManagedAgentsAgentToolset20260401GrepInput,
type BetaManagedAgentsAgentToolset20260401Params as BetaManagedAgentsAgentToolset20260401Params,
+ type BetaManagedAgentsAgentToolset20260401ReadInput as BetaManagedAgentsAgentToolset20260401ReadInput,
+ type BetaManagedAgentsAgentToolset20260401WriteInput as BetaManagedAgentsAgentToolset20260401WriteInput,
type BetaManagedAgentsAlwaysAllowPolicy as BetaManagedAgentsAlwaysAllowPolicy,
type BetaManagedAgentsAlwaysAskPolicy as BetaManagedAgentsAlwaysAskPolicy,
type BetaManagedAgentsAnthropicSkill as BetaManagedAgentsAnthropicSkill,
@@ -887,6 +1044,7 @@ export declare namespace Agents {
type BetaManagedAgentsMultiagentCoordinator as BetaManagedAgentsMultiagentCoordinator,
type BetaManagedAgentsMultiagentCoordinatorParams as BetaManagedAgentsMultiagentCoordinatorParams,
type BetaManagedAgentsMultiagentSelfParams as BetaManagedAgentsMultiagentSelfParams,
+ type BetaManagedAgentsSessionThreadAgent as BetaManagedAgentsSessionThreadAgent,
type BetaManagedAgentsSkillParams as BetaManagedAgentsSkillParams,
type BetaManagedAgentsURLMCPServerParams as BetaManagedAgentsURLMCPServerParams,
type BetaManagedAgentsAgentsPageCursor as BetaManagedAgentsAgentsPageCursor,
diff --git a/src/resources/beta/agents/index.ts b/src/resources/beta/agents/index.ts
index 64f2d8b4..1de34d80 100644
--- a/src/resources/beta/agents/index.ts
+++ b/src/resources/beta/agents/index.ts
@@ -9,7 +9,13 @@ export {
type BetaManagedAgentsAgentToolsetDefaultConfig,
type BetaManagedAgentsAgentToolsetDefaultConfigParams,
type BetaManagedAgentsAgentToolset20260401,
+ type BetaManagedAgentsAgentToolset20260401BashInput,
+ type BetaManagedAgentsAgentToolset20260401EditInput,
+ type BetaManagedAgentsAgentToolset20260401GlobInput,
+ type BetaManagedAgentsAgentToolset20260401GrepInput,
type BetaManagedAgentsAgentToolset20260401Params,
+ type BetaManagedAgentsAgentToolset20260401ReadInput,
+ type BetaManagedAgentsAgentToolset20260401WriteInput,
type BetaManagedAgentsAlwaysAllowPolicy,
type BetaManagedAgentsAlwaysAskPolicy,
type BetaManagedAgentsAnthropicSkill,
@@ -32,6 +38,7 @@ export {
type BetaManagedAgentsMultiagentCoordinator,
type BetaManagedAgentsMultiagentCoordinatorParams,
type BetaManagedAgentsMultiagentSelfParams,
+ type BetaManagedAgentsSessionThreadAgent,
type BetaManagedAgentsSkillParams,
type BetaManagedAgentsURLMCPServerParams,
type AgentCreateParams,
diff --git a/src/resources/beta/beta.ts b/src/resources/beta/beta.ts
index 2fbe6c42..4440f4b2 100644
--- a/src/resources/beta/beta.ts
+++ b/src/resources/beta/beta.ts
@@ -1,26 +1,6 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
import { APIResource } from '../../core/resource';
-import * as EnvironmentsAPI from './environments';
-import {
- BetaCloudConfig,
- BetaCloudConfigParams,
- BetaEnvironment,
- BetaEnvironmentDeleteResponse,
- BetaEnvironmentsPageCursor,
- BetaLimitedNetwork,
- BetaLimitedNetworkParams,
- BetaPackages,
- BetaPackagesParams,
- BetaUnrestrictedNetwork,
- EnvironmentArchiveParams,
- EnvironmentCreateParams,
- EnvironmentDeleteParams,
- EnvironmentListParams,
- EnvironmentRetrieveParams,
- EnvironmentUpdateParams,
- Environments,
-} from './environments';
import * as FilesAPI from './files';
import {
BetaFileScope,
@@ -103,7 +83,13 @@ import {
BetaManagedAgentsAgentToolConfig,
BetaManagedAgentsAgentToolConfigParams,
BetaManagedAgentsAgentToolset20260401,
+ BetaManagedAgentsAgentToolset20260401BashInput,
+ BetaManagedAgentsAgentToolset20260401EditInput,
+ BetaManagedAgentsAgentToolset20260401GlobInput,
+ BetaManagedAgentsAgentToolset20260401GrepInput,
BetaManagedAgentsAgentToolset20260401Params,
+ BetaManagedAgentsAgentToolset20260401ReadInput,
+ BetaManagedAgentsAgentToolset20260401WriteInput,
BetaManagedAgentsAgentToolsetDefaultConfig,
BetaManagedAgentsAgentToolsetDefaultConfigParams,
BetaManagedAgentsAgentsPageCursor,
@@ -129,9 +115,32 @@ import {
BetaManagedAgentsMultiagentCoordinator,
BetaManagedAgentsMultiagentCoordinatorParams,
BetaManagedAgentsMultiagentSelfParams,
+ BetaManagedAgentsSessionThreadAgent,
BetaManagedAgentsSkillParams,
BetaManagedAgentsURLMCPServerParams,
} from './agents/agents';
+import * as EnvironmentsAPI from './environments/environments';
+import {
+ BetaCloudConfig,
+ BetaCloudConfigParams,
+ BetaEnvironment,
+ BetaEnvironmentDeleteResponse,
+ BetaEnvironmentsPageCursor,
+ BetaLimitedNetwork,
+ BetaLimitedNetworkParams,
+ BetaPackages,
+ BetaPackagesParams,
+ BetaSelfHostedConfig,
+ BetaSelfHostedConfigParams,
+ BetaUnrestrictedNetwork,
+ EnvironmentArchiveParams,
+ EnvironmentCreateParams,
+ EnvironmentDeleteParams,
+ EnvironmentListParams,
+ EnvironmentRetrieveParams,
+ EnvironmentUpdateParams,
+ Environments,
+} from './environments/environments';
import * as MemoryStoresAPI from './memory-stores/memory-stores';
import {
BetaManagedAgentsDeletedMemoryStore,
@@ -378,10 +387,13 @@ import {
BetaManagedAgentsOutcomeEvaluationResource,
BetaManagedAgentsSession,
BetaManagedAgentsSessionAgent,
+ BetaManagedAgentsSessionAgentUpdate,
BetaManagedAgentsSessionMultiagentCoordinator,
BetaManagedAgentsSessionStats,
+ BetaManagedAgentsSessionUpdatedEvent,
BetaManagedAgentsSessionUsage,
BetaManagedAgentsSessionsPageCursor,
+ BetaManagedAgentsUserToolResultEvent,
SessionArchiveParams,
SessionCreateParams,
SessionDeleteParams,
@@ -800,7 +812,13 @@ export declare namespace Beta {
type BetaManagedAgentsAgentToolsetDefaultConfig as BetaManagedAgentsAgentToolsetDefaultConfig,
type BetaManagedAgentsAgentToolsetDefaultConfigParams as BetaManagedAgentsAgentToolsetDefaultConfigParams,
type BetaManagedAgentsAgentToolset20260401 as BetaManagedAgentsAgentToolset20260401,
+ type BetaManagedAgentsAgentToolset20260401BashInput as BetaManagedAgentsAgentToolset20260401BashInput,
+ type BetaManagedAgentsAgentToolset20260401EditInput as BetaManagedAgentsAgentToolset20260401EditInput,
+ type BetaManagedAgentsAgentToolset20260401GlobInput as BetaManagedAgentsAgentToolset20260401GlobInput,
+ type BetaManagedAgentsAgentToolset20260401GrepInput as BetaManagedAgentsAgentToolset20260401GrepInput,
type BetaManagedAgentsAgentToolset20260401Params as BetaManagedAgentsAgentToolset20260401Params,
+ type BetaManagedAgentsAgentToolset20260401ReadInput as BetaManagedAgentsAgentToolset20260401ReadInput,
+ type BetaManagedAgentsAgentToolset20260401WriteInput as BetaManagedAgentsAgentToolset20260401WriteInput,
type BetaManagedAgentsAlwaysAllowPolicy as BetaManagedAgentsAlwaysAllowPolicy,
type BetaManagedAgentsAlwaysAskPolicy as BetaManagedAgentsAlwaysAskPolicy,
type BetaManagedAgentsAnthropicSkill as BetaManagedAgentsAnthropicSkill,
@@ -823,6 +841,7 @@ export declare namespace Beta {
type BetaManagedAgentsMultiagentCoordinator as BetaManagedAgentsMultiagentCoordinator,
type BetaManagedAgentsMultiagentCoordinatorParams as BetaManagedAgentsMultiagentCoordinatorParams,
type BetaManagedAgentsMultiagentSelfParams as BetaManagedAgentsMultiagentSelfParams,
+ type BetaManagedAgentsSessionThreadAgent as BetaManagedAgentsSessionThreadAgent,
type BetaManagedAgentsSkillParams as BetaManagedAgentsSkillParams,
type BetaManagedAgentsURLMCPServerParams as BetaManagedAgentsURLMCPServerParams,
type BetaManagedAgentsAgentsPageCursor as BetaManagedAgentsAgentsPageCursor,
@@ -843,6 +862,8 @@ export declare namespace Beta {
type BetaLimitedNetworkParams as BetaLimitedNetworkParams,
type BetaPackages as BetaPackages,
type BetaPackagesParams as BetaPackagesParams,
+ type BetaSelfHostedConfig as BetaSelfHostedConfig,
+ type BetaSelfHostedConfigParams as BetaSelfHostedConfigParams,
type BetaUnrestrictedNetwork as BetaUnrestrictedNetwork,
type BetaEnvironmentsPageCursor as BetaEnvironmentsPageCursor,
type EnvironmentCreateParams as EnvironmentCreateParams,
@@ -869,9 +890,12 @@ export declare namespace Beta {
type BetaManagedAgentsOutcomeEvaluationResource as BetaManagedAgentsOutcomeEvaluationResource,
type BetaManagedAgentsSession as BetaManagedAgentsSession,
type BetaManagedAgentsSessionAgent as BetaManagedAgentsSessionAgent,
+ type BetaManagedAgentsSessionAgentUpdate as BetaManagedAgentsSessionAgentUpdate,
type BetaManagedAgentsSessionMultiagentCoordinator as BetaManagedAgentsSessionMultiagentCoordinator,
type BetaManagedAgentsSessionStats as BetaManagedAgentsSessionStats,
+ type BetaManagedAgentsSessionUpdatedEvent as BetaManagedAgentsSessionUpdatedEvent,
type BetaManagedAgentsSessionUsage as BetaManagedAgentsSessionUsage,
+ type BetaManagedAgentsUserToolResultEvent as BetaManagedAgentsUserToolResultEvent,
type BetaManagedAgentsSessionsPageCursor as BetaManagedAgentsSessionsPageCursor,
type SessionCreateParams as SessionCreateParams,
type SessionRetrieveParams as SessionRetrieveParams,
diff --git a/src/resources/beta/environments.ts b/src/resources/beta/environments.ts
index 0e83df6b..85fdba4f 100644
--- a/src/resources/beta/environments.ts
+++ b/src/resources/beta/environments.ts
@@ -1,542 +1,3 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-import { APIResource } from '../../core/resource';
-import * as BetaAPI from './beta';
-import { APIPromise } from '../../core/api-promise';
-import { PageCursor, type PageCursorParams, PagePromise } from '../../core/pagination';
-import { buildHeaders } from '../../internal/headers';
-import { RequestOptions } from '../../internal/request-options';
-import { path } from '../../internal/utils/path';
-
-export class Environments extends APIResource {
- /**
- * Create a new environment with the specified configuration.
- *
- * @example
- * ```ts
- * const betaEnvironment =
- * await client.beta.environments.create({
- * name: 'python-data-analysis',
- * });
- * ```
- */
- create(params: EnvironmentCreateParams, options?: RequestOptions): APIPromise {
- const { betas, ...body } = params;
- return this._client.post('/v1/environments?beta=true', {
- body,
- ...options,
- headers: buildHeaders([
- { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
- options?.headers,
- ]),
- });
- }
-
- /**
- * Retrieve a specific environment by ID.
- *
- * @example
- * ```ts
- * const betaEnvironment =
- * await client.beta.environments.retrieve(
- * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
- * );
- * ```
- */
- retrieve(
- environmentID: string,
- params: EnvironmentRetrieveParams | null | undefined = {},
- options?: RequestOptions,
- ): APIPromise {
- const { betas } = params ?? {};
- return this._client.get(path`/v1/environments/${environmentID}?beta=true`, {
- ...options,
- headers: buildHeaders([
- { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
- options?.headers,
- ]),
- });
- }
-
- /**
- * Update an existing environment's configuration.
- *
- * @example
- * ```ts
- * const betaEnvironment =
- * await client.beta.environments.update(
- * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
- * );
- * ```
- */
- update(
- environmentID: string,
- params: EnvironmentUpdateParams,
- options?: RequestOptions,
- ): APIPromise {
- const { betas, ...body } = params;
- return this._client.post(path`/v1/environments/${environmentID}?beta=true`, {
- body,
- ...options,
- headers: buildHeaders([
- { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
- options?.headers,
- ]),
- });
- }
-
- /**
- * List environments with pagination support.
- *
- * @example
- * ```ts
- * // Automatically fetches more pages as needed.
- * for await (const betaEnvironment of client.beta.environments.list()) {
- * // ...
- * }
- * ```
- */
- list(
- params: EnvironmentListParams | null | undefined = {},
- options?: RequestOptions,
- ): PagePromise {
- const { betas, ...query } = params ?? {};
- return this._client.getAPIList('/v1/environments?beta=true', PageCursor, {
- query,
- ...options,
- headers: buildHeaders([
- { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
- options?.headers,
- ]),
- });
- }
-
- /**
- * Delete an environment by ID. Returns a confirmation of the deletion.
- *
- * @example
- * ```ts
- * const betaEnvironmentDeleteResponse =
- * await client.beta.environments.delete(
- * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
- * );
- * ```
- */
- delete(
- environmentID: string,
- params: EnvironmentDeleteParams | null | undefined = {},
- options?: RequestOptions,
- ): APIPromise {
- const { betas } = params ?? {};
- return this._client.delete(path`/v1/environments/${environmentID}?beta=true`, {
- ...options,
- headers: buildHeaders([
- { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
- options?.headers,
- ]),
- });
- }
-
- /**
- * Archive an environment by ID. Archived environments cannot be used to create new
- * sessions.
- *
- * @example
- * ```ts
- * const betaEnvironment =
- * await client.beta.environments.archive(
- * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
- * );
- * ```
- */
- archive(
- environmentID: string,
- params: EnvironmentArchiveParams | null | undefined = {},
- options?: RequestOptions,
- ): APIPromise {
- const { betas } = params ?? {};
- return this._client.post(path`/v1/environments/${environmentID}/archive?beta=true`, {
- ...options,
- headers: buildHeaders([
- { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
- options?.headers,
- ]),
- });
- }
-}
-
-export type BetaEnvironmentsPageCursor = PageCursor;
-
-/**
- * `cloud` environment configuration.
- */
-export interface BetaCloudConfig {
- /**
- * Network configuration policy.
- */
- networking: BetaUnrestrictedNetwork | BetaLimitedNetwork;
-
- /**
- * Package manager configuration.
- */
- packages: BetaPackages;
-
- /**
- * Environment type
- */
- type: 'cloud';
-}
-
-/**
- * Request params for `cloud` environment configuration.
- *
- * Fields default to null; on update, omitted fields preserve the existing value.
- */
-export interface BetaCloudConfigParams {
- /**
- * Environment type
- */
- type: 'cloud';
-
- /**
- * Network configuration policy. Omit on update to preserve the existing value.
- */
- networking?: BetaUnrestrictedNetwork | BetaLimitedNetworkParams | null;
-
- /**
- * Specify packages (and optionally their versions) available in this environment.
- *
- * When versioning, use the version semantics relevant for the package manager,
- * e.g. for `pip` use `package==1.0.0`. You are responsible for validating the
- * package and version exist. Unversioned installs the latest.
- */
- packages?: BetaPackagesParams | null;
-}
-
-/**
- * Unified Environment resource for both cloud and self-hosted environments.
- */
-export interface BetaEnvironment {
- /**
- * Environment identifier (e.g., 'env\_...')
- */
- id: string;
-
- /**
- * RFC 3339 timestamp when environment was archived, or null if not archived
- */
- archived_at: string | null;
-
- /**
- * `cloud` environment configuration.
- */
- config: BetaCloudConfig;
-
- /**
- * RFC 3339 timestamp when environment was created
- */
- created_at: string;
-
- /**
- * User-provided description for the environment
- */
- description: string;
-
- /**
- * User-provided metadata key-value pairs
- */
- metadata: { [key: string]: string };
-
- /**
- * Human-readable name for the environment
- */
- name: string;
-
- /**
- * The type of object (always 'environment')
- */
- type: 'environment';
-
- /**
- * RFC 3339 timestamp when environment was last updated
- */
- updated_at: string;
-}
-
-/**
- * Response after deleting an environment.
- */
-export interface BetaEnvironmentDeleteResponse {
- /**
- * Environment identifier
- */
- id: string;
-
- /**
- * The type of response
- */
- type: 'environment_deleted';
-}
-
-/**
- * Limited network access.
- */
-export interface BetaLimitedNetwork {
- /**
- * Permits outbound access to MCP server endpoints configured on the agent, beyond
- * those listed in the `allowed_hosts` array.
- */
- allow_mcp_servers: boolean;
-
- /**
- * Permits outbound access to public package registries (PyPI, npm, etc.) beyond
- * those listed in the `allowed_hosts` array.
- */
- allow_package_managers: boolean;
-
- /**
- * Specifies domains the container can reach.
- */
- allowed_hosts: Array;
-
- /**
- * Network policy type
- */
- type: 'limited';
-}
-
-/**
- * Limited network request params.
- *
- * Fields default to null; on update, omitted fields preserve the existing value.
- */
-export interface BetaLimitedNetworkParams {
- /**
- * Network policy type
- */
- type: 'limited';
-
- /**
- * Permits outbound access to MCP server endpoints configured on the agent, beyond
- * those listed in the `allowed_hosts` array. Defaults to `false`.
- */
- allow_mcp_servers?: boolean | null;
-
- /**
- * Permits outbound access to public package registries (PyPI, npm, etc.) beyond
- * those listed in the `allowed_hosts` array. Defaults to `false`.
- */
- allow_package_managers?: boolean | null;
-
- /**
- * Specifies domains the container can reach.
- */
- allowed_hosts?: Array | null;
-}
-
-/**
- * Packages (and their versions) available in this environment.
- */
-export interface BetaPackages {
- /**
- * Ubuntu/Debian packages to install
- */
- apt: Array;
-
- /**
- * Rust packages to install
- */
- cargo: Array;
-
- /**
- * Ruby packages to install
- */
- gem: Array;
-
- /**
- * Go packages to install
- */
- go: Array;
-
- /**
- * Node.js packages to install
- */
- npm: Array;
-
- /**
- * Python packages to install
- */
- pip: Array;
-
- /**
- * Package configuration type
- */
- type?: 'packages';
-}
-
-/**
- * Specify packages (and optionally their versions) available in this environment.
- *
- * When versioning, use the version semantics relevant for the package manager,
- * e.g. for `pip` use `package==1.0.0`. You are responsible for validating the
- * package and version exist. Unversioned installs the latest.
- */
-export interface BetaPackagesParams {
- /**
- * Ubuntu/Debian packages to install
- */
- apt?: Array | null;
-
- /**
- * Rust packages to install
- */
- cargo?: Array | null;
-
- /**
- * Ruby packages to install
- */
- gem?: Array | null;
-
- /**
- * Go packages to install
- */
- go?: Array | null;
-
- /**
- * Node.js packages to install
- */
- npm?: Array | null;
-
- /**
- * Python packages to install
- */
- pip?: Array | null;
-
- /**
- * Package configuration type
- */
- type?: 'packages';
-}
-
-/**
- * Unrestricted network access.
- */
-export interface BetaUnrestrictedNetwork {
- /**
- * Network policy type
- */
- type: 'unrestricted';
-}
-
-export interface EnvironmentCreateParams {
- /**
- * Body param: Human-readable name for the environment
- */
- name: string;
-
- /**
- * Body param: Request params for `cloud` environment configuration.
- *
- * Fields default to null; on update, omitted fields preserve the existing value.
- */
- config?: BetaCloudConfigParams | null;
-
- /**
- * Body param: Optional description of the environment
- */
- description?: string | null;
-
- /**
- * Body param: User-provided metadata key-value pairs
- */
- metadata?: { [key: string]: string };
-
- /**
- * Header param: Optional header to specify the beta version(s) you want to use.
- */
- betas?: Array;
-}
-
-export interface EnvironmentRetrieveParams {
- /**
- * Optional header to specify the beta version(s) you want to use.
- */
- betas?: Array;
-}
-
-export interface EnvironmentUpdateParams {
- /**
- * Body param: Request params for `cloud` environment configuration.
- *
- * Fields default to null; on update, omitted fields preserve the existing value.
- */
- config?: BetaCloudConfigParams | null;
-
- /**
- * Body param: Updated description of the environment
- */
- description?: string | null;
-
- /**
- * Body param: User-provided metadata key-value pairs. Set a value to null or empty
- * string to delete the key.
- */
- metadata?: { [key: string]: string | null };
-
- /**
- * Body param: Updated name for the environment
- */
- name?: string | null;
-
- /**
- * Header param: Optional header to specify the beta version(s) you want to use.
- */
- betas?: Array;
-}
-
-export interface EnvironmentListParams extends PageCursorParams {
- /**
- * Query param: Include archived environments in the response
- */
- include_archived?: boolean;
-
- /**
- * Header param: Optional header to specify the beta version(s) you want to use.
- */
- betas?: Array;
-}
-
-export interface EnvironmentDeleteParams {
- /**
- * Optional header to specify the beta version(s) you want to use.
- */
- betas?: Array;
-}
-
-export interface EnvironmentArchiveParams {
- /**
- * Optional header to specify the beta version(s) you want to use.
- */
- betas?: Array;
-}
-
-export declare namespace Environments {
- export {
- type BetaCloudConfig as BetaCloudConfig,
- type BetaCloudConfigParams as BetaCloudConfigParams,
- type BetaEnvironment as BetaEnvironment,
- type BetaEnvironmentDeleteResponse as BetaEnvironmentDeleteResponse,
- type BetaLimitedNetwork as BetaLimitedNetwork,
- type BetaLimitedNetworkParams as BetaLimitedNetworkParams,
- type BetaPackages as BetaPackages,
- type BetaPackagesParams as BetaPackagesParams,
- type BetaUnrestrictedNetwork as BetaUnrestrictedNetwork,
- type BetaEnvironmentsPageCursor as BetaEnvironmentsPageCursor,
- type EnvironmentCreateParams as EnvironmentCreateParams,
- type EnvironmentRetrieveParams as EnvironmentRetrieveParams,
- type EnvironmentUpdateParams as EnvironmentUpdateParams,
- type EnvironmentListParams as EnvironmentListParams,
- type EnvironmentDeleteParams as EnvironmentDeleteParams,
- type EnvironmentArchiveParams as EnvironmentArchiveParams,
- };
-}
+export * from './environments/index';
diff --git a/src/resources/beta/environments/environments.ts b/src/resources/beta/environments/environments.ts
new file mode 100644
index 00000000..87ca636e
--- /dev/null
+++ b/src/resources/beta/environments/environments.ts
@@ -0,0 +1,625 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+import { APIResource } from '../../../core/resource';
+import * as BetaAPI from '../beta';
+import * as WorkAPI from './work';
+import {
+ BetaSelfHostedWork,
+ BetaSelfHostedWorkHeartbeatResponse,
+ BetaSelfHostedWorkListResponse,
+ BetaSelfHostedWorkQueueStats,
+ BetaSelfHostedWorkStopRequest,
+ BetaSelfHostedWorkUpdateRequest,
+ BetaSelfHostedWorksPageCursor,
+ BetaSessionWorkData,
+ Work,
+ WorkAckParams,
+ WorkHeartbeatParams,
+ WorkListParams,
+ WorkPollParams,
+ WorkRetrieveParams,
+ WorkStatsParams,
+ WorkStopParams,
+ WorkUpdateParams,
+} from './work';
+import { APIPromise } from '../../../core/api-promise';
+import { PageCursor, type PageCursorParams, PagePromise } from '../../../core/pagination';
+import { buildHeaders } from '../../../internal/headers';
+import { RequestOptions } from '../../../internal/request-options';
+import { path } from '../../../internal/utils/path';
+
+export class Environments extends APIResource {
+ work: WorkAPI.Work = new WorkAPI.Work(this._client);
+
+ /**
+ * Create a new environment with the specified configuration.
+ *
+ * @example
+ * ```ts
+ * const betaEnvironment =
+ * await client.beta.environments.create({
+ * name: 'python-data-analysis',
+ * });
+ * ```
+ */
+ create(params: EnvironmentCreateParams, options?: RequestOptions): APIPromise {
+ const { betas, ...body } = params;
+ return this._client.post('/v1/environments?beta=true', {
+ body,
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Retrieve a specific environment by ID.
+ *
+ * @example
+ * ```ts
+ * const betaEnvironment =
+ * await client.beta.environments.retrieve(
+ * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * );
+ * ```
+ */
+ retrieve(
+ environmentID: string,
+ params: EnvironmentRetrieveParams | null | undefined = {},
+ options?: RequestOptions,
+ ): APIPromise {
+ const { betas } = params ?? {};
+ return this._client.get(path`/v1/environments/${environmentID}?beta=true`, {
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Update an existing environment's configuration.
+ *
+ * @example
+ * ```ts
+ * const betaEnvironment =
+ * await client.beta.environments.update(
+ * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * );
+ * ```
+ */
+ update(
+ environmentID: string,
+ params: EnvironmentUpdateParams,
+ options?: RequestOptions,
+ ): APIPromise {
+ const { betas, ...body } = params;
+ return this._client.post(path`/v1/environments/${environmentID}?beta=true`, {
+ body,
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * List environments with pagination support.
+ *
+ * @example
+ * ```ts
+ * // Automatically fetches more pages as needed.
+ * for await (const betaEnvironment of client.beta.environments.list()) {
+ * // ...
+ * }
+ * ```
+ */
+ list(
+ params: EnvironmentListParams | null | undefined = {},
+ options?: RequestOptions,
+ ): PagePromise {
+ const { betas, ...query } = params ?? {};
+ return this._client.getAPIList('/v1/environments?beta=true', PageCursor, {
+ query,
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Delete an environment by ID. Returns a confirmation of the deletion.
+ *
+ * @example
+ * ```ts
+ * const betaEnvironmentDeleteResponse =
+ * await client.beta.environments.delete(
+ * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * );
+ * ```
+ */
+ delete(
+ environmentID: string,
+ params: EnvironmentDeleteParams | null | undefined = {},
+ options?: RequestOptions,
+ ): APIPromise {
+ const { betas } = params ?? {};
+ return this._client.delete(path`/v1/environments/${environmentID}?beta=true`, {
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Archive an environment by ID. Archived environments cannot be used to create new
+ * sessions.
+ *
+ * @example
+ * ```ts
+ * const betaEnvironment =
+ * await client.beta.environments.archive(
+ * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * );
+ * ```
+ */
+ archive(
+ environmentID: string,
+ params: EnvironmentArchiveParams | null | undefined = {},
+ options?: RequestOptions,
+ ): APIPromise {
+ const { betas } = params ?? {};
+ return this._client.post(path`/v1/environments/${environmentID}/archive?beta=true`, {
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+}
+
+export type BetaEnvironmentsPageCursor = PageCursor;
+
+/**
+ * `cloud` environment configuration.
+ */
+export interface BetaCloudConfig {
+ /**
+ * Network configuration policy.
+ */
+ networking: BetaUnrestrictedNetwork | BetaLimitedNetwork;
+
+ /**
+ * Package manager configuration.
+ */
+ packages: BetaPackages;
+
+ /**
+ * Environment type
+ */
+ type: 'cloud';
+}
+
+/**
+ * Request params for `cloud` environment configuration.
+ *
+ * Fields default to null; on update, omitted fields preserve the existing value.
+ */
+export interface BetaCloudConfigParams {
+ /**
+ * Environment type
+ */
+ type: 'cloud';
+
+ /**
+ * Network configuration policy. Omit on update to preserve the existing value.
+ */
+ networking?: BetaUnrestrictedNetwork | BetaLimitedNetworkParams | null;
+
+ /**
+ * Specify packages (and optionally their versions) available in this environment.
+ *
+ * When versioning, use the version semantics relevant for the package manager,
+ * e.g. for `pip` use `package==1.0.0`. You are responsible for validating the
+ * package and version exist. Unversioned installs the latest.
+ */
+ packages?: BetaPackagesParams | null;
+}
+
+/**
+ * Unified Environment resource for both cloud and self-hosted environments.
+ */
+export interface BetaEnvironment {
+ /**
+ * Environment identifier (e.g., 'env\_...')
+ */
+ id: string;
+
+ /**
+ * RFC 3339 timestamp when environment was archived, or null if not archived
+ */
+ archived_at: string | null;
+
+ /**
+ * Environment configuration (either Anthropic Cloud or self-hosted)
+ */
+ config: BetaCloudConfig | BetaSelfHostedConfig;
+
+ /**
+ * RFC 3339 timestamp when environment was created
+ */
+ created_at: string;
+
+ /**
+ * User-provided description for the environment
+ */
+ description: string;
+
+ /**
+ * User-provided metadata key-value pairs
+ */
+ metadata: { [key: string]: string };
+
+ /**
+ * Human-readable name for the environment
+ */
+ name: string;
+
+ /**
+ * The type of object (always 'environment')
+ */
+ type: 'environment';
+
+ /**
+ * RFC 3339 timestamp when environment was last updated
+ */
+ updated_at: string;
+
+ /**
+ * The visibility scope for this environment. 'organization' means visible to all
+ * accounts. 'account' means visible only to the owning account.
+ */
+ scope?: 'organization' | 'account';
+}
+
+/**
+ * Response after deleting an environment.
+ */
+export interface BetaEnvironmentDeleteResponse {
+ /**
+ * Environment identifier
+ */
+ id: string;
+
+ /**
+ * The type of response
+ */
+ type: 'environment_deleted';
+}
+
+/**
+ * Limited network access.
+ */
+export interface BetaLimitedNetwork {
+ /**
+ * Permits outbound access to MCP server endpoints configured on the agent, beyond
+ * those listed in the `allowed_hosts` array.
+ */
+ allow_mcp_servers: boolean;
+
+ /**
+ * Permits outbound access to public package registries (PyPI, npm, etc.) beyond
+ * those listed in the `allowed_hosts` array.
+ */
+ allow_package_managers: boolean;
+
+ /**
+ * Specifies domains the container can reach.
+ */
+ allowed_hosts: Array;
+
+ /**
+ * Network policy type
+ */
+ type: 'limited';
+}
+
+/**
+ * Limited network request params.
+ *
+ * Fields default to null; on update, omitted fields preserve the existing value.
+ */
+export interface BetaLimitedNetworkParams {
+ /**
+ * Network policy type
+ */
+ type: 'limited';
+
+ /**
+ * Permits outbound access to MCP server endpoints configured on the agent, beyond
+ * those listed in the `allowed_hosts` array. Defaults to `false`.
+ */
+ allow_mcp_servers?: boolean | null;
+
+ /**
+ * Permits outbound access to public package registries (PyPI, npm, etc.) beyond
+ * those listed in the `allowed_hosts` array. Defaults to `false`.
+ */
+ allow_package_managers?: boolean | null;
+
+ /**
+ * Specifies domains the container can reach.
+ */
+ allowed_hosts?: Array | null;
+}
+
+/**
+ * Packages (and their versions) available in this environment.
+ */
+export interface BetaPackages {
+ /**
+ * Ubuntu/Debian packages to install
+ */
+ apt: Array;
+
+ /**
+ * Rust packages to install
+ */
+ cargo: Array;
+
+ /**
+ * Ruby packages to install
+ */
+ gem: Array;
+
+ /**
+ * Go packages to install
+ */
+ go: Array;
+
+ /**
+ * Node.js packages to install
+ */
+ npm: Array;
+
+ /**
+ * Python packages to install
+ */
+ pip: Array;
+
+ /**
+ * Package configuration type
+ */
+ type?: 'packages';
+}
+
+/**
+ * Specify packages (and optionally their versions) available in this environment.
+ *
+ * When versioning, use the version semantics relevant for the package manager,
+ * e.g. for `pip` use `package==1.0.0`. You are responsible for validating the
+ * package and version exist. Unversioned installs the latest.
+ */
+export interface BetaPackagesParams {
+ /**
+ * Ubuntu/Debian packages to install
+ */
+ apt?: Array | null;
+
+ /**
+ * Rust packages to install
+ */
+ cargo?: Array | null;
+
+ /**
+ * Ruby packages to install
+ */
+ gem?: Array | null;
+
+ /**
+ * Go packages to install
+ */
+ go?: Array | null;
+
+ /**
+ * Node.js packages to install
+ */
+ npm?: Array | null;
+
+ /**
+ * Python packages to install
+ */
+ pip?: Array | null;
+
+ /**
+ * Package configuration type
+ */
+ type?: 'packages';
+}
+
+/**
+ * Configuration for self-hosted environments.
+ */
+export interface BetaSelfHostedConfig {
+ /**
+ * Environment type
+ */
+ type: 'self_hosted';
+}
+
+/**
+ * Request params for `self_hosted` environment configuration.
+ */
+export interface BetaSelfHostedConfigParams {
+ /**
+ * Environment type
+ */
+ type: 'self_hosted';
+}
+
+/**
+ * Unrestricted network access.
+ */
+export interface BetaUnrestrictedNetwork {
+ /**
+ * Network policy type
+ */
+ type: 'unrestricted';
+}
+
+export interface EnvironmentCreateParams {
+ /**
+ * Body param: Human-readable name for the environment
+ */
+ name: string;
+
+ /**
+ * Body param: Environment configuration
+ */
+ config?: BetaCloudConfigParams | BetaSelfHostedConfigParams | null;
+
+ /**
+ * Body param: Optional description of the environment
+ */
+ description?: string | null;
+
+ /**
+ * Body param: User-provided metadata key-value pairs
+ */
+ metadata?: { [key: string]: string };
+
+ /**
+ * Body param: The visibility scope for this environment. 'organization' makes the
+ * environment visible to all accounts. 'account' restricts visibility to the
+ * owning account only. Only applicable for self-hosted environments. If not
+ * specified, defaults based on organization type.
+ */
+ scope?: 'organization' | 'account' | null;
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface EnvironmentRetrieveParams {
+ /**
+ * Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface EnvironmentUpdateParams {
+ /**
+ * Body param: Updated environment configuration
+ */
+ config?: BetaCloudConfigParams | BetaSelfHostedConfigParams | null;
+
+ /**
+ * Body param: Updated description of the environment
+ */
+ description?: string | null;
+
+ /**
+ * Body param: User-provided metadata key-value pairs. Set a value to null or empty
+ * string to delete the key.
+ */
+ metadata?: { [key: string]: string | null };
+
+ /**
+ * Body param: Updated name for the environment
+ */
+ name?: string | null;
+
+ /**
+ * Body param: The visibility scope for this environment. 'organization' makes the
+ * environment visible to all accounts. 'account' restricts visibility to the
+ * owning account only.
+ */
+ scope?: 'organization' | 'account' | null;
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface EnvironmentListParams extends PageCursorParams {
+ /**
+ * Query param: Include archived environments in the response
+ */
+ include_archived?: boolean;
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface EnvironmentDeleteParams {
+ /**
+ * Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface EnvironmentArchiveParams {
+ /**
+ * Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+Environments.Work = Work;
+
+export declare namespace Environments {
+ export {
+ type BetaCloudConfig as BetaCloudConfig,
+ type BetaCloudConfigParams as BetaCloudConfigParams,
+ type BetaEnvironment as BetaEnvironment,
+ type BetaEnvironmentDeleteResponse as BetaEnvironmentDeleteResponse,
+ type BetaLimitedNetwork as BetaLimitedNetwork,
+ type BetaLimitedNetworkParams as BetaLimitedNetworkParams,
+ type BetaPackages as BetaPackages,
+ type BetaPackagesParams as BetaPackagesParams,
+ type BetaSelfHostedConfig as BetaSelfHostedConfig,
+ type BetaSelfHostedConfigParams as BetaSelfHostedConfigParams,
+ type BetaUnrestrictedNetwork as BetaUnrestrictedNetwork,
+ type BetaEnvironmentsPageCursor as BetaEnvironmentsPageCursor,
+ type EnvironmentCreateParams as EnvironmentCreateParams,
+ type EnvironmentRetrieveParams as EnvironmentRetrieveParams,
+ type EnvironmentUpdateParams as EnvironmentUpdateParams,
+ type EnvironmentListParams as EnvironmentListParams,
+ type EnvironmentDeleteParams as EnvironmentDeleteParams,
+ type EnvironmentArchiveParams as EnvironmentArchiveParams,
+ };
+
+ export {
+ Work as Work,
+ type BetaSelfHostedWork as BetaSelfHostedWork,
+ type BetaSelfHostedWorkHeartbeatResponse as BetaSelfHostedWorkHeartbeatResponse,
+ type BetaSelfHostedWorkListResponse as BetaSelfHostedWorkListResponse,
+ type BetaSelfHostedWorkQueueStats as BetaSelfHostedWorkQueueStats,
+ type BetaSelfHostedWorkStopRequest as BetaSelfHostedWorkStopRequest,
+ type BetaSelfHostedWorkUpdateRequest as BetaSelfHostedWorkUpdateRequest,
+ type BetaSessionWorkData as BetaSessionWorkData,
+ type BetaSelfHostedWorksPageCursor as BetaSelfHostedWorksPageCursor,
+ type WorkRetrieveParams as WorkRetrieveParams,
+ type WorkUpdateParams as WorkUpdateParams,
+ type WorkListParams as WorkListParams,
+ type WorkAckParams as WorkAckParams,
+ type WorkHeartbeatParams as WorkHeartbeatParams,
+ type WorkPollParams as WorkPollParams,
+ type WorkStatsParams as WorkStatsParams,
+ type WorkStopParams as WorkStopParams,
+ };
+}
diff --git a/src/resources/beta/environments/index.ts b/src/resources/beta/environments/index.ts
new file mode 100644
index 00000000..e73271b1
--- /dev/null
+++ b/src/resources/beta/environments/index.ts
@@ -0,0 +1,42 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+export {
+ Environments,
+ type BetaCloudConfig,
+ type BetaCloudConfigParams,
+ type BetaEnvironment,
+ type BetaEnvironmentDeleteResponse,
+ type BetaLimitedNetwork,
+ type BetaLimitedNetworkParams,
+ type BetaPackages,
+ type BetaPackagesParams,
+ type BetaSelfHostedConfig,
+ type BetaSelfHostedConfigParams,
+ type BetaUnrestrictedNetwork,
+ type EnvironmentCreateParams,
+ type EnvironmentRetrieveParams,
+ type EnvironmentUpdateParams,
+ type EnvironmentListParams,
+ type EnvironmentDeleteParams,
+ type EnvironmentArchiveParams,
+ type BetaEnvironmentsPageCursor,
+} from './environments';
+export {
+ Work,
+ type BetaSelfHostedWork,
+ type BetaSelfHostedWorkHeartbeatResponse,
+ type BetaSelfHostedWorkListResponse,
+ type BetaSelfHostedWorkQueueStats,
+ type BetaSelfHostedWorkStopRequest,
+ type BetaSelfHostedWorkUpdateRequest,
+ type BetaSessionWorkData,
+ type WorkRetrieveParams,
+ type WorkUpdateParams,
+ type WorkListParams,
+ type WorkAckParams,
+ type WorkHeartbeatParams,
+ type WorkPollParams,
+ type WorkStatsParams,
+ type WorkStopParams,
+ type BetaSelfHostedWorksPageCursor,
+} from './work';
diff --git a/src/resources/beta/environments/work.ts b/src/resources/beta/environments/work.ts
new file mode 100644
index 00000000..58cf6bae
--- /dev/null
+++ b/src/resources/beta/environments/work.ts
@@ -0,0 +1,697 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+import type { Anthropic } from '../../../client';
+import { APIResource } from '../../../core/resource';
+import * as BetaAPI from '../beta';
+import { APIPromise } from '../../../core/api-promise';
+import { PageCursor, type PageCursorParams, PagePromise } from '../../../core/pagination';
+import { buildHeaders } from '../../../internal/headers';
+import { RequestOptions } from '../../../internal/request-options';
+import { path } from '../../../internal/utils/path';
+import {
+ WorkPoller,
+ type WorkPollerOptions as RunnerWorkPollerOptions,
+} from '../../../lib/environments/poller';
+import {
+ EnvironmentWorker,
+ type EnvironmentWorkerOptions as RunnerEnvironmentWorkerOptions,
+} from '../../../lib/environments/worker';
+
+export class Work extends APIResource {
+ /**
+ * Note: these endpoints are called automatically by the pre-built environment
+ * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted
+ * sandbox environments. They are included here as a reference; you do not need to
+ * invoke them directly.
+ *
+ * Retrieve detailed information about a specific work item.
+ *
+ * @example
+ * ```ts
+ * const betaSelfHostedWork =
+ * await client.beta.environments.work.retrieve('work_id', {
+ * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * });
+ * ```
+ */
+ retrieve(
+ workID: string,
+ params: WorkRetrieveParams,
+ options?: RequestOptions,
+ ): APIPromise {
+ const { environment_id, betas } = params;
+ return this._client.get(path`/v1/environments/${environment_id}/work/${workID}?beta=true`, {
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Note: these endpoints are called automatically by the pre-built environment
+ * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted
+ * sandbox environments. They are included here as a reference; you do not need to
+ * invoke them directly.
+ *
+ * Update work item metadata with merge semantics.
+ *
+ * @example
+ * ```ts
+ * const betaSelfHostedWork =
+ * await client.beta.environments.work.update('work_id', {
+ * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * metadata: { foo: 'string' },
+ * });
+ * ```
+ */
+ update(workID: string, params: WorkUpdateParams, options?: RequestOptions): APIPromise {
+ const { environment_id, betas, ...body } = params;
+ return this._client.post(path`/v1/environments/${environment_id}/work/${workID}?beta=true`, {
+ body,
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Note: these endpoints are called automatically by the pre-built environment
+ * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted
+ * sandbox environments. They are included here as a reference; you do not need to
+ * invoke them directly.
+ *
+ * List work items in an environment.
+ *
+ * @example
+ * ```ts
+ * // Automatically fetches more pages as needed.
+ * for await (const betaSelfHostedWork of client.beta.environments.work.list(
+ * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * )) {
+ * // ...
+ * }
+ * ```
+ */
+ list(
+ environmentID: string,
+ params: WorkListParams | null | undefined = {},
+ options?: RequestOptions,
+ ): PagePromise {
+ const { betas, ...query } = params ?? {};
+ return this._client.getAPIList(
+ path`/v1/environments/${environmentID}/work?beta=true`,
+ PageCursor,
+ {
+ query,
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ },
+ );
+ }
+
+ /**
+ * Note: these endpoints are called automatically by the pre-built environment
+ * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted
+ * sandbox environments. They are included here as a reference; you do not need to
+ * invoke them directly.
+ *
+ * Acknowledge receipt of a work item, transitioning it from 'queued' to 'starting'
+ * and removing it from the queue.
+ *
+ * @example
+ * ```ts
+ * const betaSelfHostedWork =
+ * await client.beta.environments.work.ack('work_id', {
+ * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * });
+ * ```
+ */
+ ack(workID: string, params: WorkAckParams, options?: RequestOptions): APIPromise {
+ const { environment_id, betas } = params;
+ return this._client.post(path`/v1/environments/${environment_id}/work/${workID}/ack?beta=true`, {
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Note: these endpoints are called automatically by the pre-built environment
+ * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted
+ * sandbox environments. They are included here as a reference; you do not need to
+ * invoke them directly.
+ *
+ * Record a heartbeat for a work item to maintain the lease.
+ *
+ * @example
+ * ```ts
+ * const betaSelfHostedWorkHeartbeatResponse =
+ * await client.beta.environments.work.heartbeat('work_id', {
+ * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * });
+ * ```
+ */
+ heartbeat(
+ workID: string,
+ params: WorkHeartbeatParams,
+ options?: RequestOptions,
+ ): APIPromise {
+ const { environment_id, desired_ttl_seconds, expected_last_heartbeat, betas } = params;
+ return this._client.post(path`/v1/environments/${environment_id}/work/${workID}/heartbeat?beta=true`, {
+ query: { desired_ttl_seconds, expected_last_heartbeat },
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Note: these endpoints are called automatically by the pre-built environment
+ * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted
+ * sandbox environments. They are included here as a reference; you do not need to
+ * invoke them directly.
+ *
+ * Long poll for work items in the queue.
+ *
+ * @example
+ * ```ts
+ * const betaSelfHostedWork =
+ * await client.beta.environments.work.poll(
+ * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * );
+ * ```
+ */
+ poll(
+ environmentID: string,
+ params: WorkPollParams | null | undefined = {},
+ options?: RequestOptions,
+ ): APIPromise {
+ const { betas, 'Anthropic-Worker-ID': anthropicWorkerID, ...query } = params ?? {};
+ return this._client.get(path`/v1/environments/${environmentID}/work/poll?beta=true`, {
+ query,
+ ...options,
+ headers: buildHeaders([
+ {
+ 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString(),
+ ...(anthropicWorkerID != null ? { 'Anthropic-Worker-ID': anthropicWorkerID } : undefined),
+ },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Get statistics about the work queue for an environment.
+ *
+ * @example
+ * ```ts
+ * const betaSelfHostedWorkQueueStats =
+ * await client.beta.environments.work.stats(
+ * 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * );
+ * ```
+ */
+ stats(
+ environmentID: string,
+ params: WorkStatsParams | null | undefined = {},
+ options?: RequestOptions,
+ ): APIPromise {
+ const { betas } = params ?? {};
+ return this._client.get(path`/v1/environments/${environmentID}/work/stats?beta=true`, {
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Note: these endpoints are called automatically by the pre-built environment
+ * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted
+ * sandbox environments. They are included here as a reference; you do not need to
+ * invoke them directly.
+ *
+ * Stop a work item, initiating graceful or forced shutdown.
+ *
+ * @example
+ * ```ts
+ * const betaSelfHostedWork =
+ * await client.beta.environments.work.stop('work_id', {
+ * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ * });
+ * ```
+ */
+ stop(workID: string, params: WorkStopParams, options?: RequestOptions): APIPromise {
+ const { environment_id, betas, ...body } = params;
+ return this._client.post(path`/v1/environments/${environment_id}/work/${workID}/stop?beta=true`, {
+ body,
+ ...options,
+ headers: buildHeaders([
+ { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() },
+ options?.headers,
+ ]),
+ });
+ }
+
+ /**
+ * Continuously claim work from a self-hosted environment, ack each item,
+ * and yield it. Posts `stop` automatically when the consumer's loop body
+ * returns or when iteration ends.
+ *
+ * @example
+ * ```ts
+ * for await (const work of client.beta.environments.work.poller({
+ * environmentId,
+ * environmentKey,
+ * })) {
+ * if (work.data.type !== 'session') continue;
+ * // ...service the work...
+ * }
+ * ```
+ */
+ poller(opts: Omit): WorkPoller {
+ return new WorkPoller({ ...opts, client: this._client as Anthropic });
+ }
+
+ /**
+ * The self-hosted environment runner: poll for work, and for each claimed
+ * session set up the workdir, download the agent's skills, run the tools while
+ * heartbeating the lease, and force-stop on exit.
+ *
+ * @example
+ * ```ts
+ * // Long-running daemon — poll, serve each session, loop:
+ * await client.beta.environments.work
+ * .worker({ environmentId, environmentKey, workdir: '/workspace' })
+ * .run();
+ *
+ * // Or service one already-claimed work item (e.g. inside a sandbox spawned
+ * // by `ant worker poll --on-work`) — handleItem() reads the ANTHROPIC_* env vars:
+ * await client.beta.environments.work.worker({ workdir: '/workspace' }).handleItem();
+ * ```
+ */
+ worker(opts: Omit): EnvironmentWorker {
+ return new EnvironmentWorker({ ...opts, client: this._client as Anthropic });
+ }
+}
+
+export type BetaSelfHostedWorksPageCursor = PageCursor;
+
+/**
+ * Work data for environment health checks.
+ *
+ * This resource type is used for assessing the health of containers where work
+ * occurs. The data is opaque to users; the runner handles the health check by
+ * probing connectivity to required services.
+ */
+export interface BetaHealthCheckWorkData {
+ /**
+ * Health check identifier
+ */
+ id: string;
+
+ /**
+ * Type of work data
+ */
+ type?: 'healthcheck';
+}
+
+/**
+ * Work resource representing a unit of work in a self-hosted environment.
+ *
+ * Work items are queued when sessions are created or when long-dormant sessions
+ * receive new messages. The Environment Manager polls for work items and executes
+ * them on customer-hosted infrastructure.
+ */
+export interface BetaSelfHostedWork {
+ /**
+ * Work identifier (e.g., 'work\_...')
+ */
+ id: string;
+
+ /**
+ * RFC 3339 timestamp when work was acknowledged by Environment Manager
+ */
+ acknowledged_at: string | null;
+
+ /**
+ * RFC 3339 timestamp when work was created
+ */
+ created_at: string;
+
+ /**
+ * The actual work to be performed (session or health check)
+ */
+ data: BetaSessionWorkData | BetaHealthCheckWorkData;
+
+ /**
+ * Environment identifier this work belongs to (e.g., `env_...`)
+ */
+ environment_id: string;
+
+ /**
+ * RFC 3339 timestamp of the most recent heartbeat
+ */
+ latest_heartbeat_at: string | null;
+
+ /**
+ * User-provided metadata key-value pairs associated with this work item
+ */
+ metadata: { [key: string]: string };
+
+ /**
+ * Session instance JWT secret (only included in certain retrieval paths)
+ */
+ secret: string | null;
+
+ /**
+ * RFC 3339 timestamp when work execution started
+ */
+ started_at: string | null;
+
+ /**
+ * Current state of the work item
+ */
+ state: 'queued' | 'starting' | 'active' | 'stopping' | 'stopped';
+
+ /**
+ * RFC 3339 timestamp when stop was requested
+ */
+ stop_requested_at: string | null;
+
+ /**
+ * RFC 3339 timestamp when work execution stopped
+ */
+ stopped_at: string | null;
+
+ /**
+ * The type of object (always 'work')
+ */
+ type: 'work';
+}
+
+/**
+ * Response after recording a heartbeat for a work item.
+ */
+export interface BetaSelfHostedWorkHeartbeatResponse {
+ /**
+ * RFC 3339 timestamp of the actual heartbeat from DB
+ */
+ last_heartbeat: string;
+
+ /**
+ * Whether the heartbeat succeeded in extending the lease
+ */
+ lease_extended: boolean;
+
+ /**
+ * Current state of the work item (active/stopping/stopped)
+ */
+ state: 'queued' | 'starting' | 'active' | 'stopping' | 'stopped';
+
+ /**
+ * Effective TTL applied to the lease
+ */
+ ttl_seconds: number;
+
+ /**
+ * The type of response
+ */
+ type: 'work_heartbeat';
+}
+
+/**
+ * Response when listing work items with cursor-based pagination.
+ */
+export interface BetaSelfHostedWorkListResponse {
+ /**
+ * List of work items
+ */
+ data: Array;
+
+ /**
+ * Opaque cursor for fetching the next page of results
+ */
+ next_page: string | null;
+}
+
+/**
+ * Statistics about the work queue for an environment.
+ *
+ * Uses Redis Stream consumer group metrics for O(1) queries.
+ */
+export interface BetaSelfHostedWorkQueueStats {
+ /**
+ * Number of work items waiting to be picked up (lag from consumer group)
+ */
+ depth: number;
+
+ /**
+ * RFC 3339 timestamp of oldest item in the work stream (includes both queued and
+ * pending items), null if stream empty
+ */
+ oldest_queued_at: string | null;
+
+ /**
+ * Number of work items being processed (polled but not acknowledged)
+ */
+ pending: number;
+
+ /**
+ * The type of object
+ */
+ type: 'work_queue_stats';
+
+ /**
+ * Number of workers that have polled for work in the last 30 seconds. Requires
+ * worker_id to be sent with poll requests.
+ */
+ workers_polling: number | null;
+}
+
+/**
+ * Request to stop a work item.
+ */
+export interface BetaSelfHostedWorkStopRequest {
+ /**
+ * If true, immediately stop work without graceful shutdown
+ */
+ force?: boolean;
+}
+
+/**
+ * Request to update work item metadata.
+ */
+export interface BetaSelfHostedWorkUpdateRequest {
+ /**
+ * Metadata patch. Set a key to a string to upsert it, or to null to delete it.
+ * Omit the field to preserve existing metadata.
+ */
+ metadata: { [key: string]: string | null };
+}
+
+/**
+ * Work data for session work items.
+ *
+ * This resource type is used when work represents a session that needs to be
+ * executed in a self-hosted environment.
+ */
+export interface BetaSessionWorkData {
+ /**
+ * Session identifier (e.g., 'session\_...')
+ */
+ id: string;
+
+ /**
+ * Type of work data
+ */
+ type: 'session';
+}
+
+/**
+ * Decoded payload of the `secret` field on a self-hosted work item. The wire value
+ * of `secret` is a base64url-encoded JSON document matching this schema. SDKs that
+ * ship a runner helper for self-hosted environments use this type as the return
+ * value of their secret-decoding utility.
+ */
+export interface BetaWorkSecret {
+ /**
+ * Bearer credential the runner uses for all per-session downstream calls
+ * (heartbeat, ack, event stream, send, stop). Format: `sk-ant-req-...`.
+ */
+ sessions_token: string;
+
+ /**
+ * API base URL the runner should use for downstream calls. When absent the runner
+ * falls back to its default Anthropic endpoint.
+ */
+ api_base_url?: string;
+}
+
+export interface WorkRetrieveParams {
+ /**
+ * Path param
+ */
+ environment_id: string;
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface WorkUpdateParams {
+ /**
+ * Path param
+ */
+ environment_id: string;
+
+ /**
+ * Body param: Metadata patch. Set a key to a string to upsert it, or to null to
+ * delete it. Omit the field to preserve existing metadata.
+ */
+ metadata: { [key: string]: string | null };
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface WorkListParams extends PageCursorParams {
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface WorkAckParams {
+ /**
+ * Path param
+ */
+ environment_id: string;
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface WorkHeartbeatParams {
+ /**
+ * Path param
+ */
+ environment_id: string;
+
+ /**
+ * Query param: Desired TTL in seconds
+ */
+ desired_ttl_seconds?: number | null;
+
+ /**
+ * Query param: Expected last_heartbeat for conditional update (optimistic
+ * concurrency). Use literal 'NO_HEARTBEAT' to claim an unclaimed lease (first
+ * heartbeat). For subsequent heartbeats, echo the server's previous last_heartbeat
+ * value exactly. Returns 412 Precondition Failed if the actual value doesn't
+ * match.
+ */
+ expected_last_heartbeat?: string | null;
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface WorkPollParams {
+ /**
+ * Query param: How long to wait for work to arrive before returning. Must be 1-999
+ * in milliseconds. Defaults to non-blocking (returns immediately if no work is
+ * available).
+ */
+ block_ms?: number | null;
+
+ /**
+ * Query param: Reclaim unacknowledged work items older than this many
+ * milliseconds. If omitted, uses the default (5000ms).
+ */
+ reclaim_older_than_ms?: number | null;
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+
+ /**
+ * Header param: Unique identifier for the specific worker polling, used to track
+ * aggregated environment-level work metrics in Console
+ */
+ 'Anthropic-Worker-ID'?: string;
+}
+
+export interface WorkStatsParams {
+ /**
+ * Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export interface WorkStopParams {
+ /**
+ * Path param
+ */
+ environment_id: string;
+
+ /**
+ * Body param: If true, immediately stop work without graceful shutdown
+ */
+ force?: boolean;
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
+export { WorkPoller, type WorkPollerOptions } from '../../../lib/environments/poller';
+export { EnvironmentWorker, type EnvironmentWorkerOptions } from '../../../lib/environments/worker';
+
+Work.WorkPoller = WorkPoller;
+Work.EnvironmentWorker = EnvironmentWorker;
+
+export declare namespace Work {
+ export { WorkPoller, EnvironmentWorker };
+
+ export {
+ type BetaHealthCheckWorkData as BetaHealthCheckWorkData,
+ type BetaSelfHostedWork as BetaSelfHostedWork,
+ type BetaSelfHostedWorkHeartbeatResponse as BetaSelfHostedWorkHeartbeatResponse,
+ type BetaSelfHostedWorkListResponse as BetaSelfHostedWorkListResponse,
+ type BetaSelfHostedWorkQueueStats as BetaSelfHostedWorkQueueStats,
+ type BetaSelfHostedWorkStopRequest as BetaSelfHostedWorkStopRequest,
+ type BetaSelfHostedWorkUpdateRequest as BetaSelfHostedWorkUpdateRequest,
+ type BetaSessionWorkData as BetaSessionWorkData,
+ type BetaWorkSecret as BetaWorkSecret,
+ type BetaSelfHostedWorksPageCursor as BetaSelfHostedWorksPageCursor,
+ type WorkRetrieveParams as WorkRetrieveParams,
+ type WorkUpdateParams as WorkUpdateParams,
+ type WorkListParams as WorkListParams,
+ type WorkAckParams as WorkAckParams,
+ type WorkHeartbeatParams as WorkHeartbeatParams,
+ type WorkPollParams as WorkPollParams,
+ type WorkStatsParams as WorkStatsParams,
+ type WorkStopParams as WorkStopParams,
+ };
+}
diff --git a/src/resources/beta/index.ts b/src/resources/beta/index.ts
index b842ecaa..d3813fa7 100644
--- a/src/resources/beta/index.ts
+++ b/src/resources/beta/index.ts
@@ -9,7 +9,13 @@ export {
type BetaManagedAgentsAgentToolsetDefaultConfig,
type BetaManagedAgentsAgentToolsetDefaultConfigParams,
type BetaManagedAgentsAgentToolset20260401,
+ type BetaManagedAgentsAgentToolset20260401BashInput,
+ type BetaManagedAgentsAgentToolset20260401EditInput,
+ type BetaManagedAgentsAgentToolset20260401GlobInput,
+ type BetaManagedAgentsAgentToolset20260401GrepInput,
type BetaManagedAgentsAgentToolset20260401Params,
+ type BetaManagedAgentsAgentToolset20260401ReadInput,
+ type BetaManagedAgentsAgentToolset20260401WriteInput,
type BetaManagedAgentsAlwaysAllowPolicy,
type BetaManagedAgentsAlwaysAskPolicy,
type BetaManagedAgentsAnthropicSkill,
@@ -32,6 +38,7 @@ export {
type BetaManagedAgentsMultiagentCoordinator,
type BetaManagedAgentsMultiagentCoordinatorParams,
type BetaManagedAgentsMultiagentSelfParams,
+ type BetaManagedAgentsSessionThreadAgent,
type BetaManagedAgentsSkillParams,
type BetaManagedAgentsURLMCPServerParams,
type AgentCreateParams,
@@ -66,6 +73,8 @@ export {
type BetaLimitedNetworkParams,
type BetaPackages,
type BetaPackagesParams,
+ type BetaSelfHostedConfig,
+ type BetaSelfHostedConfigParams,
type BetaUnrestrictedNetwork,
type EnvironmentCreateParams,
type EnvironmentRetrieveParams,
@@ -74,7 +83,7 @@ export {
type EnvironmentDeleteParams,
type EnvironmentArchiveParams,
type BetaEnvironmentsPageCursor,
-} from './environments';
+} from './environments/index';
export {
Files,
type BetaFileScope,
@@ -354,9 +363,12 @@ export {
type BetaManagedAgentsOutcomeEvaluationResource,
type BetaManagedAgentsSession,
type BetaManagedAgentsSessionAgent,
+ type BetaManagedAgentsSessionAgentUpdate,
type BetaManagedAgentsSessionMultiagentCoordinator,
type BetaManagedAgentsSessionStats,
+ type BetaManagedAgentsSessionUpdatedEvent,
type BetaManagedAgentsSessionUsage,
+ type BetaManagedAgentsUserToolResultEvent,
type SessionCreateParams,
type SessionRetrieveParams,
type SessionUpdateParams,
diff --git a/src/resources/beta/messages/messages.ts b/src/resources/beta/messages/messages.ts
index b5687648..919d4762 100644
--- a/src/resources/beta/messages/messages.ts
+++ b/src/resources/beta/messages/messages.ts
@@ -1179,11 +1179,6 @@ export interface BetaCompactionBlock {
* treats these as no-ops. Empty string content is not allowed.
*/
export interface BetaCompactionBlockParam {
- /**
- * Summary of previously compacted content, or null if compaction failed
- */
- content: string | null;
-
type: 'compaction';
/**
@@ -1191,6 +1186,11 @@ export interface BetaCompactionBlockParam {
*/
cache_control?: BetaCacheControlEphemeral | null;
+ /**
+ * Summary of previously compacted content, or null if compaction failed
+ */
+ content?: string | null;
+
/**
* Opaque metadata from prior compaction, to be round-tripped verbatim
*/
diff --git a/src/resources/beta/sessions/events.ts b/src/resources/beta/sessions/events.ts
index bd7490df..27be9967 100644
--- a/src/resources/beta/sessions/events.ts
+++ b/src/resources/beta/sessions/events.ts
@@ -1,13 +1,19 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+import type { Anthropic } from '../../../client';
import { APIResource } from '../../../core/resource';
import * as BetaAPI from '../beta';
+import * as SessionsAPI from './sessions';
import { APIPromise } from '../../../core/api-promise';
import { PageCursor, type PageCursorParams, PagePromise } from '../../../core/pagination';
import { Stream } from '../../../core/streaming';
import { buildHeaders } from '../../../internal/headers';
import { RequestOptions } from '../../../internal/request-options';
import { path } from '../../../internal/utils/path';
+import {
+ SessionToolRunner,
+ type SessionToolRunnerOptions as RunnerSessionToolRunnerOptions,
+} from '../../../lib/tools/SessionToolRunner';
export class Events extends APIResource {
/**
@@ -109,6 +115,29 @@ export class Events extends APIResource {
stream: true,
}) as APIPromise>;
}
+
+ /**
+ * Attach to a session and dispatch every incoming `agent.tool_use` and
+ * `agent.custom_tool_use` event to a local tool registry, sending the matching
+ * result back (`user.tool_result` / `user.custom_tool_result`). The
+ * sessions-side counterpart to `client.beta.messages.toolRunner`: yields one
+ * entry per completed tool call so callers can observe each dispatch (and
+ * `break` to abort cleanly).
+ *
+ * @example
+ * ```ts
+ * import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node';
+ *
+ * for await (const call of client.beta.sessions.events.toolRunner(work.data.id, {
+ * tools: [...betaAgentToolset20260401({ workdir }), myTool],
+ * })) {
+ * console.log(`${call.name} -> ${call.isError ? 'error' : 'ok'}`);
+ * }
+ * ```
+ */
+ toolRunner(sessionID: string, opts: Omit): SessionToolRunner {
+ return new SessionToolRunner(sessionID, { ...opts, client: this._client as Anthropic });
+ }
}
export type BetaManagedAgentsSessionEventsPageCursor = PageCursor;
@@ -524,7 +553,8 @@ export type BetaManagedAgentsEventParams =
| BetaManagedAgentsUserInterruptEventParams
| BetaManagedAgentsUserToolConfirmationEventParams
| BetaManagedAgentsUserCustomToolResultEventParams
- | BetaManagedAgentsUserDefineOutcomeEventParams;
+ | BetaManagedAgentsUserDefineOutcomeEventParams
+ | BetaManagedAgentsUserToolResultEventParams;
/**
* Document referenced by file ID.
@@ -804,6 +834,7 @@ export interface BetaManagedAgentsSendSessionEvents {
| BetaManagedAgentsUserToolConfirmationEvent
| BetaManagedAgentsUserCustomToolResultEvent
| BetaManagedAgentsUserDefineOutcomeEvent
+ | SessionsAPI.BetaManagedAgentsUserToolResultEvent
>;
}
@@ -897,7 +928,9 @@ export type BetaManagedAgentsSessionEvent =
| BetaManagedAgentsSessionThreadStatusRunningEvent
| BetaManagedAgentsSessionThreadStatusIdleEvent
| BetaManagedAgentsSessionThreadStatusTerminatedEvent
- | BetaManagedAgentsSessionThreadStatusRescheduledEvent;
+ | SessionsAPI.BetaManagedAgentsUserToolResultEvent
+ | BetaManagedAgentsSessionThreadStatusRescheduledEvent
+ | SessionsAPI.BetaManagedAgentsSessionUpdatedEvent;
/**
* The agent is idle waiting on one or more blocking user-input events (tool
@@ -1380,7 +1413,9 @@ export type BetaManagedAgentsStreamSessionEvents =
| BetaManagedAgentsSessionThreadStatusRunningEvent
| BetaManagedAgentsSessionThreadStatusIdleEvent
| BetaManagedAgentsSessionThreadStatusTerminatedEvent
- | BetaManagedAgentsSessionThreadStatusRescheduledEvent;
+ | SessionsAPI.BetaManagedAgentsUserToolResultEvent
+ | BetaManagedAgentsSessionThreadStatusRescheduledEvent
+ | SessionsAPI.BetaManagedAgentsSessionUpdatedEvent;
/**
* Regular text content.
@@ -1744,6 +1779,38 @@ export interface BetaManagedAgentsUserToolConfirmationEventParams {
deny_message?: string | null;
}
+/**
+ * Parameters for providing the result of an agent-toolset tool execution. Only
+ * valid on `self_hosted` environments, where sandbox-routed tools are executed by
+ * the client rather than the server.
+ */
+export interface BetaManagedAgentsUserToolResultEventParams {
+ /**
+ * The id of the `agent.tool_use` event this result corresponds to, which can be
+ * found in the last `session.status_idle`
+ * [event's](https://platform.claude.com/docs/en/api/beta/sessions/events/list#beta_managed_agents_session_requires_action.event_ids)
+ * `stop_reason.event_ids` field.
+ */
+ tool_use_id: string;
+
+ type: 'user.tool_result';
+
+ /**
+ * The result content returned by the tool.
+ */
+ content?: Array<
+ | BetaManagedAgentsTextBlock
+ | BetaManagedAgentsImageBlock
+ | BetaManagedAgentsDocumentBlock
+ | BetaManagedAgentsSearchResultBlock
+ >;
+
+ /**
+ * Whether the tool execution resulted in an error.
+ */
+ is_error?: boolean | null;
+}
+
export interface EventListParams extends PageCursorParams {
/**
* Query param: Return events created after this time (exclusive).
@@ -1803,7 +1870,13 @@ export interface EventStreamParams {
betas?: Array;
}
+export { SessionToolRunner, type SessionToolRunnerOptions } from '../../../lib/tools/SessionToolRunner';
+
+Events.SessionToolRunner = SessionToolRunner;
+
export declare namespace Events {
+ export { SessionToolRunner };
+
export {
type BetaManagedAgentsAgentCustomToolUseEvent as BetaManagedAgentsAgentCustomToolUseEvent,
type BetaManagedAgentsAgentMCPToolResultEvent as BetaManagedAgentsAgentMCPToolResultEvent,
@@ -1876,6 +1949,7 @@ export declare namespace Events {
type BetaManagedAgentsUserMessageEventParams as BetaManagedAgentsUserMessageEventParams,
type BetaManagedAgentsUserToolConfirmationEvent as BetaManagedAgentsUserToolConfirmationEvent,
type BetaManagedAgentsUserToolConfirmationEventParams as BetaManagedAgentsUserToolConfirmationEventParams,
+ type BetaManagedAgentsUserToolResultEventParams as BetaManagedAgentsUserToolResultEventParams,
type BetaManagedAgentsSessionEventsPageCursor as BetaManagedAgentsSessionEventsPageCursor,
type EventListParams as EventListParams,
type EventSendParams as EventSendParams,
diff --git a/src/resources/beta/sessions/index.ts b/src/resources/beta/sessions/index.ts
index 49cf7e52..b6cdb336 100644
--- a/src/resources/beta/sessions/index.ts
+++ b/src/resources/beta/sessions/index.ts
@@ -73,6 +73,7 @@ export {
type BetaManagedAgentsUserMessageEventParams,
type BetaManagedAgentsUserToolConfirmationEvent,
type BetaManagedAgentsUserToolConfirmationEventParams,
+ type BetaManagedAgentsUserToolResultEventParams,
type EventListParams,
type EventSendParams,
type EventStreamParams,
@@ -110,9 +111,12 @@ export {
type BetaManagedAgentsOutcomeEvaluationResource,
type BetaManagedAgentsSession,
type BetaManagedAgentsSessionAgent,
+ type BetaManagedAgentsSessionAgentUpdate,
type BetaManagedAgentsSessionMultiagentCoordinator,
type BetaManagedAgentsSessionStats,
+ type BetaManagedAgentsSessionUpdatedEvent,
type BetaManagedAgentsSessionUsage,
+ type BetaManagedAgentsUserToolResultEvent,
type SessionCreateParams,
type SessionRetrieveParams,
type SessionUpdateParams,
@@ -124,7 +128,6 @@ export {
export {
Threads,
type BetaManagedAgentsSessionThread,
- type BetaManagedAgentsSessionThreadAgent,
type BetaManagedAgentsSessionThreadStats,
type BetaManagedAgentsSessionThreadStatus,
type BetaManagedAgentsSessionThreadUsage,
diff --git a/src/resources/beta/sessions/sessions.ts b/src/resources/beta/sessions/sessions.ts
index bd257bc0..077d3a08 100644
--- a/src/resources/beta/sessions/sessions.ts
+++ b/src/resources/beta/sessions/sessions.ts
@@ -77,6 +77,7 @@ import {
BetaManagedAgentsUserMessageEventParams,
BetaManagedAgentsUserToolConfirmationEvent,
BetaManagedAgentsUserToolConfirmationEventParams,
+ BetaManagedAgentsUserToolResultEventParams,
EventListParams,
EventSendParams,
EventStreamParams,
@@ -102,7 +103,6 @@ import {
import * as ThreadsAPI from './threads/threads';
import {
BetaManagedAgentsSessionThread,
- BetaManagedAgentsSessionThreadAgent,
BetaManagedAgentsSessionThreadStats,
BetaManagedAgentsSessionThreadStatus,
BetaManagedAgentsSessionThreadUsage,
@@ -596,6 +596,29 @@ export interface BetaManagedAgentsSessionAgent {
version: number;
}
+/**
+ * Mid-session agent configuration update. Only `tools` and `mcp_servers` are
+ * updatable. Full replacement: the provided array becomes the new value. To
+ * preserve existing entries, GET the session, modify the array, and POST it back.
+ */
+export interface BetaManagedAgentsSessionAgentUpdate {
+ /**
+ * Replacement MCP server list. Full replacement: the provided array becomes the
+ * new value. Send an empty array to clear; omit to preserve.
+ */
+ mcp_servers?: Array;
+
+ /**
+ * Replacement tool list. Full replacement: the provided array becomes the new
+ * value. Send an empty array to clear; omit to preserve.
+ */
+ tools?: Array<
+ | AgentsAPI.BetaManagedAgentsAgentToolset20260401Params
+ | AgentsAPI.BetaManagedAgentsMCPToolsetParams
+ | AgentsAPI.BetaManagedAgentsCustomToolParams
+ >;
+}
+
/**
* Resolved coordinator topology with full agent definitions for each roster
* member.
@@ -604,7 +627,7 @@ export interface BetaManagedAgentsSessionMultiagentCoordinator {
/**
* Full `agent` definitions the coordinator may spawn as session threads.
*/
- agents: Array;
+ agents: Array;
type: 'coordinator';
}
@@ -626,6 +649,42 @@ export interface BetaManagedAgentsSessionStats {
duration_seconds?: number;
}
+/**
+ * Emitted when an UpdateSession request changed at least one field. Carries only
+ * the fields that changed; absent fields were not part of the update. The new
+ * configuration applies from the next turn.
+ */
+export interface BetaManagedAgentsSessionUpdatedEvent {
+ /**
+ * Unique identifier for this event.
+ */
+ id: string;
+
+ /**
+ * A timestamp in RFC 3339 format
+ */
+ processed_at: string;
+
+ type: 'session.updated';
+
+ /**
+ * Resolved `agent` definition for a `session`. Snapshot of the `agent` at
+ * `session` creation time.
+ */
+ agent?: BetaManagedAgentsSessionAgent | null;
+
+ /**
+ * The session's full metadata bag after the update. Present when the update set
+ * non-empty metadata; absent when metadata was unchanged or cleared to empty.
+ */
+ metadata?: { [key: string]: string };
+
+ /**
+ * The session's new title. Present only when the update changed it.
+ */
+ title?: string | null;
+}
+
/**
* Cumulative token usage for a session across all turns.
*/
@@ -651,6 +710,54 @@ export interface BetaManagedAgentsSessionUsage {
output_tokens?: number;
}
+/**
+ * Event sent by the client providing the result of an agent-toolset tool
+ * execution. Only valid on `self_hosted` environments, where sandbox-routed tools
+ * are executed by the client rather than the server.
+ */
+export interface BetaManagedAgentsUserToolResultEvent {
+ /**
+ * Unique identifier for this event.
+ */
+ id: string;
+
+ /**
+ * The id of the `agent.tool_use` event this result corresponds to, which can be
+ * found in the last `session.status_idle`
+ * [event's](https://platform.claude.com/docs/en/api/beta/sessions/events/list#beta_managed_agents_session_requires_action.event_ids)
+ * `stop_reason.event_ids` field.
+ */
+ tool_use_id: string;
+
+ type: 'user.tool_result';
+
+ /**
+ * The result content returned by the tool.
+ */
+ content?: Array<
+ | EventsAPI.BetaManagedAgentsTextBlock
+ | EventsAPI.BetaManagedAgentsImageBlock
+ | EventsAPI.BetaManagedAgentsDocumentBlock
+ | EventsAPI.BetaManagedAgentsSearchResultBlock
+ >;
+
+ /**
+ * Whether the tool execution resulted in an error.
+ */
+ is_error?: boolean | null;
+
+ /**
+ * A timestamp in RFC 3339 format
+ */
+ processed_at?: string | null;
+
+ /**
+ * Routes this result to a subagent thread. Copy from the `agent.tool_use` event's
+ * `session_thread_id`.
+ */
+ session_thread_id?: string | null;
+}
+
export interface SessionCreateParams {
/**
* Body param: Agent identifier. Accepts the `agent` ID string, which pins the
@@ -706,6 +813,14 @@ export interface SessionRetrieveParams {
}
export interface SessionUpdateParams {
+ /**
+ * Body param: Mid-session agent configuration update. Only `tools` and
+ * `mcp_servers` are updatable. Full replacement: the provided array becomes the
+ * new value. To preserve existing entries, GET the session, modify the array, and
+ * POST it back.
+ */
+ agent?: BetaManagedAgentsSessionAgentUpdate;
+
/**
* Body param: Metadata patch. Set a key to a string to upsert it, or to null to
* delete it. Omit the field to preserve.
@@ -824,9 +939,12 @@ export declare namespace Sessions {
type BetaManagedAgentsOutcomeEvaluationResource as BetaManagedAgentsOutcomeEvaluationResource,
type BetaManagedAgentsSession as BetaManagedAgentsSession,
type BetaManagedAgentsSessionAgent as BetaManagedAgentsSessionAgent,
+ type BetaManagedAgentsSessionAgentUpdate as BetaManagedAgentsSessionAgentUpdate,
type BetaManagedAgentsSessionMultiagentCoordinator as BetaManagedAgentsSessionMultiagentCoordinator,
type BetaManagedAgentsSessionStats as BetaManagedAgentsSessionStats,
+ type BetaManagedAgentsSessionUpdatedEvent as BetaManagedAgentsSessionUpdatedEvent,
type BetaManagedAgentsSessionUsage as BetaManagedAgentsSessionUsage,
+ type BetaManagedAgentsUserToolResultEvent as BetaManagedAgentsUserToolResultEvent,
type BetaManagedAgentsSessionsPageCursor as BetaManagedAgentsSessionsPageCursor,
type SessionCreateParams as SessionCreateParams,
type SessionRetrieveParams as SessionRetrieveParams,
@@ -909,6 +1027,7 @@ export declare namespace Sessions {
type BetaManagedAgentsUserMessageEventParams as BetaManagedAgentsUserMessageEventParams,
type BetaManagedAgentsUserToolConfirmationEvent as BetaManagedAgentsUserToolConfirmationEvent,
type BetaManagedAgentsUserToolConfirmationEventParams as BetaManagedAgentsUserToolConfirmationEventParams,
+ type BetaManagedAgentsUserToolResultEventParams as BetaManagedAgentsUserToolResultEventParams,
type BetaManagedAgentsSessionEventsPageCursor as BetaManagedAgentsSessionEventsPageCursor,
type EventListParams as EventListParams,
type EventSendParams as EventSendParams,
@@ -935,7 +1054,6 @@ export declare namespace Sessions {
export {
Threads as Threads,
type BetaManagedAgentsSessionThread as BetaManagedAgentsSessionThread,
- type BetaManagedAgentsSessionThreadAgent as BetaManagedAgentsSessionThreadAgent,
type BetaManagedAgentsSessionThreadStats as BetaManagedAgentsSessionThreadStats,
type BetaManagedAgentsSessionThreadStatus as BetaManagedAgentsSessionThreadStatus,
type BetaManagedAgentsSessionThreadUsage as BetaManagedAgentsSessionThreadUsage,
diff --git a/src/resources/beta/sessions/threads/index.ts b/src/resources/beta/sessions/threads/index.ts
index ba1ec109..a103e291 100644
--- a/src/resources/beta/sessions/threads/index.ts
+++ b/src/resources/beta/sessions/threads/index.ts
@@ -4,7 +4,6 @@ export { Events, type EventListParams, type EventStreamParams } from './events';
export {
Threads,
type BetaManagedAgentsSessionThread,
- type BetaManagedAgentsSessionThreadAgent,
type BetaManagedAgentsSessionThreadStats,
type BetaManagedAgentsSessionThreadStatus,
type BetaManagedAgentsSessionThreadUsage,
diff --git a/src/resources/beta/sessions/threads/threads.ts b/src/resources/beta/sessions/threads/threads.ts
index 5a9f8ad7..d304247d 100644
--- a/src/resources/beta/sessions/threads/threads.ts
+++ b/src/resources/beta/sessions/threads/threads.ts
@@ -121,7 +121,7 @@ export interface BetaManagedAgentsSessionThread {
* at thread creation time. The multiagent roster is not repeated here; read it
* from `Session.agent`.
*/
- agent: BetaManagedAgentsSessionThreadAgent;
+ agent: AgentsAPI.BetaManagedAgentsSessionThreadAgent;
/**
* A timestamp in RFC 3339 format
@@ -166,40 +166,6 @@ export interface BetaManagedAgentsSessionThread {
usage: BetaManagedAgentsSessionThreadUsage | null;
}
-/**
- * Resolved `agent` definition for a single `session_thread`. Snapshot of the agent
- * at thread creation time. The multiagent roster is not repeated here; read it
- * from `Session.agent`.
- */
-export interface BetaManagedAgentsSessionThreadAgent {
- id: string;
-
- description: string | null;
-
- mcp_servers: Array;
-
- /**
- * Model identifier and configuration.
- */
- model: AgentsAPI.BetaManagedAgentsModelConfig;
-
- name: string;
-
- skills: Array;
-
- system: string | null;
-
- tools: Array<
- | AgentsAPI.BetaManagedAgentsAgentToolset20260401
- | AgentsAPI.BetaManagedAgentsMCPToolset
- | AgentsAPI.BetaManagedAgentsCustomTool
- >;
-
- type: 'agent';
-
- version: number;
-}
-
/**
* Timing statistics for a session thread.
*/
@@ -287,7 +253,9 @@ export type BetaManagedAgentsStreamSessionThreadEvents =
| EventsAPI.BetaManagedAgentsSessionThreadStatusRunningEvent
| EventsAPI.BetaManagedAgentsSessionThreadStatusIdleEvent
| EventsAPI.BetaManagedAgentsSessionThreadStatusTerminatedEvent
- | EventsAPI.BetaManagedAgentsSessionThreadStatusRescheduledEvent;
+ | SessionsAPI.BetaManagedAgentsUserToolResultEvent
+ | EventsAPI.BetaManagedAgentsSessionThreadStatusRescheduledEvent
+ | SessionsAPI.BetaManagedAgentsSessionUpdatedEvent;
export interface ThreadRetrieveParams {
/**
@@ -325,7 +293,6 @@ Threads.Events = Events;
export declare namespace Threads {
export {
type BetaManagedAgentsSessionThread as BetaManagedAgentsSessionThread,
- type BetaManagedAgentsSessionThreadAgent as BetaManagedAgentsSessionThreadAgent,
type BetaManagedAgentsSessionThreadStats as BetaManagedAgentsSessionThreadStats,
type BetaManagedAgentsSessionThreadStatus as BetaManagedAgentsSessionThreadStatus,
type BetaManagedAgentsSessionThreadUsage as BetaManagedAgentsSessionThreadUsage,
diff --git a/src/resources/beta/skills/index.ts b/src/resources/beta/skills/index.ts
index ca00aaec..9ad90e43 100644
--- a/src/resources/beta/skills/index.ts
+++ b/src/resources/beta/skills/index.ts
@@ -22,5 +22,6 @@ export {
type VersionRetrieveParams,
type VersionListParams,
type VersionDeleteParams,
+ type VersionDownloadParams,
type VersionListResponsesPageCursor,
} from './versions';
diff --git a/src/resources/beta/skills/skills.ts b/src/resources/beta/skills/skills.ts
index 13737de2..02d7f4e1 100644
--- a/src/resources/beta/skills/skills.ts
+++ b/src/resources/beta/skills/skills.ts
@@ -8,6 +8,7 @@ import {
VersionCreateResponse,
VersionDeleteParams,
VersionDeleteResponse,
+ VersionDownloadParams,
VersionListParams,
VersionListResponse,
VersionListResponsesPageCursor,
@@ -380,5 +381,6 @@ export declare namespace Skills {
type VersionRetrieveParams as VersionRetrieveParams,
type VersionListParams as VersionListParams,
type VersionDeleteParams as VersionDeleteParams,
+ type VersionDownloadParams as VersionDownloadParams,
};
}
diff --git a/src/resources/beta/skills/versions.ts b/src/resources/beta/skills/versions.ts
index 1d259d3d..dc164656 100644
--- a/src/resources/beta/skills/versions.ts
+++ b/src/resources/beta/skills/versions.ts
@@ -127,6 +127,35 @@ export class Versions extends APIResource {
]),
});
}
+
+ /**
+ * Download a skill version's content as a zip archive.
+ *
+ * @example
+ * ```ts
+ * const response = await client.beta.skills.versions.download(
+ * 'version',
+ * { skill_id: 'skill_id' },
+ * );
+ *
+ * const content = await response.blob();
+ * console.log(content);
+ * ```
+ */
+ download(version: string, params: VersionDownloadParams, options?: RequestOptions): APIPromise {
+ const { skill_id, betas } = params;
+ return this._client.get(path`/v1/skills/${skill_id}/versions/${version}/content?beta=true`, {
+ ...options,
+ headers: buildHeaders([
+ {
+ 'anthropic-beta': [...(betas ?? []), 'skills-2025-10-02'].toString(),
+ Accept: 'application/binary',
+ },
+ options?.headers,
+ ]),
+ __binaryResponse: true,
+ });
+ }
}
export type VersionListResponsesPageCursor = PageCursor;
@@ -359,6 +388,20 @@ export interface VersionDeleteParams {
betas?: Array;
}
+export interface VersionDownloadParams {
+ /**
+ * Path param: Unique identifier for the skill.
+ *
+ * The format and length of IDs may change over time.
+ */
+ skill_id: string;
+
+ /**
+ * Header param: Optional header to specify the beta version(s) you want to use.
+ */
+ betas?: Array;
+}
+
export declare namespace Versions {
export {
type VersionCreateResponse as VersionCreateResponse,
@@ -370,5 +413,6 @@ export declare namespace Versions {
type VersionRetrieveParams as VersionRetrieveParams,
type VersionListParams as VersionListParams,
type VersionDeleteParams as VersionDeleteParams,
+ type VersionDownloadParams as VersionDownloadParams,
};
}
diff --git a/src/tools/agent-toolset/fs-util.ts b/src/tools/agent-toolset/fs-util.ts
new file mode 100644
index 00000000..10eb5a91
--- /dev/null
+++ b/src/tools/agent-toolset/fs-util.ts
@@ -0,0 +1,154 @@
+/**
+ * Shared, Node-only filesystem helpers for the agent toolset's file tools:
+ * path confinement (symlink-aware), an atomic write, and language-independent
+ * error messages. Kept out of `node.ts` so the tool implementations stay focused
+ * and these helpers can be reused by every file tool.
+ */
+
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import { randomUUID } from 'node:crypto';
+import { ToolError } from '../../lib/tools/ToolError';
+
+/** Mode for directories the file tools create — not world-writable under a 0 umask. */
+export const DIR_CREATE_MODE = 0o755;
+/** Mode for files the file tools create. */
+export const FILE_CREATE_MODE = 0o644;
+
+/** `realpath` `p`, or return `p` unchanged when it cannot be resolved. */
+async function realpathOrSelf(p: string): Promise {
+ try {
+ return await fs.realpath(p);
+ } catch {
+ return p;
+ }
+}
+
+/**
+ * Fully resolve `abs`: `realpath` the longest existing ancestor and re-append
+ * the rest, but never re-append a component that is itself a symlink — read the
+ * link and continue from its target instead. This handles paths being created
+ * (write/edit) without letting a symlink leaf (e.g. a dangling one pointing
+ * outside a confinement root) slip through unresolved.
+ */
+export async function canonicalize(abs: string): Promise {
+ const tail: string[] = [];
+ let prefix = abs;
+ for (;;) {
+ let real: string;
+ try {
+ real = await fs.realpath(prefix);
+ } catch {
+ let isLink = false;
+ try {
+ isLink = (await fs.lstat(prefix)).isSymbolicLink();
+ } catch {
+ /* prefix truly doesn't exist (ENOENT) — fall through and walk up */
+ }
+ if (isLink) {
+ // Resolve the symlink ourselves and retry; `tail` (the part below it)
+ // still applies to the link's target.
+ prefix = path.resolve(path.dirname(prefix), await fs.readlink(prefix));
+ continue;
+ }
+ const parent = path.dirname(prefix);
+ if (parent === prefix) return abs; // walked past the FS root without a hit
+ tail.push(path.basename(prefix));
+ prefix = parent;
+ continue;
+ }
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
+ }
+}
+
+/**
+ * Resolve `p` and confine it to `root`.
+ *
+ * Unless `allowOutside` is set, absolute inputs are rejected and the
+ * **canonical** path is returned — every symlink in `p` (including the leaf,
+ * even a dangling one) is resolved before the confinement check, and the
+ * resolved path is what the caller then operates on, so a symlink inside `root`
+ * that points outside it can neither pass the check nor be followed afterwards.
+ *
+ * Residual TOCTOU: a component could still be swapped for a symlink between this
+ * call and the eventual `fs` operation. Closing that fully needs per-component
+ * `O_NOFOLLOW`/`openat`, which Node does not expose ergonomically; this is why a
+ * sandbox is still recommended for the toolset as a whole.
+ */
+export async function confineToRoot(
+ root: string,
+ p: string,
+ opts?: { allowOutside?: boolean },
+): Promise {
+ const allowOutside = opts?.allowOutside ?? false;
+ if (path.isAbsolute(p)) {
+ if (!allowOutside) {
+ throw new ToolError(`absolute path ${JSON.stringify(p)} not permitted`);
+ }
+ return path.resolve(p);
+ }
+ const realRoot = await realpathOrSelf(path.resolve(root));
+ const abs = path.resolve(realRoot, p);
+ if (allowOutside) return abs;
+ const real = await canonicalize(abs);
+ const rootSep = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
+ if (real !== realRoot && !real.startsWith(rootSep)) {
+ throw new ToolError(`path ${JSON.stringify(p)} escapes workdir`);
+ }
+ return real;
+}
+
+/**
+ * Atomically write `content` to `targetPath`: write a sibling temp file, fsync
+ * it, then rename over the target. The rename is atomic on most filesystems, so
+ * a crash mid-write never leaves the target half-written.
+ */
+export async function atomicWriteFile(targetPath: string, content: string): Promise {
+ const dir = path.dirname(targetPath);
+ const tempPath = path.join(dir, `.tmp-${process.pid}-${randomUUID()}`);
+ let handle: fs.FileHandle | undefined;
+ try {
+ handle = await fs.open(tempPath, 'wx', FILE_CREATE_MODE);
+ await handle.writeFile(content, 'utf-8');
+ await handle.sync();
+ await handle.close();
+ handle = undefined;
+ await fs.rename(tempPath, targetPath);
+ } catch (err) {
+ if (handle) await handle.close().catch(() => {});
+ await fs.unlink(tempPath).catch(() => {});
+ throw err;
+ }
+}
+
+/**
+ * Map a thrown filesystem error to a consistent, language-independent message,
+ * so the model sees the same wording regardless of the runtime (Node's raw
+ * `ENOENT: no such file...` text would otherwise leak through). Falls back to
+ * the raw error message for codes we don't special-case.
+ */
+export function fsErrorMessage(err: unknown, file: string): string {
+ const code = (err as { code?: string } | null)?.code;
+ switch (code) {
+ case 'ENOENT':
+ return `${file}: no such file or directory`;
+ case 'EACCES':
+ case 'EPERM':
+ return `${file}: permission denied`;
+ case 'ENOTDIR':
+ return `${file}: not a directory`;
+ case 'EISDIR':
+ return `${file}: is a directory`;
+ case 'ELOOP':
+ return `${file}: too many levels of symbolic links`;
+ case 'ENAMETOOLONG':
+ return `${file}: file name too long`;
+ case 'ENOSPC':
+ return `${file}: no space left on device`;
+ case 'EMFILE':
+ case 'ENFILE':
+ return `${file}: too many open files`;
+ default:
+ return `${file}: ${err instanceof Error ? err.message : String(err)}`;
+ }
+}
diff --git a/src/tools/agent-toolset/node.ts b/src/tools/agent-toolset/node.ts
new file mode 100644
index 00000000..e41c38b3
--- /dev/null
+++ b/src/tools/agent-toolset/node.ts
@@ -0,0 +1,787 @@
+/**
+ * Node implementation of the `agent_toolset_20260401` tools — `bash`, `read`,
+ * `write`, `edit`, `glob`, `grep` — plus the workdir/skills
+ * {@link AgentToolContext}.
+ *
+ * This mirrors `@anthropic-ai/sdk/tools/memory/node`: it is the explicit,
+ * Node-only entry point for these implementations. Importing it pulls in
+ * `node:child_process`, `node:fs`, etc., so it is kept separate from the rest of
+ * the SDK — depending on it is an opt-in.
+ *
+ * **Node 22+ is required** for this module: the `glob` tool uses the native
+ * `fs.glob`, added in Node 22. The rest of the SDK still supports Node 18+; only
+ * the agent toolset has this requirement.
+ *
+ * The result of {@link betaAgentToolset20260401} is a plain `BetaRunnableTool[]`;
+ * hand it to any tool runner — `client.beta.messages.toolRunner({ …, tools })`
+ * for the Messages API, or `client.beta.sessions.events.toolRunner({ …, tools })`
+ * for a managed-agents session:
+ *
+ * ```ts
+ * import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node';
+ *
+ * const tools = betaAgentToolset20260401({ workdir: '/work' });
+ * const tools2 = betaAgentToolset20260401({ workdir: '/work' }).filter((t) => t.name !== 'bash');
+ * ```
+ *
+ * Trust model: the file tools confine to `workdir` (symlink-aware) and are safe
+ * without a sandbox; `bash` is unrestricted and should run inside one. See
+ * {@link AgentToolContext}.
+ */
+
+import * as fs from 'node:fs/promises';
+import * as fssync from 'node:fs';
+import * as path from 'node:path';
+import * as cp from 'node:child_process';
+import * as crypto from 'node:crypto';
+import * as readline from 'node:readline';
+import type { Anthropic } from '../../client';
+import { AnthropicError } from '../../core/error';
+import type { BetaRunnableTool } from '../../lib/tools/BetaRunnableTool';
+import { ToolError } from '../../lib/tools/ToolError';
+import { betaTool } from '../../helpers/beta/json-schema';
+import { promiseWithResolvers } from '../../internal/utils/promise';
+import { atomicWriteFile, confineToRoot, DIR_CREATE_MODE, fsErrorMessage } from './fs-util';
+
+export { setupSkills, resolveSkillVersion, extractSkillArchive } from './skills';
+
+const BASH_OUTPUT_LIMIT = 100 * 1024;
+const BASH_DEFAULT_TIMEOUT_MS = 120_000;
+const READ_MAX_BYTES = 256 * 1024;
+// `edit` reads the whole file before rewriting it, so it carries the same
+// OOM/FIFO exposure as `read`. Kept as a symmetric alias for now; the cap value
+// (and reject-vs-truncate behaviour) is intentionally a separate knob pending
+// validation with the CMA team.
+const EDIT_MAX_BYTES = READ_MAX_BYTES;
+const GREP_OUTPUT_LIMIT = 100 * 1024;
+const GREP_MAX_LINE_LENGTH = 2000;
+const GLOB_RESULT_LIMIT = 200;
+
+const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
+
+// `fs.glob` is Node 22+. `@types/node` may still target an older line, so the
+// surface this module uses is typed locally rather than relying on the ambient
+// declaration. At runtime this module requires Node 22 (see the file header).
+type GlobFn = (
+ pattern: string,
+ options: {
+ cwd?: string;
+ withFileTypes?: boolean;
+ exclude?: (entry: fssync.Dirent) => boolean;
+ },
+) => AsyncIterable;
+const fsGlob = (fs as unknown as { glob: GlobFn }).glob;
+
+/**
+ * Workdir + path-policy for the agent toolset.
+ *
+ * Trust model — two tiers:
+ *
+ * - The file tools ({@link betaReadTool}, {@link betaWriteTool},
+ * {@link betaEditTool}, {@link betaGlobTool}, {@link betaGrepTool}) confine to
+ * `workdir` unless `unrestrictedPaths` is set. {@link resolvePath}
+ * canonicalizes the target (resolving every symlink, including the leaf)
+ * before the check *and* returns that canonical path for the operation, so a
+ * symlink inside the workdir that points outside it neither passes the check
+ * nor gets followed afterwards — this is a real boundary, not a lexical hint
+ * (modulo the residual TOCTOU noted on {@link resolvePath}).
+ * - {@link betaBashTool} runs an unrestricted `/bin/bash` and cannot be
+ * confined. Run it — and, for defense in depth, the whole toolset — inside a
+ * sandbox the host controls (e.g. a self-hosted environment runner).
+ */
+export interface AgentToolContext {
+ /** Base directory for resolving relative tool paths. */
+ workdir: string;
+ /**
+ * When `false` (default), the file tools reject absolute paths and paths
+ * that escape `workdir` (symlinks resolved). Does **not** constrain
+ * {@link betaBashTool}.
+ */
+ unrestrictedPaths?: boolean;
+ /**
+ * Anthropic client. Optional — the bare toolset needs no client; it is only
+ * used by `setupSkills`, which (together with {@link AgentToolContext.sessionId})
+ * fetches the session's resolved agent and downloads each of its skills into
+ * `{workdir}/skills//`.
+ */
+ client?: Anthropic;
+ /** Session whose agent's skills `setupSkills` should download. */
+ sessionId?: string;
+ /**
+ * Optional environment for the bash subprocess. When unset, the bash tool
+ * inherits the process environment with the runner's `ANTHROPIC_*`
+ * credentials scrubbed. When provided, it FULLY REPLACES that default
+ * environment — the mapping is used verbatim and is NOT merged with or added
+ * to the scrubbed process environment. To keep the defaults plus extra vars,
+ * build the combined mapping yourself before passing it.
+ */
+ env?: NodeJS.ProcessEnv;
+}
+
+/**
+ * Returns the `agent_toolset_20260401` implementations bound to `ctx`. The
+ * result is a plain array of `BetaRunnableTool`; filter or extend it before
+ * handing it to a tool runner:
+ *
+ * ```ts
+ * const tools = [...betaAgentToolset20260401(ctx), myCustomTool];
+ * const tools = betaAgentToolset20260401(ctx).filter((t) => t.name !== 'grep');
+ * ```
+ *
+ * Concurrency note: `client.beta.sessions.events.toolRunner` dispatches a
+ * session's tool calls serially (the sessions API delivers one `agent.tool_use`
+ * at a time). `client.beta.messages.toolRunner` runs a turn's `tool.run` calls
+ * via `Promise.all`. The toolset below is safe under either model —
+ * {@link betaBashTool} serializes its persistent shell internally and the FS
+ * tools are independent per call — but {@link betaEditTool}/{@link betaWriteTool}
+ * cannot synchronize concurrent writes to the *same* file across processes, so a
+ * multi-edit turn touching one path is still subject to inherent FS lost-update
+ * races. Custom tools that close over mutable state should do their own queueing.
+ */
+export function betaAgentToolset20260401(ctx: AgentToolContext): BetaRunnableTool[] {
+ return [
+ betaBashTool(ctx),
+ betaReadTool(ctx),
+ betaWriteTool(ctx),
+ betaEditTool(ctx),
+ betaGlobTool(ctx),
+ betaGrepTool(ctx),
+ ];
+}
+
+/**
+ * Resolve `p` relative to `ctx.workdir`. Unless `unrestrictedPaths` is set,
+ * absolute inputs are rejected and the **canonical** path is returned — every
+ * symlink in `p` (including the leaf, even a dangling one) is resolved before
+ * the workdir check, and the resolved path is what the tool then operates on, so
+ * a symlink inside the workdir that points outside it can neither pass the check
+ * nor be followed afterwards. See the trust model on {@link AgentToolContext}.
+ *
+ * Residual TOCTOU: a component could still be swapped for a symlink between this
+ * call and the eventual `fs` operation. Closing that fully needs per-component
+ * `O_NOFOLLOW`/`openat`, which Node does not expose ergonomically; the same
+ * residual exposure exists in `tools/memory/node` and is why a sandbox is still
+ * recommended for the toolset as a whole.
+ */
+export function resolvePath(ctx: AgentToolContext, p: string): Promise {
+ return confineToRoot(ctx.workdir, p, { allowOutside: ctx.unrestrictedPaths ?? false });
+}
+
+// ---- bash ----------------------------------------------------------------
+
+/**
+ * Build the environment for the spawned bash shell. The runner process holds
+ * Anthropic credentials in `ANTHROPIC_*` env vars — the API key, the auth token,
+ * and the per-work session token among them. `bash` runs an unrestricted shell,
+ * so any command the agent runs could read those straight out of `process.env`;
+ * strip the whole `ANTHROPIC_*` namespace from the child's environment.
+ * Everything else (PATH, HOME, locale, …) is passed through unchanged.
+ *
+ * Passing an explicit `env` to {@link AgentToolContext} does NOT add to this
+ * default — it FULLY REPLACES it. The provided mapping becomes the entire bash
+ * environment verbatim; nothing here is merged in, so callers who want the
+ * scrubbed process environment plus extras must build that mapping themselves.
+ */
+function scrubbedShellEnv(): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = {};
+ for (const [key, value] of Object.entries(process.env)) {
+ if (key.startsWith('ANTHROPIC_')) continue;
+ env[key] = value;
+ }
+ return env;
+}
+
+/**
+ * A persistent /bin/bash process. State (cwd, env, background jobs) survives
+ * across exec() calls. Uses pipes rather than a PTY so input is never echoed.
+ */
+export class BashSession {
+ #proc: cp.ChildProcessWithoutNullStreams;
+ #buf = '';
+ #truncated = false;
+ #closed = false;
+ // While a command is in flight, the resolver to fire once its sentinel lands
+ // in `#buf` (or once the shell dies). Event-driven: no polling loop.
+ #waiting: { sentinel: string; resolve: () => void } | null = null;
+
+ constructor(dir: string, env: NodeJS.ProcessEnv = scrubbedShellEnv()) {
+ this.#proc = cp.spawn('/bin/bash', ['--noprofile', '--norc'], {
+ cwd: dir,
+ // `env` is the full base environment (the scrubbed process env by
+ // default, or the verbatim replacement from `AgentToolContext.env`).
+ // PS1/PS2/TERM are shell-control settings BashSession always applies so
+ // the pipe-based sentinel exec parsing works — not part of the
+ // user-facing environment.
+ env: { ...env, PS1: '', PS2: '', TERM: 'dumb' },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ detached: true,
+ });
+ this.#proc.stdout.setEncoding('utf8');
+ this.#proc.stderr.setEncoding('utf8');
+ this.#proc.stdout.on('data', (d: string) => this.#append(d));
+ this.#proc.stderr.on('data', (d: string) => this.#append(d));
+ this.#proc.once('close', () => {
+ this.#closed = true;
+ // Wake any in-flight exec so it fails fast instead of waiting for its deadline.
+ const w = this.#waiting;
+ this.#waiting = null;
+ w?.resolve();
+ });
+ }
+
+ /** Whether the underlying shell process has exited. */
+ get closed(): boolean {
+ return this.#closed;
+ }
+
+ // Cap the buffer during accumulation so a command that streams unboundedly
+ // can't OOM the runner. Keeps the tail so the sentinel stays detectable.
+ // Also resolves the in-flight exec the instant its sentinel is buffered.
+ #append(d: string): void {
+ this.#buf += d;
+ if (this.#buf.length > BASH_OUTPUT_LIMIT) {
+ this.#buf = this.#buf.slice(this.#buf.length - BASH_OUTPUT_LIMIT);
+ this.#truncated = true;
+ }
+ if (this.#waiting && this.#buf.indexOf(this.#waiting.sentinel) >= 0) {
+ const w = this.#waiting;
+ this.#waiting = null;
+ w.resolve();
+ }
+ }
+
+ async exec(
+ command: string,
+ opts: { timeoutMs?: number; signal?: AbortSignal | null | undefined } = {},
+ ): Promise<{ output: string; exitCode: number }> {
+ if (this.#closed) {
+ throw new AnthropicError('bash session terminated');
+ }
+ const timeoutMs = opts.timeoutMs ?? BASH_DEFAULT_TIMEOUT_MS;
+ const signal = opts.signal;
+ if (signal?.aborted) {
+ throw new AnthropicError('bash command aborted');
+ }
+ this.#buf = '';
+ this.#truncated = false;
+ // Per-call nonce so a command that prints a fixed marker can't spoof the
+ // exit-code framing. The `''` split keeps the literal out of what we write
+ // to stdin — only the shell's printf reassembles it.
+ const sentinel = `__ANT_CMD_${crypto.randomUUID()}_DONE__`;
+ const sentinelSplit = `${sentinel.slice(0, 8)}''${sentinel.slice(8)}`;
+ // &1; printf '\\n${sentinelSplit}%d\\n' $?\n`;
+ this.#proc.stdin.write(wrapped);
+
+ if (this.#buf.indexOf(sentinel) < 0) {
+ // Park until the sentinel lands, the deadline passes, the caller aborts,
+ // or the shell dies — whichever comes first. `#append` (and the `close`
+ // handler) resolve `sentinelSeen`; the deadline / abort reject.
+ const { promise: sentinelSeen, resolve } = promiseWithResolvers();
+ this.#waiting = { sentinel, resolve };
+ let timer: ReturnType | undefined;
+ let onAbort: (() => void) | undefined;
+ try {
+ await Promise.race([
+ sentinelSeen,
+ new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new AnthropicError(`bash command timed out after ${timeoutMs}ms`)),
+ timeoutMs,
+ );
+ }),
+ new Promise((_, reject) => {
+ if (!signal) return;
+ onAbort = () => reject(new AnthropicError('bash command aborted'));
+ signal.addEventListener('abort', onAbort, { once: true });
+ }),
+ ]);
+ } finally {
+ if (timer) clearTimeout(timer);
+ if (onAbort && signal) signal.removeEventListener('abort', onAbort);
+ this.#waiting = null;
+ }
+ }
+
+ const idx = this.#buf.indexOf(sentinel);
+ if (idx < 0) {
+ // The shell closed (or was killed) before emitting the sentinel.
+ throw new AnthropicError('bash session terminated');
+ }
+ const tail = this.#buf.slice(idx + sentinel.length);
+ const m = tail.match(/^(-?\d+)/);
+ const exitCode = m ? parseInt(m[1]!, 10) : -1;
+ let out = this.#buf.slice(0, idx).replace(ANSI_RE, '').replace(/\n+$/, '');
+ if (this.#truncated) {
+ out = `[output truncated]\n${out}`;
+ }
+ return { output: out, exitCode };
+ }
+
+ close(): void {
+ if (this.#closed) return;
+ this.#closed = true;
+ const w = this.#waiting;
+ this.#waiting = null;
+ w?.resolve();
+ this.#proc.stdout.destroy();
+ this.#proc.stderr.destroy();
+ this.#proc.stdin.destroy();
+ try {
+ // Negative PID targets the process group so foreground jobs (e.g. a
+ // hung sleep) die with the shell.
+ process.kill(-this.#proc.pid!, 'SIGKILL');
+ } catch {
+ this.#proc.kill('SIGKILL');
+ }
+ this.#proc.unref();
+ }
+}
+
+export function betaBashTool(ctx: AgentToolContext): BetaRunnableTool {
+ let session: BashSession | undefined;
+ // Concurrent run() callers chain onto this promise so writes to the shared
+ // shell's stdin can't interleave (which would corrupt the sentinel-match
+ // exit-code parsing in BashSession.exec). Each call replaces `tail` with a
+ // promise that resolves only after its own exec settles.
+ let tail: Promise = Promise.resolve();
+ return betaTool({
+ name: 'bash',
+ description: 'Run a bash command in a persistent shell. State (cwd, env vars) persists across calls.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ command: { type: 'string', description: 'The command to run' },
+ restart: { type: 'boolean', description: 'Restart the persistent shell before running' },
+ timeout_ms: { type: 'integer', description: 'Per-call timeout in milliseconds' },
+ },
+ },
+ run: async ({ command, restart, timeout_ms }, context) => {
+ const prev = tail;
+ const gate = promiseWithResolvers();
+ tail = gate.promise;
+ // Swallow prior rejections — earlier callers got their own error path;
+ // we just need to wait for the shell to be free.
+ try {
+ await prev;
+ } catch {
+ // ignore
+ }
+ try {
+ if (restart) {
+ session?.close();
+ session = undefined;
+ }
+ if (!command) {
+ if (restart) return 'bash session restarted';
+ throw new ToolError('bash: command is required');
+ }
+ session ??= new BashSession(ctx.workdir, ctx.env);
+ try {
+ const { output, exitCode } = await session.exec(command, {
+ timeoutMs: timeout_ms ?? BASH_DEFAULT_TIMEOUT_MS,
+ signal: context?.signal,
+ });
+ if (exitCode !== 0) throw new ToolError(output || `exit ${exitCode}`);
+ return output;
+ } catch (e) {
+ if (e instanceof ToolError) throw e;
+ // Timeout, abort, or terminated: the still-running command will emit
+ // a stale sentinel, so discard this session and let the next call
+ // start fresh.
+ session.close();
+ session = undefined;
+ throw new ToolError(`bash: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ } finally {
+ gate.resolve();
+ }
+ },
+ close: () => {
+ session?.close();
+ session = undefined;
+ },
+ });
+}
+
+// ---- fs ------------------------------------------------------------------
+
+export function betaReadTool(ctx: AgentToolContext): BetaRunnableTool {
+ return betaTool({
+ name: 'read',
+ description: 'Read a UTF-8 text file relative to the workdir.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ file_path: { type: 'string' },
+ view_range: {
+ type: 'array',
+ items: { type: 'integer' },
+ description: '[start_line, end_line] 1-indexed inclusive',
+ },
+ },
+ required: ['file_path'],
+ },
+ run: async ({ file_path, view_range }) => {
+ if (!file_path) throw new ToolError('read: file_path is required');
+ const abs = await resolvePath(ctx, file_path);
+ let data: string;
+ try {
+ // stat() before any open(): the size cap stops a multi-GB file from
+ // OOM'ing the runner, and isFile() rejects FIFOs/devices/dirs without
+ // opening them (open() on an unconnected FIFO blocks indefinitely).
+ const st = await fs.stat(abs);
+ if (!st.isFile()) {
+ throw new ToolError(`read: ${file_path} is not a regular file`);
+ }
+ if (st.size > READ_MAX_BYTES) {
+ throw new ToolError(
+ `read: ${file_path} is ${st.size} bytes, exceeds ${READ_MAX_BYTES}-byte limit. ` +
+ 'Use bash (head/tail/sed) to read a slice.',
+ );
+ }
+ data = await fs.readFile(abs, 'utf8');
+ } catch (e) {
+ if (e instanceof ToolError) throw e;
+ throw new ToolError(`read: ${fsErrorMessage(e, file_path)}`);
+ }
+ if (!view_range) return data;
+ if (view_range.length !== 2) throw new ToolError('read: view_range must be [start_line, end_line]');
+ const [startLine, endLine] = view_range as [number, number];
+ const lines = data.split('\n');
+ const start = Math.max(0, startLine - 1);
+ const end = endLine > 0 ? endLine : lines.length;
+ return lines.slice(start, end).join('\n');
+ },
+ });
+}
+
+export function betaWriteTool(ctx: AgentToolContext): BetaRunnableTool {
+ return betaTool({
+ name: 'write',
+ description: 'Write a UTF-8 text file relative to the workdir, creating parent directories as needed.',
+ inputSchema: {
+ type: 'object',
+ properties: { file_path: { type: 'string' }, content: { type: 'string' } },
+ required: ['file_path', 'content'],
+ },
+ run: async ({ file_path, content }) => {
+ if (!file_path) throw new ToolError('write: file_path is required');
+ const abs = await resolvePath(ctx, file_path);
+ try {
+ await fs.mkdir(path.dirname(abs), { recursive: true, mode: DIR_CREATE_MODE });
+ await atomicWriteFile(abs, content ?? '');
+ } catch (e) {
+ throw new ToolError(`write: ${fsErrorMessage(e, file_path)}`);
+ }
+ return `wrote ${Buffer.byteLength(content ?? '')} bytes to ${file_path}`;
+ },
+ });
+}
+
+export function betaEditTool(ctx: AgentToolContext): BetaRunnableTool {
+ return betaTool({
+ name: 'edit',
+ description:
+ 'Replace old_string with new_string in a file. old_string must be unique unless replace_all.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ file_path: { type: 'string' },
+ old_string: { type: 'string' },
+ new_string: { type: 'string' },
+ replace_all: { type: 'boolean' },
+ },
+ required: ['file_path', 'old_string', 'new_string'],
+ },
+ run: async ({ file_path, old_string, new_string, replace_all }) => {
+ if (!file_path) throw new ToolError('edit: file_path is required');
+ if (!old_string) throw new ToolError('edit: old_string is required');
+ const abs = await resolvePath(ctx, file_path);
+ let data: string;
+ try {
+ // stat() before any open() — same guard as `read`: the size cap stops a
+ // multi-GB file from OOM'ing the runner, and isFile() rejects
+ // FIFOs/devices/dirs without opening them (open() on an unconnected FIFO
+ // blocks indefinitely). The edit path is model-controlled, so it needs
+ // the same bound `read` already has.
+ const st = await fs.stat(abs);
+ if (!st.isFile()) {
+ throw new ToolError(`edit: ${file_path} is not a regular file`);
+ }
+ if (st.size > EDIT_MAX_BYTES) {
+ throw new ToolError(
+ `edit: ${file_path} is ${st.size} bytes, exceeds ${EDIT_MAX_BYTES}-byte limit. ` +
+ 'Use bash (sed/awk) to edit a large file.',
+ );
+ }
+ data = await fs.readFile(abs, 'utf8');
+ } catch (e) {
+ if (e instanceof ToolError) throw e;
+ throw new ToolError(`edit: ${fsErrorMessage(e, file_path)}`);
+ }
+ const count = data.split(old_string).length - 1;
+ if (count === 0) throw new ToolError(`edit: old_string not found in ${file_path}`);
+ let updated: string;
+ if (replace_all) {
+ updated = data.split(old_string).join(new_string);
+ } else {
+ if (count > 1)
+ throw new ToolError(`edit: old_string appears ${count} times in ${file_path} (must be unique)`);
+ // Callback form so `$&`/`$1`/`` $` `` in new_string are inserted
+ // literally instead of expanded as replacement patterns.
+ updated = data.replace(old_string, () => new_string);
+ }
+ try {
+ await atomicWriteFile(abs, updated);
+ } catch (e) {
+ throw new ToolError(`edit: write: ${fsErrorMessage(e, file_path)}`);
+ }
+ return `edited ${file_path} (${replace_all ? count : 1} replacement(s))`;
+ },
+ });
+}
+
+// ---- search --------------------------------------------------------------
+
+export function betaGlobTool(ctx: AgentToolContext): BetaRunnableTool {
+ return betaTool({
+ name: 'glob',
+ description:
+ 'Match files under the workdir against a glob pattern. Results are mtime-sorted, newest first.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ pattern: { type: 'string' },
+ path: { type: 'string', description: 'Directory to search in. Defaults to the workdir.' },
+ },
+ required: ['pattern'],
+ },
+ run: async ({ pattern, path: searchPath }) => {
+ if (!pattern) throw new ToolError('glob: pattern is required');
+ let root = path.resolve(ctx.workdir);
+ let pat = pattern;
+ if (path.isAbsolute(pattern)) {
+ if (!ctx.unrestrictedPaths) throw new ToolError('glob: absolute pattern not permitted');
+ root = path.parse(pattern).root;
+ pat = path.relative(root, pattern);
+ } else if (searchPath) {
+ root = await resolvePath(ctx, searchPath);
+ }
+ // A `..` in the *pattern itself* (e.g. `../../*`) walks `fs.glob` out of
+ // the search root — this is separate from the `searchPath` confinement
+ // above, which only covers the path argument. Reject it outright when the
+ // toolset is confined.
+ if (!ctx.unrestrictedPaths && pat.split(/[\\/]/).includes('..')) {
+ throw new ToolError('glob: ".." is not permitted in the pattern');
+ }
+ const matches: { path: string; mtime: number }[] = [];
+ try {
+ // Native `fs.glob` (Node 22+). `exclude` prunes the noisy dirs the
+ // legacy walker skipped; only regular files are collected.
+ for await (const entry of fsGlob(pat, {
+ cwd: root,
+ withFileTypes: true,
+ exclude: (d) => d.name === '.git' || d.name === 'node_modules',
+ })) {
+ if (!entry.isFile()) continue;
+ const full = path.join(entry.parentPath, entry.name);
+ // Defense in depth: drop any match that resolved outside the search
+ // root (e.g. via a symlinked directory in the tree) when confined.
+ if (!ctx.unrestrictedPaths && !isWithin(root, full)) continue;
+ let mtime = 0;
+ try {
+ mtime = (await fs.stat(full)).mtimeMs;
+ } catch {
+ // unreadable — keep it in the list with mtime 0
+ }
+ matches.push({ path: full, mtime });
+ }
+ } catch (e) {
+ throw new ToolError(`glob: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ if (matches.length === 0) return 'no matches';
+ matches.sort((a, b) => b.mtime - a.mtime);
+ return matches
+ .slice(0, GLOB_RESULT_LIMIT)
+ .map((m) => m.path)
+ .join('\n');
+ },
+ });
+}
+
+export function betaGrepTool(ctx: AgentToolContext): BetaRunnableTool {
+ return betaTool({
+ name: 'grep',
+ description: 'Search file contents for a regex. Uses ripgrep if available, otherwise a built-in walker.',
+ inputSchema: {
+ type: 'object',
+ properties: { pattern: { type: 'string' }, path: { type: 'string' } },
+ required: ['pattern'],
+ },
+ run: async ({ pattern, path: p }, context) => {
+ if (!pattern) throw new ToolError('grep: pattern is required');
+ let searchPath = path.resolve(ctx.workdir);
+ if (p) searchPath = await resolvePath(ctx, p);
+ const rg = await findRg();
+ return rg ?
+ runRipgrep(rg, pattern, searchPath, context?.signal)
+ : runWalkGrep(pattern, searchPath, context?.signal);
+ },
+ });
+}
+
+function runRipgrep(
+ rg: string,
+ pattern: string,
+ searchPath: string,
+ signal?: AbortSignal | null | undefined,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const proc = cp.spawn(rg, ['-n', '--no-heading', '-e', pattern, '--', searchPath], {
+ ...(signal ? { signal } : {}),
+ });
+ let out = '';
+ let errOut = '';
+ let truncated = false;
+ proc.stdout.on('data', (d) => {
+ if (truncated) return;
+ out += d;
+ if (out.length > GREP_OUTPUT_LIMIT) {
+ truncated = true;
+ out = out.slice(0, GREP_OUTPUT_LIMIT);
+ proc.kill('SIGKILL');
+ }
+ });
+ proc.stderr.on('data', (d) => (errOut += d));
+ proc.on('close', (code) => {
+ if (signal?.aborted) return reject(new ToolError('grep: aborted'));
+ if (truncated) return resolve(out + `\n[output truncated at ${GREP_OUTPUT_LIMIT} bytes]`);
+ if (code === 0) return resolve(out);
+ if (code === 1) return resolve('no matches');
+ reject(new ToolError(`grep: rg failed: ${errOut || `exit ${code}`}`));
+ });
+ proc.on('error', (e) => {
+ if (signal?.aborted) return reject(new ToolError('grep: aborted'));
+ reject(new ToolError(`grep: rg failed: ${e.message}`));
+ });
+ });
+}
+
+async function runWalkGrep(
+ pattern: string,
+ root: string,
+ signal?: AbortSignal | null | undefined,
+): Promise {
+ let re: RegExp;
+ try {
+ re = new RegExp(pattern);
+ } catch (e) {
+ throw new ToolError(`grep: invalid regex: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ const hits: string[] = [];
+ let budget = GREP_OUTPUT_LIMIT;
+ const push = (line: string): boolean => {
+ budget -= line.length + 1;
+ if (budget < 0) {
+ hits.push(`[output truncated at ${GREP_OUTPUT_LIMIT} bytes]`);
+ return false;
+ }
+ hits.push(line);
+ return true;
+ };
+ const stat = await fs.stat(root).catch(() => null);
+ if (stat?.isFile()) {
+ await grepFile(root, re, push);
+ } else {
+ await walk(root, '', (rel) => grepFile(path.join(root, rel), re, push), signal);
+ }
+ if (signal?.aborted) throw new ToolError('grep: aborted');
+ if (hits.length === 0) return 'no matches';
+ return hits.join('\n');
+}
+
+async function grepFile(file: string, re: RegExp, push: (line: string) => boolean): Promise {
+ const stream = fssync.createReadStream(file, { encoding: 'utf8' });
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+ let i = 0;
+ try {
+ for await (const line of rl) {
+ i++;
+ // Cap line length: `pattern` is model-supplied and JS regexes backtrack,
+ // so a pathological pattern against a very long line is a ReDoS.
+ if (line.length > GREP_MAX_LINE_LENGTH) continue;
+ if (re.test(line) && !push(`${file}:${i}:${line}`)) return false;
+ }
+ } catch {
+ // unreadable / binary
+ } finally {
+ stream.destroy();
+ }
+ return true;
+}
+
+// ---- utils ---------------------------------------------------------------
+
+/** True when `p` is `root` itself or lexically contained within it. */
+function isWithin(root: string, p: string): boolean {
+ const rel = path.relative(root, p);
+ return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
+}
+
+const WALK_MAX_DEPTH = 40;
+const WALK_MAX_ENTRIES = 50_000;
+
+/**
+ * Bounded recursive walk. `fn` may return `false` to abort. Only real
+ * directories are descended into and only real files are handed to `fn` —
+ * symlinks (and devices/fifos/sockets) are skipped entirely so a symlink inside
+ * the root cannot be followed out of it.
+ */
+async function walk(
+ root: string,
+ rel: string,
+ fn: (rel: string) => boolean | void | Promise,
+ signal?: AbortSignal | null | undefined,
+): Promise {
+ let remaining = WALK_MAX_ENTRIES;
+ async function inner(rel: string, depth: number): Promise {
+ if (depth > WALK_MAX_DEPTH) return true;
+ if (signal?.aborted) return false;
+ let entries: fssync.Dirent[];
+ try {
+ entries = await fs.readdir(path.join(root, rel), { withFileTypes: true });
+ } catch {
+ return true;
+ }
+ for (const e of entries) {
+ if (e.name === '.git' || e.name === 'node_modules') continue;
+ if (remaining-- <= 0) return false;
+ if (signal?.aborted) return false;
+ const childRel = rel ? path.join(rel, e.name) : e.name;
+ if (e.isDirectory()) {
+ if (!(await inner(childRel, depth + 1))) return false;
+ } else if (e.isFile()) {
+ if ((await fn(childRel)) === false) return false;
+ }
+ // Symlinks, devices, fifos and sockets are intentionally skipped.
+ }
+ return true;
+ }
+ await inner(rel, 0);
+}
+
+async function findRg(): Promise {
+ const dirs = (process.env['PATH'] ?? '').split(path.delimiter);
+ for (const d of dirs) {
+ const candidate = path.join(d, 'rg');
+ try {
+ await fs.access(candidate, fssync.constants.X_OK);
+ return candidate;
+ } catch {
+ // not here
+ }
+ }
+ return null;
+}
diff --git a/src/tools/agent-toolset/skills.ts b/src/tools/agent-toolset/skills.ts
new file mode 100644
index 00000000..04ca7a47
--- /dev/null
+++ b/src/tools/agent-toolset/skills.ts
@@ -0,0 +1,256 @@
+/**
+ * Node-only skill plumbing for the agent toolset: downloading a session
+ * agent's skills into the workdir and extracting the archives. Kept in its own
+ * file because it is a distinct concern from the tool implementations in
+ * `node.ts` — distinct enough, and large enough, to review on its own.
+ */
+
+import * as fs from 'node:fs/promises';
+import * as fssync from 'node:fs';
+import * as path from 'node:path';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { Readable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
+import type { Anthropic } from '../../client';
+import { AnthropicError } from '../../core/error';
+import { loggerFor } from '../../internal/utils/log';
+import { DIR_CREATE_MODE } from './fs-util';
+import type { AgentToolContext } from './node';
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Download the session agent's skills into `{ctx.workdir}/skills//`.
+ *
+ * No-op (returns a no-op cleanup) unless both `ctx.client` and `ctx.sessionId`
+ * are set. Looks up the session's resolved agent and, for each skill, fetches
+ * its files via `client.beta.skills.versions.download` and extracts the archive
+ * (a zip or tar.* archive) into a directory named after the skill. A failure on
+ * one skill is logged and does not block the others. Call this before starting
+ * the session tool runner (e.g. right after the bash session / workdir is
+ * ready).
+ *
+ * Returns a cleanup function that removes the skill directories this call
+ * created — call it once the work item is done so downloaded skills do not
+ * accumulate in the workdir across sessions.
+ */
+export async function setupSkills(ctx: AgentToolContext): Promise<() => Promise> {
+ const { client, sessionId } = ctx;
+ if (!client || !sessionId) return async () => {};
+ const log = loggerFor(client);
+ const session = await client.beta.sessions.retrieve(sessionId);
+ const skillsRoot = path.resolve(ctx.workdir, 'skills');
+ const created: string[] = [];
+ for (const skill of session.agent.skills) {
+ try {
+ const versionId = await resolveSkillVersion(client, skill.skill_id, skill.version);
+ const version = await client.beta.skills.versions.retrieve(versionId, { skill_id: skill.skill_id });
+ // The directory is the skill's name, reduced to a single safe path
+ // component so a hostile name can't escape `skillsRoot`.
+ let dirname = path.basename(version.name.trim());
+ if (dirname === '' || dirname === '.' || dirname === '..') dirname = skill.skill_id;
+ const dest = path.resolve(skillsRoot, dirname);
+ if (dest !== skillsRoot && !dest.startsWith(skillsRoot + path.sep)) {
+ log.warn('skill name escapes the skills dir; skipping', {
+ component: 'agent-tool-context',
+ name: version.name,
+ });
+ continue;
+ }
+ const resp = await client.beta.skills.versions.download(versionId, { skill_id: skill.skill_id });
+ await fs.rm(dest, { recursive: true, force: true });
+ await fs.mkdir(dest, { recursive: true, mode: DIR_CREATE_MODE });
+ created.push(dest);
+ await extractSkillArchive(resp, dest);
+ log.info('downloaded skill', {
+ component: 'agent-tool-context',
+ skill_id: skill.skill_id,
+ version: versionId,
+ dest,
+ });
+ } catch (e) {
+ log.warn('failed to download skill', {
+ component: 'agent-tool-context',
+ skill_id: skill.skill_id,
+ error: String(e),
+ });
+ }
+ }
+ return async () => {
+ for (const dest of created) {
+ await fs.rm(dest, { recursive: true, force: true }).catch((e) => {
+ log.warn('failed to clean up skill', { component: 'agent-tool-context', dest, error: String(e) });
+ });
+ }
+ };
+}
+
+/**
+ * Resolve `version` to the concrete numeric timestamp the
+ * `/v1/skills/{id}/versions/{version}` endpoints require — `session.agent.skills[].version`
+ * can be an alias such as `"latest"`, which those endpoints reject. Numeric
+ * versions pass through unchanged.
+ */
+export async function resolveSkillVersion(
+ client: Anthropic,
+ skillId: string,
+ version: string,
+): Promise {
+ if (/^\d+$/.test(version)) return version;
+ let newest: string | undefined;
+ for await (const v of client.beta.skills.versions.list(skillId)) {
+ if (/^\d+$/.test(v.version) && (newest === undefined || BigInt(v.version) > BigInt(newest))) {
+ newest = v.version;
+ }
+ }
+ if (newest === undefined) {
+ throw new AnthropicError(
+ `skill ${JSON.stringify(skillId)} has no concrete version to resolve ${JSON.stringify(
+ version,
+ )} against`,
+ );
+ }
+ return newest;
+}
+
+/** Reject archive members that are absolute or contain a `..` component. */
+function assertSafeMemberNames(names: string): void {
+ for (const raw of names.split('\n')) {
+ const entry = raw.trim();
+ if (!entry) continue;
+ if (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) {
+ throw new AnthropicError(`refusing to extract unsafe archive member: ${entry}`);
+ }
+ }
+}
+
+/**
+ * Reject archives that contain anything other than regular files and
+ * directories. The type char is the first byte of each `ls`-style line emitted
+ * by `tar -tvf` / `unzip -Z`: `-` file, `d` dir, `l` symlink, `h` hardlink,
+ * `b`/`c` device, `p` fifo, `s` socket. A symlink/hardlink member is how an
+ * archive escapes its extraction dir even when no name contains `..`.
+ */
+function assertNoSpecialMembers(verboseListing: string): void {
+ for (const line of verboseListing.split('\n')) {
+ const type = line.trimStart()[0];
+ if (type === 'l' || type === 'h' || type === 'b' || type === 'c' || type === 'p' || type === 's') {
+ throw new AnthropicError('refusing to extract archive with symlink/hardlink/device member');
+ }
+ }
+}
+
+/**
+ * Run an archive CLI (`unzip` for zip archives, `tar` for everything else),
+ * returning its stdout. Both binaries must be on `PATH`; a missing one would
+ * otherwise surface as an opaque `ENOENT` spawn failure, so it is turned into a
+ * clear, specific error naming the missing command.
+ */
+async function runArchiveTool(cmd: 'unzip' | 'tar', args: string[]): Promise {
+ try {
+ const { stdout } = await execFileAsync(cmd, args);
+ return stdout;
+ } catch (e) {
+ if (e != null && typeof e === 'object' && (e as { code?: unknown }).code === 'ENOENT') {
+ throw new AnthropicError(
+ `skill extraction requires the \`${cmd}\` command, but it was not found on PATH`,
+ );
+ }
+ throw e;
+ }
+}
+
+/**
+ * The single top-level directory shared by every entry in a newline-separated
+ * archive listing, or `''` if entries don't all live under one common
+ * directory. Skill bundles are packaged wrapped in one directory named after
+ * the skill (e.g. `pdf/SKILL.md`, `pdf/scripts/...`); the extractor strips it
+ * so contents land directly in the skill's dir instead of a redundant nested
+ * `//` level. A flat or multi-root archive yields `''`.
+ */
+function archiveTopDir(listing: string): string {
+ let top: string | undefined;
+ let nested = false;
+ for (const raw of listing.split('\n')) {
+ // Drop `.` / empty segments so a `./pdf/...`-style listing (e.g. from
+ // `tar -C dir .`) is treated the same as `pdf/...`.
+ const parts = raw
+ .trim()
+ .split('/')
+ .filter((p) => p !== '' && p !== '.');
+ if (parts.length === 0) continue;
+ const first = parts[0]!;
+ if (top === undefined) top = first;
+ else if (first !== top) return '';
+ if (parts.length > 1) nested = true;
+ }
+ return top !== undefined && nested ? top : '';
+}
+
+/**
+ * Extract a skill download (a zip or tar.* archive) into `dest`. Streams the
+ * response body straight to a temp file beside `dest` (so the whole archive is
+ * never buffered in memory — skills can contain large binaries), then shells out
+ * to `unzip`/`tar` — consistent with the rest of the toolset, which already
+ * invokes `bash` and `rg`. Both `unzip` and `tar` must be available on `PATH`; a
+ * missing binary surfaces as a clear error (see {@link runArchiveTool}). Refuses
+ * any member that would escape `dest` (zip-slip / tar-slip), including
+ * symlink/hardlink members: skill archives come from the API, but skills can be
+ * third-party.
+ *
+ * The skill bundle's single wrapper directory is stripped: the archive is
+ * extracted into a staging dir and the wrapper's contents are promoted into
+ * `dest`, so files land at `dest/SKILL.md` rather than a doubled
+ * `dest//SKILL.md` (`unzip` has no `--strip-components`, so this is
+ * done uniformly by staging + promote rather than per-tool flags).
+ */
+export async function extractSkillArchive(resp: Response, dest: string): Promise {
+ const tmp = path.join(dest, `.skill-archive-${process.pid}-${Date.now()}`);
+ if (!resp.body) {
+ throw new AnthropicError('skill download response had no body');
+ }
+ await pipeline(
+ Readable.fromWeb(resp.body as Parameters[0]),
+ fssync.createWriteStream(tmp),
+ );
+ const stage = path.join(path.dirname(dest), `.skill-stage-${process.pid}-${Date.now()}`);
+ try {
+ // Sniff the first bytes: zip archives start with "PK\x03\x04"; treat
+ // anything else as a tar.* archive (`tar -xf` autodetects gzip/bzip2/xz).
+ const head = await readHead(tmp, 4);
+ const isZip =
+ head.length >= 4 && head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04;
+ const archiveCmd = isZip ? 'unzip' : 'tar';
+ // List first, validate, then extract — `tar`/`unzip` will happily write a
+ // `../` member (or follow a symlink member) outside `-C`/`-d` otherwise.
+ const listing = await runArchiveTool(archiveCmd, isZip ? ['-Z1', tmp] : ['-tf', tmp]);
+ assertSafeMemberNames(listing);
+ assertNoSpecialMembers(await runArchiveTool(archiveCmd, isZip ? ['-Z', tmp] : ['-tvf', tmp]));
+ const top = archiveTopDir(listing);
+ await fs.mkdir(stage, { recursive: true, mode: DIR_CREATE_MODE });
+ await runArchiveTool(archiveCmd, isZip ? ['-oq', tmp, '-d', stage] : ['-xf', tmp, '-C', stage]);
+ // Promote the wrapper's contents (or the staged tree itself, if the
+ // archive wasn't wrapped) into the already-created empty `dest`. `stage`
+ // is a sibling of `dest`, so each rename stays on one filesystem.
+ const srcRoot = top ? path.join(stage, top) : stage;
+ for (const entry of await fs.readdir(srcRoot)) {
+ await fs.rename(path.join(srcRoot, entry), path.join(dest, entry));
+ }
+ } finally {
+ await fs.rm(tmp, { force: true });
+ await fs.rm(stage, { recursive: true, force: true });
+ }
+}
+
+/** Read the first `n` bytes of `file`. */
+async function readHead(file: string, n: number): Promise {
+ const handle = await fs.open(file, 'r');
+ try {
+ const buf = Buffer.alloc(n);
+ const { bytesRead } = await handle.read(buf, 0, n, 0);
+ return buf.subarray(0, bytesRead);
+ } finally {
+ await handle.close();
+ }
+}
diff --git a/src/version.ts b/src/version.ts
index b75d8d52..1b7c9366 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const VERSION = '0.96.0'; // x-release-please-version
+export const VERSION = '0.97.0'; // x-release-please-version
diff --git a/tests/api-resources/beta/environments.test.ts b/tests/api-resources/beta/environments/environments.test.ts
similarity index 99%
rename from tests/api-resources/beta/environments.test.ts
rename to tests/api-resources/beta/environments/environments.test.ts
index 7cc1b295..6b24d4d0 100644
--- a/tests/api-resources/beta/environments.test.ts
+++ b/tests/api-resources/beta/environments/environments.test.ts
@@ -42,6 +42,7 @@ describe('resource environments', () => {
},
description: 'Python environment with data-analysis packages.',
metadata: { foo: 'string' },
+ scope: 'organization',
betas: ['message-batches-2024-09-24'],
});
});
diff --git a/tests/api-resources/beta/environments/work.test.ts b/tests/api-resources/beta/environments/work.test.ts
new file mode 100644
index 00000000..fd2ac2a4
--- /dev/null
+++ b/tests/api-resources/beta/environments/work.test.ts
@@ -0,0 +1,194 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+import Anthropic from '@anthropic-ai/sdk';
+
+const client = new Anthropic({
+ apiKey: 'my-anthropic-api-key',
+ baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010',
+});
+
+describe('resource work', () => {
+ test('retrieve: only required params', async () => {
+ const responsePromise = client.beta.environments.work.retrieve('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ });
+ 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('retrieve: required and optional params', async () => {
+ const response = await client.beta.environments.work.retrieve('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ betas: ['message-batches-2024-09-24'],
+ });
+ });
+
+ test('update: only required params', async () => {
+ const responsePromise = client.beta.environments.work.update('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ metadata: { foo: 'string' },
+ });
+ 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('update: required and optional params', async () => {
+ const response = await client.beta.environments.work.update('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ metadata: { foo: 'string' },
+ betas: ['message-batches-2024-09-24'],
+ });
+ });
+
+ // buildURL drops path-level query params (SDK-4349)
+ test.skip('list', async () => {
+ const responsePromise = client.beta.environments.work.list('env_011CZkZ9X2dpNyB7HsEFoRfW');
+ 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);
+ });
+
+ // buildURL drops path-level query params (SDK-4349)
+ test.skip('list: request options and params are passed correctly', async () => {
+ // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error
+ await expect(
+ client.beta.environments.work.list(
+ 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ {
+ limit: 1,
+ page: 'page',
+ betas: ['message-batches-2024-09-24'],
+ },
+ { path: '/_stainless_unknown_path' },
+ ),
+ ).rejects.toThrow(Anthropic.NotFoundError);
+ });
+
+ test('ack: only required params', async () => {
+ const responsePromise = client.beta.environments.work.ack('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ });
+ 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('ack: required and optional params', async () => {
+ const response = await client.beta.environments.work.ack('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ betas: ['message-batches-2024-09-24'],
+ });
+ });
+
+ test('heartbeat: only required params', async () => {
+ const responsePromise = client.beta.environments.work.heartbeat('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ });
+ 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('heartbeat: required and optional params', async () => {
+ const response = await client.beta.environments.work.heartbeat('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ desired_ttl_seconds: 0,
+ expected_last_heartbeat: 'expected_last_heartbeat',
+ betas: ['message-batches-2024-09-24'],
+ });
+ });
+
+ test('poll', async () => {
+ const responsePromise = client.beta.environments.work.poll('env_011CZkZ9X2dpNyB7HsEFoRfW');
+ 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('poll: request options and params are passed correctly', async () => {
+ // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error
+ await expect(
+ client.beta.environments.work.poll(
+ 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ {
+ block_ms: 1,
+ reclaim_older_than_ms: 1,
+ betas: ['message-batches-2024-09-24'],
+ 'Anthropic-Worker-ID': 'Anthropic-Worker-ID',
+ },
+ { path: '/_stainless_unknown_path' },
+ ),
+ ).rejects.toThrow(Anthropic.NotFoundError);
+ });
+
+ // buildURL drops path-level query params (SDK-4349)
+ test.skip('stats', async () => {
+ const responsePromise = client.beta.environments.work.stats('env_011CZkZ9X2dpNyB7HsEFoRfW');
+ 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);
+ });
+
+ // buildURL drops path-level query params (SDK-4349)
+ test.skip('stats: request options and params are passed correctly', async () => {
+ // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error
+ await expect(
+ client.beta.environments.work.stats(
+ 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ { betas: ['message-batches-2024-09-24'] },
+ { path: '/_stainless_unknown_path' },
+ ),
+ ).rejects.toThrow(Anthropic.NotFoundError);
+ });
+
+ test('stop: only required params', async () => {
+ const responsePromise = client.beta.environments.work.stop('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ });
+ 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('stop: required and optional params', async () => {
+ const response = await client.beta.environments.work.stop('work_id', {
+ environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW',
+ force: true,
+ betas: ['message-batches-2024-09-24'],
+ });
+ });
+});
diff --git a/tests/api-resources/beta/skills/versions.test.ts b/tests/api-resources/beta/skills/versions.test.ts
index 1c07d8c7..cf5d6a37 100644
--- a/tests/api-resources/beta/skills/versions.test.ts
+++ b/tests/api-resources/beta/skills/versions.test.ts
@@ -94,4 +94,11 @@ describe('resource versions', () => {
betas: ['message-batches-2024-09-24'],
});
});
+
+ test('download: required and optional params', async () => {
+ const response = await client.beta.skills.versions.download('version', {
+ skill_id: 'skill_id',
+ betas: ['message-batches-2024-09-24'],
+ });
+ });
});
diff --git a/tests/lib/environments/poller-iter.test.ts b/tests/lib/environments/poller-iter.test.ts
new file mode 100644
index 00000000..25ebc37a
--- /dev/null
+++ b/tests/lib/environments/poller-iter.test.ts
@@ -0,0 +1,379 @@
+import { WorkPoller, POLL_BLOCK_MS } from '@anthropic-ai/sdk/lib/environments';
+import { APIError } from '@anthropic-ai/sdk/core/error';
+
+// Minimal fake `client.beta.environments.work` resource. Tests script
+// poll/ack/stop responses; we record how each was called for assertions.
+//
+// The poller now scopes a sub-client via `copyClientForHelper` and
+// routes every poll/ack/stop through it, so the fake exposes
+// `withOptions` and `_options`; the sub-client reuses the same recorder
+// so call-shape assertions still work as before.
+
+interface RecordedCall {
+ method: 'poll' | 'ack' | 'stop';
+ args: unknown[];
+}
+
+interface PollResponse {
+ type: 'work' | 'null' | 'throw';
+ value?: unknown;
+ err?: unknown;
+}
+
+function makeFakeClient(opts: {
+ poll: PollResponse[];
+ ack?: { type: 'ok' | 'throw'; err?: unknown }[];
+ stop?: { type: 'ok' | 'throw'; err?: unknown }[];
+}) {
+ const calls: RecordedCall[] = [];
+ const withOptionsCalls: Array> = [];
+ let pollIdx = 0;
+ let ackIdx = 0;
+ let stopIdx = 0;
+ const fake: Record = {
+ // `copyClientForHelper` reads `_options.defaultHeaders` to merge
+ // them onto the sub-client; provide a minimal shape so the util doesn't
+ // throw on the fake.
+ _options: { defaultHeaders: undefined },
+ withOptions: (options: Record) => {
+ withOptionsCalls.push(options);
+ return fake;
+ },
+ beta: {
+ environments: {
+ work: {
+ poll: (...args: unknown[]) => {
+ calls.push({ method: 'poll', args });
+ const r = opts.poll[pollIdx++] ?? { type: 'null' };
+ if (r.type === 'throw') return Promise.reject(r.err);
+ if (r.type === 'null') return Promise.resolve(null);
+ return Promise.resolve(r.value);
+ },
+ ack: (...args: unknown[]) => {
+ calls.push({ method: 'ack', args });
+ const r = opts.ack?.[ackIdx++] ?? { type: 'ok' };
+ if (r.type === 'throw') return Promise.reject(r.err);
+ return Promise.resolve({});
+ },
+ stop: (...args: unknown[]) => {
+ calls.push({ method: 'stop', args });
+ const r = opts.stop?.[stopIdx++] ?? { type: 'ok' };
+ if (r.type === 'throw') return Promise.reject(r.err);
+ return Promise.resolve({});
+ },
+ },
+ },
+ },
+ };
+ return { client: fake as never, calls, withOptionsCalls };
+}
+
+function makeWork(id = 'work_1', dataType = 'session'): Record {
+ return {
+ id,
+ state: 'queued',
+ environment_id: 'env_1',
+ data: { type: dataType, id: 'sesn_1' },
+ };
+}
+
+describe('WorkPoller', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('yields the work item and posts ack before yield, stop after consumer body', async () => {
+ const work = makeWork();
+ const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] });
+
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ });
+
+ const items: Array<{ workId: string }> = [];
+ const consumer = (async () => {
+ for await (const work of iter) {
+ items.push({ workId: work.id });
+ // Stop after first item.
+ iter.abort();
+ break;
+ }
+ })();
+
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+
+ expect(items).toEqual([{ workId: 'work_1' }]);
+
+ const order = calls.map((c) => c.method);
+ // poll then ack then stop, in order.
+ expect(order.slice(0, 3)).toEqual(['poll', 'ack', 'stop']);
+ });
+
+ test('scopes a sub-client to the environment key and routes all calls through it', async () => {
+ // Previously every poll/ack/stop carried a per-request `Authorization:
+ // Bearer ` header. The poller now scopes a sub-client via
+ // `copyClientForHelper`, which clears the parent's `apiKey` so
+ // `X-Api-Key` doesn't ride alongside the bearer credential on the wire.
+ const work = makeWork();
+ const { client, withOptionsCalls } = makeFakeClient({ poll: [{ type: 'work', value: work }] });
+
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ });
+
+ const consumer = (async () => {
+ for await (const _ of iter) {
+ iter.abort();
+ break;
+ }
+ })();
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+
+ expect(withOptionsCalls).toContainEqual(
+ expect.objectContaining({ apiKey: null, authToken: 'env_key', credentials: undefined }),
+ );
+ });
+
+ test('stops the work item even when the consumer breaks immediately', async () => {
+ const work = makeWork();
+ const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] });
+
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ });
+
+ const consumer = (async () => {
+ for await (const _ of iter) {
+ iter.abort();
+ break;
+ }
+ })();
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+
+ expect(calls.some((c) => c.method === 'stop')).toBe(true);
+ });
+
+ test('backs off on poll errors using setTimeout-based delay', async () => {
+ const err = Object.assign(Object.create(APIError.prototype) as APIError, { status: 503 });
+ const work = makeWork();
+ const { client, calls } = makeFakeClient({
+ poll: [
+ { type: 'throw', err },
+ { type: 'work', value: work },
+ ],
+ });
+
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ });
+
+ const consumer = (async () => {
+ for await (const _ of iter) {
+ iter.abort();
+ break;
+ }
+ })();
+ // Drive past the initial 1s backoff window.
+ await jest.advanceTimersByTimeAsync(2_000);
+ await consumer;
+
+ const polls = calls.filter((c) => c.method === 'poll').length;
+ expect(polls).toBe(2);
+ });
+
+ test('honors externally provided AbortSignal', async () => {
+ const abortCtl = new AbortController();
+ const { client } = makeFakeClient({ poll: [] });
+
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ signal: abortCtl.signal,
+ });
+
+ const consumer = (async () => {
+ for await (const _ of iter) {
+ // never yields — poll always returns null
+ }
+ })();
+ abortCtl.abort();
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+ // Reaching here without timeout is the assertion.
+ expect(iter.signal.aborted).toBe(true);
+ });
+
+ test('throws when iterated twice', async () => {
+ const { client } = makeFakeClient({ poll: [] });
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ });
+ iter.abort();
+ // First iteration drains the (already aborted) generator.
+ for await (const _ of iter) {
+ // unreachable
+ }
+ // Second iteration must throw the consumed-stream guard.
+ await expect(
+ (async () => {
+ for await (const _ of iter) {
+ // unreachable
+ }
+ })(),
+ ).rejects.toThrow(/consumed/);
+ });
+
+ test('default poll passes block_ms = POLL_BLOCK_MS and no reclaim_older_than_ms', async () => {
+ const work = makeWork();
+ const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] });
+
+ const iter = new WorkPoller({ client, environmentId: 'env_1', environmentKey: 'env_key' });
+ const consumer = (async () => {
+ for await (const _ of iter) {
+ iter.abort();
+ break;
+ }
+ })();
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+
+ const poll = calls.find((c) => c.method === 'poll')!;
+ const params = poll.args[1] as Record;
+ expect(params['block_ms']).toBe(POLL_BLOCK_MS);
+ expect('reclaim_older_than_ms' in params).toBe(false);
+ });
+
+ test('blockMs: null omits block_ms; reclaimOlderThanMs is forwarded', async () => {
+ const work = makeWork();
+ const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] });
+
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ blockMs: null,
+ reclaimOlderThanMs: 7_000,
+ });
+ const consumer = (async () => {
+ for await (const _ of iter) {
+ iter.abort();
+ break;
+ }
+ })();
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+
+ const poll = calls.find((c) => c.method === 'poll')!;
+ const params = poll.args[1] as Record;
+ expect('block_ms' in params).toBe(false);
+ expect(params['reclaim_older_than_ms']).toBe(7_000);
+ });
+
+ test('explicit blockMs is forwarded as block_ms', async () => {
+ const work = makeWork();
+ const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] });
+
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ blockMs: 250,
+ });
+ const consumer = (async () => {
+ for await (const _ of iter) {
+ iter.abort();
+ break;
+ }
+ })();
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+
+ const poll = calls.find((c) => c.method === 'poll')!;
+ expect((poll.args[1] as Record)['block_ms']).toBe(250);
+ });
+
+ test('drain ends iteration when the queue is empty instead of long-polling', async () => {
+ // poll always returns null; without `drain` this would loop forever.
+ const { client, calls } = makeFakeClient({ poll: [{ type: 'null' }] });
+
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ drain: true,
+ });
+
+ const items: unknown[] = [];
+ const consumer = (async () => {
+ for await (const work of iter) items.push(work);
+ })();
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+
+ expect(items).toEqual([]);
+ // Returned after a single empty poll — no backoff sleep, no re-poll.
+ expect(calls.filter((c) => c.method === 'poll').length).toBe(1);
+ });
+
+ test('without drain, an empty queue is retried (default long-poll behavior)', async () => {
+ const work = makeWork();
+ const { client, calls } = makeFakeClient({
+ poll: [{ type: 'null' }, { type: 'work', value: work }],
+ });
+
+ const iter = new WorkPoller({ client, environmentId: 'env_1', environmentKey: 'env_key' });
+ const consumer = (async () => {
+ for await (const _ of iter) {
+ iter.abort();
+ break;
+ }
+ })();
+ // Advance past the empty-poll jittered wait (1-3s) so the second poll runs.
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+
+ expect(calls.filter((c) => c.method === 'poll').length).toBe(2);
+ });
+
+ test('does not auto-post stop when autoStop is false (the consumer owns it)', async () => {
+ const work = makeWork();
+ const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] });
+
+ const iter = new WorkPoller({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ autoStop: false,
+ });
+
+ const consumer = (async () => {
+ for await (const _ of iter) {
+ iter.abort();
+ break;
+ }
+ })();
+ await jest.advanceTimersByTimeAsync(5_000);
+ await consumer;
+
+ // poll + ack happened, but the poller left the stop to the consumer.
+ expect(calls.some((c) => c.method === 'ack')).toBe(true);
+ expect(calls.some((c) => c.method === 'stop')).toBe(false);
+ });
+});
diff --git a/tests/lib/environments/poller.test.ts b/tests/lib/environments/poller.test.ts
new file mode 100644
index 00000000..0692d5db
--- /dev/null
+++ b/tests/lib/environments/poller.test.ts
@@ -0,0 +1,66 @@
+import { backoff, jitter, isStatus, is4xx } from '@anthropic-ai/sdk/lib/environments';
+import { APIError } from '@anthropic-ai/sdk/core/error';
+
+describe('backoff', () => {
+ const cases: { description: string; attempt: number; want: number }[] = [
+ { description: 'first attempt yields the 1s base delay', attempt: 0, want: 1000 },
+ { description: 'second attempt doubles to 2s', attempt: 1, want: 2000 },
+ { description: 'third attempt doubles again to 4s', attempt: 2, want: 4000 },
+ {
+ description: 'large attempt count is clamped to the 60s cap rather than overflowing',
+ attempt: 20,
+ want: 60_000,
+ },
+ ];
+ for (const tc of cases) {
+ test(tc.description, () => {
+ expect(backoff(tc.attempt)).toBe(tc.want);
+ });
+ }
+});
+
+describe('jitter', () => {
+ test('result always falls within [low, high) so callers can rely on the bound', () => {
+ for (let i = 0; i < 200; i++) {
+ const v = jitter(1000, 3000);
+ expect(v).toBeGreaterThanOrEqual(1000);
+ expect(v).toBeLessThan(3000);
+ }
+ });
+});
+
+describe('isStatus / is4xx', () => {
+ function makeErr(status: number): APIError {
+ return Object.assign(Object.create(APIError.prototype) as APIError, { status });
+ }
+
+ const cases: { description: string; err: unknown; status: number; wantIs: boolean; want4xx: boolean }[] = [
+ {
+ description: 'an APIError with matching status is detected by both helpers',
+ err: makeErr(409),
+ status: 409,
+ wantIs: true,
+ want4xx: true,
+ },
+ {
+ description: 'a 5xx APIError is not 4xx and does not match a 409 check',
+ err: makeErr(503),
+ status: 409,
+ wantIs: false,
+ want4xx: false,
+ },
+ {
+ description: 'a plain Error is never treated as an APIError',
+ err: new Error('boom'),
+ status: 500,
+ wantIs: false,
+ want4xx: false,
+ },
+ ];
+ for (const tc of cases) {
+ test(tc.description, () => {
+ expect(isStatus(tc.err, tc.status)).toBe(tc.wantIs);
+ expect(is4xx(tc.err)).toBe(tc.want4xx);
+ });
+ }
+});
diff --git a/tests/lib/environments/worker.test.ts b/tests/lib/environments/worker.test.ts
new file mode 100644
index 00000000..f1c7b333
--- /dev/null
+++ b/tests/lib/environments/worker.test.ts
@@ -0,0 +1,270 @@
+import { EnvironmentWorker } from '@anthropic-ai/sdk/lib/environments';
+import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool';
+
+// =====
+// Test fakes
+//
+// EnvironmentWorker = WorkPoller (claim work) + per-session SessionToolRunner
+// + a parallel lease heartbeat + force-stop on exit. We fake the whole client
+// surface those touch so we can drive one claimed session end-to-end.
+// =====
+
+type AnyEvent = Record & { type: string };
+
+interface WorkerCalls {
+ poll: number;
+ ack: number;
+ heartbeat: number;
+ stop: { force?: boolean }[];
+ send: AnyEvent[][];
+ retrieve: number;
+ withOptions: Array>;
+ // The `options` (last) argument captured per control-plane / session method.
+ opts: Record;
+}
+
+function makeFake(opts: { sessionStream: AnyEvent[] }) {
+ const calls: WorkerCalls = {
+ poll: 0,
+ ack: 0,
+ heartbeat: 0,
+ stop: [],
+ send: [],
+ retrieve: 0,
+ withOptions: [],
+ opts: { poll: [], ack: [], heartbeat: [], stop: [], send: [], stream: [], list: [] },
+ };
+ const externalAbort = new AbortController();
+
+ const work = {
+ id: 'work_1',
+ environment_id: 'env_1',
+ data: { type: 'session', id: 'sesn_1' },
+ };
+
+ const fake = {
+ // The per-item handler scopes a client to the environment key via
+ // `copyClientForHelper`, which calls `withOptions` with the bearer
+ // override, `apiKey: null` to clear the parent's `X-Api-Key`, and the
+ // helper-telemetry default header. The fake records each `withOptions`
+ // override and reuses itself so the rest of the surface stays wired up.
+ _options: { defaultHeaders: undefined },
+ withOptions: (options: Record) => {
+ calls.withOptions.push(options);
+ return fake;
+ },
+ beta: {
+ environments: {
+ work: {
+ poll: (_envId: string, _params: unknown, options?: unknown) => {
+ calls.poll++;
+ calls.opts['poll']!.push(options);
+ if (calls.poll === 1) return Promise.resolve(work);
+ // Second poll: end the run.
+ externalAbort.abort();
+ return Promise.reject(new Error('aborted'));
+ },
+ ack: (_workId: string, _params: unknown, options?: unknown) => {
+ calls.ack++;
+ calls.opts['ack']!.push(options);
+ return Promise.resolve(work);
+ },
+ heartbeat: (_workId: string, _params: unknown, options?: unknown) => {
+ calls.heartbeat++;
+ calls.opts['heartbeat']!.push(options);
+ return Promise.resolve({
+ last_heartbeat: `hb_${calls.heartbeat}`,
+ ttl_seconds: 60,
+ state: 'running',
+ lease_extended: true,
+ });
+ },
+ stop: (_workId: string, params: { force?: boolean }, options?: unknown) => {
+ calls.stop.push({ ...(params.force !== undefined ? { force: params.force } : {}) });
+ calls.opts['stop']!.push(options);
+ return Promise.resolve(work);
+ },
+ },
+ },
+ sessions: {
+ retrieve: () => {
+ calls.retrieve++;
+ return Promise.resolve({ agent: { skills: [] } });
+ },
+ events: {
+ list: (_sessionId: string, _params: unknown, options?: unknown) => {
+ calls.opts['list']!.push(options);
+ return makeAsyncIterable([]);
+ },
+ send: (_sessionId: string, body: { events: AnyEvent[] }, options?: unknown) => {
+ calls.send.push(body.events);
+ calls.opts['send']!.push(options);
+ return Promise.resolve({});
+ },
+ stream: (_sessionId: string, _params: unknown, options?: { signal?: AbortSignal }) => {
+ calls.opts['stream']!.push(options);
+ return Promise.resolve(makeAbortableAsyncIterable(opts.sessionStream, options?.signal));
+ },
+ },
+ },
+ },
+ };
+ return { client: fake as never, calls, signal: externalAbort.signal };
+}
+
+function makeAsyncIterable(items: T[]): AsyncIterable {
+ return {
+ async *[Symbol.asyncIterator]() {
+ for (const it of items) yield it;
+ },
+ };
+}
+
+function makeAbortableAsyncIterable(items: T[], signal?: AbortSignal): AsyncIterable {
+ return {
+ async *[Symbol.asyncIterator]() {
+ for (const it of items) yield it;
+ if (!signal || signal.aborted) return;
+ await new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }));
+ },
+ };
+}
+
+function okTool(name: string): BetaRunnableTool {
+ return {
+ type: 'custom',
+ name,
+ description: name,
+ input_schema: { type: 'object', properties: {} },
+ parse: (x: unknown) => x as never,
+ run: async () => 'ok',
+ };
+}
+
+const TERMINATED: AnyEvent = { type: 'session.status_terminated', id: 'ev_term' };
+
+describe('EnvironmentWorker', () => {
+ test('claims a session, dispatches its tools, heartbeats the lease, and force-stops on exit', async () => {
+ const { client, calls, signal } = makeFake({
+ sessionStream: [{ type: 'agent.tool_use', id: 'tu_1', name: 'echo', input: {} }, TERMINATED],
+ });
+
+ const worker = new EnvironmentWorker({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ tools: [okTool('echo')],
+ workdir: '/tmp',
+ maxIdleMs: 0,
+ signal,
+ });
+ await worker.run();
+
+ // Claimed + ack'd the work item.
+ expect(calls.poll).toBeGreaterThanOrEqual(1);
+ expect(calls.ack).toBe(1);
+ // Set up the workdir/skills for the session.
+ expect(calls.retrieve).toBe(1);
+ // Heartbeated the lease at least once while the session ran.
+ expect(calls.heartbeat).toBeGreaterThanOrEqual(1);
+ // Posted the tool result back to the session.
+ const sentResults = calls.send.flat().filter((e) => e.type === 'user.tool_result');
+ expect(sentResults).toHaveLength(1);
+ expect(sentResults[0]!['tool_use_id']).toBe('tu_1');
+ // Force-stopped the work item on exit.
+ expect(calls.stop.some((s) => s.force === true)).toBe(true);
+ // The per-session calls were scoped to the environment key, with the
+ // parent's `apiKey` cleared so `X-Api-Key` doesn't ride alongside the
+ // bearer credential.
+ expect(calls.withOptions).toContainEqual(expect.objectContaining({ apiKey: null, authToken: 'env_key' }));
+ });
+
+ test('forwards requestOptions custom headers to poll/ack/heartbeat/stop and the session calls', async () => {
+ const { client, calls, signal } = makeFake({
+ sessionStream: [{ type: 'agent.tool_use', id: 'tu_1', name: 'echo', input: {} }, TERMINATED],
+ });
+
+ const worker = new EnvironmentWorker({
+ client,
+ environmentId: 'env_1',
+ environmentKey: 'env_key',
+ tools: [okTool('echo')],
+ workdir: '/tmp',
+ maxIdleMs: 0,
+ signal,
+ requestOptions: { headers: { 'x-proxy-token': 'tok-abc' } },
+ });
+ await worker.run();
+
+ const header = (opt: unknown): string | null | undefined =>
+ (opt as { headers?: { values?: Headers } } | undefined)?.headers?.values?.get('x-proxy-token');
+
+ for (const method of ['poll', 'ack', 'heartbeat', 'stop', 'stream', 'list', 'send'] as const) {
+ const captured = calls.opts[method]!;
+ expect(captured.length).toBeGreaterThanOrEqual(1);
+ for (const opt of captured) {
+ expect(header(opt)).toBe('tok-abc');
+ }
+ }
+ });
+
+ test('run() requires environmentId and environmentKey', async () => {
+ const { client } = makeFake({ sessionStream: [TERMINATED] });
+ await expect(new EnvironmentWorker({ client, environmentId: 'env_1' }).run()).rejects.toThrow(
+ /environmentId and environmentKey are required/,
+ );
+ await expect(new EnvironmentWorker({ client, environmentKey: 'env_key' }).run()).rejects.toThrow(
+ /environmentId and environmentKey are required/,
+ );
+ });
+
+ test('handleItem resolves the environment key from the worker or an explicit option', async () => {
+ const { client, calls } = makeFake({ sessionStream: [TERMINATED] });
+
+ // From the worker's own environmentKey.
+ await new EnvironmentWorker({
+ client,
+ environmentKey: 'worker_key',
+ tools: [],
+ workdir: '/tmp',
+ maxIdleMs: 0,
+ }).handleItem({ workId: 'work_1', environmentId: 'env_1', sessionId: 'sesn_1' });
+ expect(calls.withOptions).toContainEqual(
+ expect.objectContaining({ apiKey: null, authToken: 'worker_key' }),
+ );
+
+ // An explicit option wins over the worker's key.
+ await new EnvironmentWorker({
+ client,
+ environmentKey: 'worker_key',
+ tools: [],
+ workdir: '/tmp',
+ maxIdleMs: 0,
+ }).handleItem({
+ workId: 'work_1',
+ environmentId: 'env_1',
+ sessionId: 'sesn_1',
+ environmentKey: 'explicit_key',
+ });
+ expect(calls.withOptions).toContainEqual(
+ expect.objectContaining({ apiKey: null, authToken: 'explicit_key' }),
+ );
+ });
+
+ test('handleItem throws when the environment key cannot be resolved', async () => {
+ const { client } = makeFake({ sessionStream: [TERMINATED] });
+ const saved = process.env['ANTHROPIC_ENVIRONMENT_KEY'];
+ delete process.env['ANTHROPIC_ENVIRONMENT_KEY'];
+ try {
+ await expect(
+ new EnvironmentWorker({ client, tools: [], workdir: '/tmp' }).handleItem({
+ workId: 'work_1',
+ environmentId: 'env_1',
+ sessionId: 'sesn_1',
+ }),
+ ).rejects.toThrow(/environmentKey is required/);
+ } finally {
+ if (saved !== undefined) process.env['ANTHROPIC_ENVIRONMENT_KEY'] = saved;
+ }
+ });
+});
diff --git a/tests/lib/helper-client.test.ts b/tests/lib/helper-client.test.ts
new file mode 100644
index 00000000..6805d9f3
--- /dev/null
+++ b/tests/lib/helper-client.test.ts
@@ -0,0 +1,200 @@
+// Direct unit tests for `copyClientForHelper`.
+//
+// These verify the load-bearing invariants of the util — auth replaced,
+// parent's `X-Api-Key` cleared on the sub-client, helper telemetry header set,
+// parent not mutated — without re-exercising `withOptions`'s own inheritance
+// contract (which is the SDK's job to keep working).
+
+import Anthropic from '@anthropic-ai/sdk';
+import { copyClientForHelper } from '@anthropic-ai/sdk/lib/helper-client';
+import { AnthropicError } from '@anthropic-ai/sdk/core/error';
+
+const VALID_MSG_RESPONSE = {
+ id: 'msg_1',
+ type: 'message',
+ role: 'assistant',
+ content: [],
+ model: 'x',
+ stop_reason: 'end_turn',
+ stop_sequence: null,
+ usage: { input_tokens: 0, output_tokens: 0 },
+};
+
+function jsonResponse(body: object, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+function farFuture(): number {
+ return Math.floor(Date.now() / 1000) + 3600;
+}
+
+function getHeader(init: RequestInit | undefined, name: string): string | null {
+ if (!init?.headers) return null;
+ if (init.headers instanceof Headers) return init.headers.get(name);
+ if (Array.isArray(init.headers)) {
+ const entry = init.headers.find(([k]) => k?.toLowerCase() === name.toLowerCase());
+ return entry?.[1] ?? null;
+ }
+ return (init.headers as Record)[name] ?? null;
+}
+
+describe('copyClientForHelper', () => {
+ test('sets the bearer auth token on the sub-client', () => {
+ const parent = new Anthropic({ apiKey: 'parent-key' });
+ const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-work-poller' });
+ expect(scoped.authToken).toBe('env-key');
+ });
+
+ test("clears the parent's X-Api-Key on the sub-client", () => {
+ // Without `apiKey: null` in the override, `withOptions` would inherit the
+ // parent's apiKey and the sub-client would emit *both* `X-Api-Key` *and*
+ // `Authorization: Bearer …` on every request — exactly the situation
+ // this util exists to prevent.
+ const parent = new Anthropic({ apiKey: 'parent-key' });
+ const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-work-poller' });
+ expect(scoped.apiKey).toBeNull();
+ });
+
+ test('stamps the helper telemetry header on the sub-client', () => {
+ const parent = new Anthropic({ apiKey: 'parent-key' });
+ const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-worker' });
+ const defaults = (scoped as unknown as { _options: { defaultHeaders: { values: Headers } } })._options
+ .defaultHeaders;
+ expect(defaults.values.get('x-stainless-helper')).toBe('environments-worker');
+ });
+
+ test("preserves the parent's custom defaultHeaders on the sub-client", () => {
+ // `withOptions` *replaces* defaultHeaders (no merge), so the util must
+ // explicitly fold in the parent's custom defaults — otherwise any
+ // headers the consumer configured at construction time would silently
+ // disappear from the sub-client's wire requests.
+ const parent = new Anthropic({
+ apiKey: 'parent-key',
+ defaultHeaders: { 'X-Custom-Tenant': 'acme' },
+ });
+ const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-work-poller' });
+ const defaults = (scoped as unknown as { _options: { defaultHeaders: { values: Headers } } })._options
+ .defaultHeaders;
+ expect(defaults.values.get('x-custom-tenant')).toBe('acme');
+ expect(defaults.values.get('x-stainless-helper')).toBe('environments-work-poller');
+ });
+
+ test('does not mutate the parent client', () => {
+ // Building the sub-client must not touch the parent's auth state — a
+ // long-lived parent could otherwise be silently re-credentialed every
+ // time a runner helper started.
+ const parent = new Anthropic({ apiKey: 'parent-key' });
+ copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-work-poller' });
+ expect(parent.apiKey).toBe('parent-key');
+ expect(parent.authToken).toBeNull();
+ });
+
+ test('throws on an empty auth token', () => {
+ // An empty `authToken` would otherwise silently produce a sub-client
+ // with `authToken: ""` which both fails auth and looks like a
+ // misconfiguration — fail loudly instead.
+ const parent = new Anthropic({ apiKey: 'parent-key' });
+ expect(() => copyClientForHelper(parent, { authToken: '', helper: 'environments-work-poller' })).toThrow(
+ AnthropicError,
+ );
+ });
+
+ test('returns the same concrete subclass as the parent', () => {
+ // The generic `` keeps the caller's compile-time
+ // subclass; at runtime `withOptions()` uses `this.constructor`, so the
+ // sub-client is always an instance of the parent's class.
+ const parent = new Anthropic({ apiKey: 'parent-key' });
+ const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'session-tool-runner' });
+ expect(scoped).toBeInstanceOf(Anthropic);
+ });
+});
+
+describe('copyClientForHelper — auth state inheritance', () => {
+ test('inherits workspace header from parent auth state but uses its own bearer token', async () => {
+ const captured: {
+ auth: string | null;
+ workspace: string | null;
+ helper: string | null;
+ apiKey: string | null;
+ }[] = [];
+ const parent = new Anthropic({
+ apiKey: null,
+ credentials: async () => ({ token: 'parent-oauth-tok', expiresAt: farFuture() }),
+ fetch: async (_url: any, init?: RequestInit) => {
+ captured.push({
+ auth: getHeader(init, 'authorization'),
+ workspace: getHeader(init, 'anthropic-workspace-id'),
+ helper: getHeader(init, 'x-stainless-helper'),
+ apiKey: getHeader(init, 'x-api-key'),
+ });
+ return jsonResponse(VALID_MSG_RESPONSE);
+ },
+ });
+ (parent as any)._authState.extraHeaders = { 'anthropic-workspace-id': 'ws-tenant-42' };
+
+ await parent.messages.create({
+ model: 'claude-sonnet-4-20250514',
+ max_tokens: 1,
+ messages: [{ role: 'user', content: 'hi' }],
+ });
+
+ const helper = copyClientForHelper(parent, {
+ authToken: 'env-scoped-bearer',
+ helper: 'environments-work-poller',
+ });
+
+ await helper.messages.create({
+ model: 'claude-sonnet-4-20250514',
+ max_tokens: 1,
+ messages: [{ role: 'user', content: 'hi' }],
+ });
+
+ // Parent: OAuth token + workspace header, no helper tag
+ expect(captured[0]!.auth).toBe('Bearer parent-oauth-tok');
+ expect(captured[0]!.workspace).toBe('ws-tenant-42');
+ expect(captured[0]!.helper).toBeNull();
+
+ // Helper sub-client: new bearer token + same workspace + helper tag, no api key
+ expect(captured[1]!.auth).toBe('Bearer env-scoped-bearer');
+ expect(captured[1]!.workspace).toBe('ws-tenant-42');
+ expect(captured[1]!.helper).toBe('environments-work-poller');
+ expect(captured[1]!.apiKey).toBeNull();
+ });
+
+ test('inherits resolved baseURL from parent', async () => {
+ const seenURLs: string[] = [];
+ const parent = new Anthropic({
+ apiKey: null,
+ credentials: async () => ({ token: 'tok', expiresAt: farFuture() }),
+ fetch: async (url: any) => {
+ seenURLs.push(String(url));
+ return jsonResponse(VALID_MSG_RESPONSE);
+ },
+ });
+ // Simulate a profile-resolved baseURL (not set via constructor, so _baseURLIsExplicit stays false)
+ (parent as any).baseURL = 'https://custom.anthropic.example';
+
+ await parent.messages.create({
+ model: 'claude-sonnet-4-20250514',
+ max_tokens: 1,
+ messages: [{ role: 'user', content: 'hi' }],
+ });
+
+ const helper = copyClientForHelper(parent, {
+ authToken: 'env-bearer',
+ helper: 'environments-worker',
+ });
+
+ await helper.messages.create({
+ model: 'claude-sonnet-4-20250514',
+ max_tokens: 1,
+ messages: [{ role: 'user', content: 'hi' }],
+ });
+
+ expect(seenURLs[0]).toMatch(/^https:\/\/custom\.anthropic\.example\//);
+ expect(seenURLs[1]).toMatch(/^https:\/\/custom\.anthropic\.example\//);
+ });
+});
diff --git a/tests/lib/tools/SessionToolRunner.test.ts b/tests/lib/tools/SessionToolRunner.test.ts
new file mode 100644
index 00000000..edacff20
--- /dev/null
+++ b/tests/lib/tools/SessionToolRunner.test.ts
@@ -0,0 +1,535 @@
+import { SessionToolRunner } from '@anthropic-ai/sdk/lib/tools/SessionToolRunner';
+import type { DispatchedToolCall } from '@anthropic-ai/sdk/lib/tools/SessionToolRunner';
+import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool';
+import { APIError } from '@anthropic-ai/sdk/core/error';
+
+// =====
+// Test fakes
+//
+// We don't need SSE plumbing here — the runner orchestration is what we want
+// to test, so we feed event sequences through a synchronous fake of
+// `client.beta.sessions.events.stream()` and a fake of `events.list()`.
+// =====
+
+type AnyEvent = Record & { type: string };
+
+interface RunnerCalls {
+ send: AnyEvent[][];
+ list: number;
+ streams: number;
+ // The `options` (3rd) argument captured from each stream/list/send call.
+ options: Array;
+}
+
+interface FakeOpts {
+ // Sequence of streams the runner will see when it calls events.stream().
+ // Each stream yields an array of events. After the last stream, subsequent
+ // calls block forever (or until the controller aborts).
+ streams: AnyEvent[][];
+ // Optional: events that events.list() will yield on each invocation.
+ list?: AnyEvent[][];
+ // Optional: `events.send` failures, by call index.
+ sendErrors?: Array<{ at: number; err: unknown }>;
+ // Optional: stream indices that throw (disconnect) after yielding their
+ // scripted events, forcing the runner to reconnect + reconcile.
+ streamErrors?: number[];
+}
+
+function makeFake(opts: FakeOpts) {
+ const calls: RunnerCalls = { send: [], list: 0, streams: 0, options: [] };
+
+ let listIdx = 0;
+ let streamIdx = 0;
+ let sendIdx = 0;
+
+ const fake = {
+ beta: {
+ sessions: {
+ events: {
+ list: (_sessionId: string, _params: unknown, options?: unknown) => {
+ calls.list++;
+ calls.options.push(options);
+ const items = opts.list?.[listIdx++] ?? [];
+ return makeAsyncIterable(items);
+ },
+ send: (_sessionId: string, body: { events: AnyEvent[] }, options?: unknown) => {
+ const i = sendIdx++;
+ calls.send.push(body.events);
+ calls.options.push(options);
+ const fail = opts.sendErrors?.find((e) => e.at === i);
+ if (fail) return Promise.reject(fail.err);
+ return Promise.resolve({});
+ },
+ stream: (_sessionId: string, _params: unknown, options?: { signal?: AbortSignal }) => {
+ calls.streams++;
+ calls.options.push(options);
+ const i = streamIdx++;
+ const events = opts.streams[i] ?? [];
+ // Mirror APIPromise>: a thenable that resolves to an
+ // AsyncIterable. The real Stream tears down on abort; the fake
+ // does the same so tests don't rely on TERMINATED to end.
+ return Promise.resolve(
+ makeAbortableAsyncIterable(events, options?.signal, opts.streamErrors?.includes(i) ?? false),
+ );
+ },
+ },
+ },
+ },
+ };
+ return { client: fake as never, calls };
+}
+
+function makeAsyncIterable(items: T[]): AsyncIterable {
+ return {
+ async *[Symbol.asyncIterator]() {
+ for (const it of items) yield it;
+ },
+ };
+}
+
+// Yields each item, then (if no items remain) either throws to mimic a
+// disconnect, or parks until the signal aborts. Mirrors how the real Stream
+// tears down on abort / errors out on a dropped connection.
+function makeAbortableAsyncIterable(
+ items: T[],
+ signal?: AbortSignal,
+ throwAfter = false,
+): AsyncIterable {
+ return {
+ async *[Symbol.asyncIterator]() {
+ for (const it of items) yield it;
+ // A scripted disconnect: the runner should reconnect + reconcile.
+ if (throwAfter) throw new Error('stream disconnected');
+ // After the scripted items, idle until aborted. Lets the runner exit
+ // cleanly via the idle watchdog or an external abort.
+ if (!signal) return;
+ if (signal.aborted) return;
+ await new Promise((resolve) => {
+ signal.addEventListener('abort', () => resolve(), { once: true });
+ });
+ },
+ };
+}
+
+function makeOkTool(name: string, output: string | BetaRunnableTool['run']): BetaRunnableTool {
+ const run: BetaRunnableTool['run'] = typeof output === 'string' ? async () => output : output;
+ return {
+ type: 'custom',
+ name,
+ description: name,
+ input_schema: { type: 'object', properties: {} },
+ parse: (x: unknown) => x as never,
+ run,
+ };
+}
+
+function toolUse(id: string, name: string, input: Record = {}): AnyEvent {
+ return { type: 'agent.tool_use', id, name, input };
+}
+
+function customToolUse(id: string, name: string, input: Record = {}): AnyEvent {
+ return { type: 'agent.custom_tool_use', id, name, input };
+}
+
+function idleEndTurn(): AnyEvent {
+ return { type: 'session.status_idle', id: 'ev_idle', stop_reason: { type: 'end_turn' } };
+}
+
+const TERMINATED: AnyEvent = { type: 'session.status_terminated', id: 'ev_term' };
+
+// =====
+
+describe('SessionToolRunner', () => {
+ test('yields DispatchedToolCall for a successful tool execution and posts the result', async () => {
+ const tool = makeOkTool('current_time', 'noon');
+ const { client, calls } = makeFake({
+ streams: [[toolUse('tu_1', 'current_time', { tz: 'UTC' }), TERMINATED]],
+ });
+ const runner = new SessionToolRunner('sesn_1', { client, tools: [tool], maxIdleMs: 0 });
+
+ const items: DispatchedToolCall[] = [];
+ for await (const c of runner) items.push(c);
+
+ expect(items).toHaveLength(1);
+ const call = items[0]!;
+ expect(call.toolUseId).toBe('tu_1');
+ expect(call.name).toBe('current_time');
+ expect(call.event.input).toEqual({ tz: 'UTC' });
+ expect(call.isError).toBe(false);
+ expect(call.posted).toBe(true);
+ expect(call.result.content).toEqual([{ type: 'text', text: 'noon' }]);
+
+ // The send carried a user.tool_result with matching tool_use_id.
+ const sentResults = calls.send.flat().filter((e) => e.type === 'user.tool_result');
+ expect(sentResults).toHaveLength(1);
+ expect(sentResults[0]!['tool_use_id']).toBe('tu_1');
+ expect(sentResults[0]!['is_error']).toBe(false);
+ });
+
+ test('yields isError=true when the tool throws', async () => {
+ const tool = makeOkTool('boom', async () => {
+ throw new Error('kaboom');
+ });
+ const { client } = makeFake({ streams: [[toolUse('tu_x', 'boom'), TERMINATED]] });
+ const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 });
+ const calls: DispatchedToolCall[] = [];
+ for await (const c of runner) calls.push(c);
+
+ expect(calls).toHaveLength(1);
+ expect(calls[0]!.isError).toBe(true);
+ expect(JSON.stringify(calls[0]!.result.content)).toMatch(/kaboom/);
+ });
+
+ test('yields isError=true for an unknown tool name', async () => {
+ const { client } = makeFake({ streams: [[toolUse('tu_u', 'no_such_tool'), TERMINATED]] });
+ const runner = new SessionToolRunner('s', { client, tools: [], maxIdleMs: 0 });
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+
+ expect(out).toHaveLength(1);
+ expect(out[0]!.isError).toBe(true);
+ expect(JSON.stringify(out[0]!.result.content)).toMatch(/not found/);
+ });
+
+ test('does not re-execute a tool whose result is already in history', async () => {
+ let runs = 0;
+ const tool = makeOkTool('once', async () => {
+ runs++;
+ return 'ran';
+ });
+ const { client } = makeFake({
+ // Reconcile sees both the tool_use AND its prior result, so execute is skipped.
+ list: [[toolUse('tu_already', 'once'), { type: 'user.tool_result', tool_use_id: 'tu_already' }]],
+ streams: [[TERMINATED]],
+ });
+ const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 });
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+
+ expect(runs).toBe(0);
+ expect(out).toHaveLength(0);
+ });
+
+ test('session.status_terminated ends iteration', async () => {
+ const { client } = makeFake({ streams: [[TERMINATED]] });
+ const runner = new SessionToolRunner('s', { client, tools: [], maxIdleMs: 0 });
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+ expect(out).toEqual([]);
+ });
+
+ test('runs each tool.close() once when iteration ends, even when the consumer breaks early', async () => {
+ let closed = 0;
+ const tool: BetaRunnableTool = {
+ ...makeOkTool('first', 'ok'),
+ close: () => {
+ closed++;
+ },
+ };
+ const { client } = makeFake({
+ streams: [[toolUse('tu_a', 'first'), toolUse('tu_b', 'first'), TERMINATED]],
+ });
+ const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 });
+
+ let count = 0;
+ for await (const _ of runner) {
+ count++;
+ if (count === 1) break;
+ }
+ expect(count).toBe(1);
+ expect(closed).toBe(1);
+ });
+
+ test('posted=false when events.send hits a permanent 4xx', async () => {
+ const tool = makeOkTool('t', 'ok');
+ const err = Object.assign(Object.create(APIError.prototype) as APIError, { status: 404 });
+ const { client } = makeFake({
+ streams: [[toolUse('tu_p', 't'), TERMINATED]],
+ sendErrors: [{ at: 0, err }],
+ });
+ const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 });
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+
+ expect(out).toHaveLength(1);
+ expect(out[0]!.posted).toBe(false);
+ expect(out[0]!.isError).toBe(false);
+ });
+
+ test('throws when iterated twice', async () => {
+ const { client } = makeFake({ streams: [[TERMINATED]] });
+ const runner = new SessionToolRunner('s', { client, tools: [], maxIdleMs: 0 });
+ for await (const _ of runner) {
+ // unreachable
+ }
+ await expect(
+ (async () => {
+ for await (const _ of runner) {
+ // unreachable
+ }
+ })(),
+ ).rejects.toThrow(/consumed/);
+ });
+
+ test('pre-aborted external signal settles cleanup without hanging', async () => {
+ // Reproducer for: a pre-aborted signal would cause idleWatchdog's
+ // maxIdleMs <= 0 branch to park on a signal that never re-fires, hanging
+ // the iterator's `Promise.allSettled` step forever.
+ const ctl = new AbortController();
+ ctl.abort();
+ const { client } = makeFake({ streams: [[]] });
+ const runner = new SessionToolRunner('s', {
+ client,
+ tools: [],
+ maxIdleMs: 0,
+ signal: ctl.signal,
+ });
+ // If the bug is back, this hangs and Jest's per-test timeout fires.
+ await Promise.race([
+ (async () => {
+ for await (const _ of runner) {
+ // unreachable
+ }
+ })(),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('iter hung')), 2_000)),
+ ]);
+ }, 5_000);
+
+ test('idle watchdog stops the runner maxIdleMs after an end_turn idle', async () => {
+ jest.useFakeTimers();
+ try {
+ // Session goes idle with end_turn and nothing else happens — the idle
+ // watchdog should stop the runner after maxIdleMs.
+ const { client } = makeFake({ streams: [[idleEndTurn()]] });
+
+ const runner = new SessionToolRunner('s', { client, tools: [], maxIdleMs: 1_000 });
+
+ let done = false;
+ const consumer = (async () => {
+ for await (const _ of runner) {
+ // unreachable — no tool calls
+ }
+ done = true;
+ })();
+
+ await jest.advanceTimersByTimeAsync(2_000);
+ await consumer;
+ expect(done).toBe(true);
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ test('a new event after end_turn resets the idle watchdog', async () => {
+ const tool = makeOkTool('echo', 'ok');
+ // end_turn arms the timer; the tool_use that follows resets it and is
+ // dispatched; the run only ends on the terminated event.
+ const { client } = makeFake({
+ streams: [[idleEndTurn(), toolUse('tu_1', 'echo'), TERMINATED]],
+ });
+ const runner = new SessionToolRunner('s', {
+ client,
+ tools: [tool],
+ // Generous grace — the timer must not fire between the scripted events.
+ maxIdleMs: 60_000,
+ });
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+ expect(out.map((c) => c.toolUseId)).toEqual(['tu_1']);
+ });
+
+ test('dispatches an agent.custom_tool_use and answers with a matching user.custom_tool_result', async () => {
+ const tool = makeOkTool('lookup_order', 'shipped');
+ const { client, calls } = makeFake({
+ streams: [[customToolUse('ctu_1', 'lookup_order', { order_id: 42 }), TERMINATED]],
+ });
+ const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 });
+
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+
+ expect(out).toHaveLength(1);
+ const call = out[0]!;
+ expect(call.event.type).toBe('agent.custom_tool_use');
+ expect(call.toolUseId).toBe('ctu_1');
+ expect(call.name).toBe('lookup_order');
+ expect(call.event.input).toEqual({ order_id: 42 });
+ expect(call.isError).toBe(false);
+ expect(call.posted).toBe(true);
+ expect(call.result.type).toBe('user.custom_tool_result');
+ expect((call.result as { custom_tool_use_id?: string }).custom_tool_use_id).toBe('ctu_1');
+ expect(call.result.content).toEqual([{ type: 'text', text: 'shipped' }]);
+
+ // A custom tool call must be answered with user.custom_tool_result, never
+ // user.tool_result — the wrong type leaves the session hung.
+ const sent = calls.send.flat();
+ expect(sent.filter((e) => e.type === 'user.tool_result')).toHaveLength(0);
+ const customResults = sent.filter((e) => e.type === 'user.custom_tool_result');
+ expect(customResults).toHaveLength(1);
+ expect(customResults[0]!['custom_tool_use_id']).toBe('ctu_1');
+ });
+
+ test('dispatches builtin and custom tool calls in one stream, each with its matching result type', async () => {
+ const { client, calls } = makeFake({
+ streams: [[toolUse('tu_b', 'echo'), customToolUse('ctu_c', 'echo'), TERMINATED]],
+ });
+ const runner = new SessionToolRunner('s', {
+ client,
+ tools: [makeOkTool('echo', 'ok')],
+ maxIdleMs: 0,
+ });
+
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+
+ expect(out.map((c) => [c.event.type, c.toolUseId])).toEqual([
+ ['agent.tool_use', 'tu_b'],
+ ['agent.custom_tool_use', 'ctu_c'],
+ ]);
+ const sent = calls.send.flat();
+ expect(sent.filter((e) => e.type === 'user.tool_result').map((e) => e['tool_use_id'])).toEqual(['tu_b']);
+ expect(
+ sent.filter((e) => e.type === 'user.custom_tool_result').map((e) => e['custom_tool_use_id']),
+ ).toEqual(['ctu_c']);
+ });
+
+ test('reconcile does not re-execute a custom tool whose user.custom_tool_result is already in history', async () => {
+ let runs = 0;
+ const tool = makeOkTool('once_custom', async () => {
+ runs++;
+ return 'ran';
+ });
+ const { client } = makeFake({
+ // Reconcile sees the custom tool_use AND its prior custom result.
+ list: [
+ [
+ customToolUse('ctu_done', 'once_custom'),
+ { type: 'user.custom_tool_result', custom_tool_use_id: 'ctu_done' },
+ ],
+ ],
+ streams: [[TERMINATED]],
+ });
+ const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 });
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+
+ expect(runs).toBe(0);
+ expect(out).toHaveLength(0);
+ });
+
+ test('passes a search_result tool-result block through as a search_result event, not stringified', async () => {
+ const tool = makeOkTool('web_search', async () => [
+ {
+ type: 'search_result',
+ source: 'https://example.com/doc',
+ title: 'Example Doc',
+ content: [{ type: 'text', text: 'the answer is 42' }],
+ citations: { enabled: true },
+ },
+ ]);
+ const { client, calls } = makeFake({
+ streams: [[toolUse('tu_s', 'web_search'), TERMINATED]],
+ });
+ const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 });
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+
+ expect(out).toHaveLength(1);
+ // Mapped to the Sessions search_result block shape, NOT a text block with
+ // a JSON.stringify of the original block.
+ expect(out[0]!.result.content).toEqual([
+ {
+ type: 'search_result',
+ source: 'https://example.com/doc',
+ title: 'Example Doc',
+ content: [{ type: 'text', text: 'the answer is 42' }],
+ citations: { enabled: true },
+ },
+ ]);
+ const sent = calls.send.flat().filter((e) => e.type === 'user.tool_result');
+ expect(sent).toHaveLength(1);
+ const content = sent[0]!['content'] as Array<{ type: string }>;
+ expect(content[0]!.type).toBe('search_result');
+ // Must not have been buried inside a stringified text block.
+ expect(JSON.stringify(content)).not.toContain('\\"type\\":\\"search_result\\"');
+ });
+
+ test('citations defaults to { enabled: false } when the producer omits it', async () => {
+ const tool = makeOkTool('web_search', async () => [
+ {
+ type: 'search_result',
+ source: 'https://example.com',
+ title: 'No citations',
+ content: [{ type: 'text', text: 'body' }],
+ },
+ ]);
+ const { client } = makeFake({ streams: [[toolUse('tu_s2', 'web_search'), TERMINATED]] });
+ const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 });
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+
+ expect(out[0]!.result.content).toEqual([
+ {
+ type: 'search_result',
+ source: 'https://example.com',
+ title: 'No citations',
+ content: [{ type: 'text', text: 'body' }],
+ citations: { enabled: false },
+ },
+ ]);
+ });
+
+ test('forwards requestOptions custom headers to stream/list/send (alongside the helper header)', async () => {
+ const tool = makeOkTool('echo', 'ok');
+ const { client, calls } = makeFake({
+ streams: [[toolUse('tu_h', 'echo')]],
+ list: [[]],
+ });
+ const runner = new SessionToolRunner('s', {
+ client,
+ tools: [tool],
+ maxIdleMs: 0,
+ requestOptions: { headers: { 'x-proxy-token': 'secret-123' } },
+ });
+ const consume = (async () => {
+ for await (const _ of runner) runner.abort();
+ })();
+ await consume;
+
+ // stream, list (reconcile) and send were all called and each carried the
+ // custom proxy header plus the helper telemetry header.
+ expect(calls.options.length).toBeGreaterThanOrEqual(3);
+ for (const opt of calls.options) {
+ const headers = (opt as { headers?: { values?: Headers } }).headers;
+ expect(headers?.values?.get('x-proxy-token')).toBe('secret-123');
+ expect(headers?.values?.get('x-stainless-helper')).toBe('SessionToolRunner');
+ }
+ });
+
+ test('retries a tool_use whose result post failed on the next reconcile instead of dropping it', async () => {
+ let runs = 0;
+ const tool = makeOkTool('retry_me', async () => {
+ runs++;
+ return 'ok';
+ });
+ const fatal = Object.assign(Object.create(APIError.prototype) as APIError, { status: 400 });
+ const { client, calls } = makeFake({
+ // Stream 1 yields the tool_use then disconnects; stream 2 terminates.
+ streams: [[toolUse('tu_r', 'retry_me')], [TERMINATED]],
+ streamErrors: [0],
+ // Reconcile on reconnect still sees the tool_use with no result event —
+ // because the first post failed — so it must be retried, not dropped.
+ list: [[], [toolUse('tu_r', 'retry_me')]],
+ // The first post fails permanently; the retry after reconnect succeeds.
+ sendErrors: [{ at: 0, err: fatal }],
+ });
+ const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 });
+ const out: DispatchedToolCall[] = [];
+ for await (const c of runner) out.push(c);
+
+ // Re-executed (and re-posted) once the failed post was retried.
+ expect(runs).toBe(2);
+ expect(calls.send.flat().filter((e) => e.type === 'user.tool_result')).toHaveLength(2);
+ expect(out.map((c) => c.posted)).toEqual([false, true]);
+ });
+});
diff --git a/tests/tools/agent-toolset.test.ts b/tests/tools/agent-toolset.test.ts
new file mode 100644
index 00000000..a9bf0579
--- /dev/null
+++ b/tests/tools/agent-toolset.test.ts
@@ -0,0 +1,349 @@
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import {
+ resolvePath,
+ betaAgentToolset20260401,
+ betaBashTool,
+ betaReadTool,
+ betaWriteTool,
+ betaEditTool,
+ betaGlobTool,
+ betaGrepTool,
+ BashSession,
+ type AgentToolContext,
+} from '@anthropic-ai/sdk/tools/agent-toolset/node';
+import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool';
+
+function tmpdir(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'runner-test-'));
+}
+
+describe('betaAgentToolset20260401', () => {
+ test('returns the agent_toolset_20260401 tool list as BetaRunnableTool objects so it can be filtered or extended', () => {
+ const tools = betaAgentToolset20260401({ workdir: '.' });
+ expect(tools.map((t) => t.name)).toEqual(['bash', 'read', 'write', 'edit', 'glob', 'grep']);
+ for (const t of tools) {
+ expect(typeof t.run).toBe('function');
+ expect(typeof t.parse).toBe('function');
+ expect(t.type).toBe('custom');
+ }
+ });
+});
+
+describe('resolvePath', () => {
+ const root = '/tmp/work';
+ const cases: { description: string; env: AgentToolContext; p: string; want?: string; wantErr?: RegExp }[] =
+ [
+ {
+ description: 'relative path under workdir resolves to an absolute child of the workdir',
+ env: { workdir: root },
+ p: 'a/b.txt',
+ want: path.resolve(root, 'a/b.txt'),
+ },
+ {
+ description: 'dot-dot that escapes the workdir is rejected when unrestrictedPaths is false',
+ env: { workdir: root },
+ p: '../etc/passwd',
+ wantErr: /escapes workdir/,
+ },
+ {
+ description: 'absolute path is rejected by default so the jail is the explicit opt-out',
+ env: { workdir: root },
+ p: '/etc/passwd',
+ wantErr: /not permitted/,
+ },
+ {
+ description: 'absolute path is allowed when unrestrictedPaths is set',
+ env: { workdir: root, unrestrictedPaths: true },
+ p: '/etc/passwd',
+ want: '/etc/passwd',
+ },
+ {
+ description: 'sibling directory with a shared prefix (work vs workdir2) is correctly rejected',
+ env: { workdir: '/tmp/work' },
+ p: '../work2/file',
+ wantErr: /escapes workdir/,
+ },
+ {
+ description: 'dot-dot that stays inside the workdir after normalisation is permitted',
+ env: { workdir: root },
+ p: 'a/../b.txt',
+ want: path.resolve(root, 'b.txt'),
+ },
+ ];
+
+ for (const tc of cases) {
+ test(tc.description, async () => {
+ if (tc.wantErr) {
+ await expect(resolvePath(tc.env, tc.p)).rejects.toThrow(tc.wantErr);
+ } else {
+ await expect(resolvePath(tc.env, tc.p)).resolves.toEqual(tc.want);
+ }
+ });
+ }
+});
+
+describe('fs tools (read/write/edit)', () => {
+ let dir: string;
+ let env: AgentToolContext;
+
+ beforeEach(() => {
+ dir = tmpdir();
+ env = { workdir: dir };
+ });
+ afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
+
+ test('write creates a file then read returns its content verbatim', async () => {
+ await betaWriteTool(env).run({ file_path: 'a.txt', content: 'hello' });
+ const out = await betaReadTool(env).run({ file_path: 'a.txt' });
+ expect(out).toBe('hello');
+ });
+
+ test('read with view_range returns the 1-indexed inclusive line slice', async () => {
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'a\nb\nc\nd\n');
+ const out = await betaReadTool(env).run({ file_path: 'f.txt', view_range: [2, 3] });
+ expect(out).toBe('b\nc');
+ });
+
+ test('read of a missing file throws ToolError so the dispatcher reports is_error', async () => {
+ await expect(betaReadTool(env).run({ file_path: 'nope.txt' })).rejects.toThrow(/ENOENT|no such file/);
+ });
+
+ test('edit with a unique old_string performs exactly one replacement', async () => {
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'foo bar foo');
+ await betaEditTool(env).run({ file_path: 'f.txt', old_string: 'bar', new_string: 'BAZ' });
+ expect(fs.readFileSync(path.join(dir, 'f.txt'), 'utf8')).toBe('foo BAZ foo');
+ });
+
+ test('edit refuses a non-unique old_string when replace_all is not set', async () => {
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'foo bar foo');
+ await expect(
+ betaEditTool(env).run({ file_path: 'f.txt', old_string: 'foo', new_string: 'X' }),
+ ).rejects.toThrow(/appears 2 times/);
+ });
+
+ test('edit with replace_all replaces every occurrence', async () => {
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'a a a');
+ await betaEditTool(env).run({ file_path: 'f.txt', old_string: 'a', new_string: 'b', replace_all: true });
+ expect(fs.readFileSync(path.join(dir, 'f.txt'), 'utf8')).toBe('b b b');
+ });
+
+ test('edit inserts new_string literally instead of expanding $& / $1 replacement patterns', async () => {
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'echo NAME');
+ await betaEditTool(env).run({ file_path: 'f.txt', old_string: 'NAME', new_string: '$&_$1_${HOME}' });
+ expect(fs.readFileSync(path.join(dir, 'f.txt'), 'utf8')).toBe('echo $&_$1_${HOME}');
+ });
+
+ test('edit with replace_all also inserts $-containing new_string literally', async () => {
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'X X');
+ await betaEditTool(env).run({ file_path: 'f.txt', old_string: 'X', new_string: '$`', replace_all: true });
+ expect(fs.readFileSync(path.join(dir, 'f.txt'), 'utf8')).toBe('$` $`');
+ });
+
+ test('read refuses a file over the size cap so a huge file cannot OOM the runner', async () => {
+ fs.writeFileSync(path.join(dir, 'big.txt'), Buffer.alloc(257 * 1024, 'a'));
+ await expect(betaReadTool(env).run({ file_path: 'big.txt' })).rejects.toThrow(/exceeds .*limit/);
+ });
+
+ test('read refuses a directory so it cannot dump a Dirent listing as bytes', async () => {
+ fs.mkdirSync(path.join(dir, 'sub'));
+ await expect(betaReadTool(env).run({ file_path: 'sub' })).rejects.toThrow(/not a regular file/);
+ });
+
+ test('edit refuses a file over the size cap so a huge file cannot OOM the runner', async () => {
+ fs.writeFileSync(path.join(dir, 'big.txt'), Buffer.alloc(257 * 1024, 'a'));
+ await expect(
+ betaEditTool(env).run({ file_path: 'big.txt', old_string: 'a', new_string: 'b' }),
+ ).rejects.toThrow(/exceeds .*limit/);
+ });
+
+ test('edit refuses a directory so a non-regular path cannot hang or be misread', async () => {
+ fs.mkdirSync(path.join(dir, 'sub'));
+ await expect(
+ betaEditTool(env).run({ file_path: 'sub', old_string: 'a', new_string: 'b' }),
+ ).rejects.toThrow(/not a regular file/);
+ });
+
+ test('edit still works on a normal file under the size cap', async () => {
+ fs.writeFileSync(path.join(dir, 'ok.txt'), 'foo bar');
+ await betaEditTool(env).run({ file_path: 'ok.txt', old_string: 'bar', new_string: 'baz' });
+ expect(fs.readFileSync(path.join(dir, 'ok.txt'), 'utf8')).toBe('foo baz');
+ });
+
+ test('write outside workdir via dot-dot is rejected by the path jail', async () => {
+ await expect(betaWriteTool(env).run({ file_path: '../escape.txt', content: 'x' })).rejects.toThrow(
+ /escapes workdir/,
+ );
+ });
+});
+
+describe('search tools (glob/grep)', () => {
+ let dir: string;
+ let env: AgentToolContext;
+
+ beforeEach(() => {
+ dir = tmpdir();
+ env = { workdir: dir };
+ fs.mkdirSync(path.join(dir, 'sub'), { recursive: true });
+ fs.writeFileSync(path.join(dir, 'a.txt'), 'alpha\nbeta\n');
+ fs.writeFileSync(path.join(dir, 'sub', 'b.ts'), 'gamma\n');
+ });
+ afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
+
+ test('glob with **/*.ts finds nested TypeScript files', async () => {
+ const out = await betaGlobTool(env).run({ pattern: '**/*.ts' });
+ expect(out).toContain(path.join(dir, 'sub', 'b.ts'));
+ expect(out).not.toContain('a.txt');
+ });
+
+ test('glob returns the literal "no matches" string when nothing matches', async () => {
+ const out = await betaGlobTool(env).run({ pattern: '**/*.nomatch' });
+ expect(out).toBe('no matches');
+ });
+
+ test('glob with a path argument searches only under that subdirectory', async () => {
+ const out = await betaGlobTool(env).run({ pattern: '*.ts', path: 'sub' });
+ expect(out).toContain(path.join(dir, 'sub', 'b.ts'));
+ expect(out).not.toContain('a.txt');
+ });
+
+ test('glob rejects a ".." pattern that would walk fs.glob out of the workdir', async () => {
+ await expect(betaGlobTool(env).run({ pattern: '../*' })).rejects.toThrow(/not permitted in the pattern/);
+ await expect(betaGlobTool(env).run({ pattern: '../../**/*.ts' })).rejects.toThrow(
+ /not permitted in the pattern/,
+ );
+ });
+
+ test('grep finds a line and reports file:lineno:content (works with or without rg on PATH)', async () => {
+ const out = await betaGrepTool(env).run({ pattern: 'beta' });
+ expect(out).toMatch(/a\.txt:2:beta/);
+ });
+});
+
+const describeBash = process.platform === 'win32' ? describe.skip : describe;
+
+describeBash('betaBashTool', () => {
+ let dir: string;
+ let tool: BetaRunnableTool<{ command?: string; restart?: boolean; timeout_ms?: number }>;
+
+ beforeEach(() => {
+ dir = tmpdir();
+ tool = betaBashTool({ workdir: dir });
+ });
+ afterEach(() => {
+ tool.close?.();
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ test('exporting a variable in one call makes it readable in a later call (state persists across the closure-held session)', async () => {
+ expect(await tool.run({ command: 'export FOO=bar; echo set' })).toBe('set');
+ expect(await tool.run({ command: 'echo $FOO' })).toBe('bar');
+ });
+
+ test('a non-zero exit throws ToolError carrying the captured output', async () => {
+ await expect(tool.run({ command: 'echo oops; (exit 7)' })).rejects.toThrow(/oops/);
+ });
+
+ test('restart drops state so a previously exported variable is no longer set', async () => {
+ await tool.run({ command: 'export FOO=bar' });
+ expect(await tool.run({ command: 'echo $FOO', restart: true })).toBe('');
+ });
+
+ test('a timed-out call discards the corrupted session so the next call starts a fresh shell instead of seeing the stale sentinel', async () => {
+ await expect(tool.run({ command: 'sleep 2', timeout_ms: 200 })).rejects.toThrow(/timed out/);
+ expect(await tool.run({ command: 'echo recovered' })).toBe('recovered');
+ });
+
+ test('the spawned shell does not inherit ANTHROPIC_* credential env vars', async () => {
+ // The runner process holds Anthropic credentials in ANTHROPIC_* env vars;
+ // an unrestricted shell must not be able to read them back out.
+ process.env['ANTHROPIC_API_KEY'] = 'sk-ant-should-not-leak';
+ const scoped = betaBashTool({ workdir: dir });
+ try {
+ expect(await scoped.run({ command: 'echo "[$ANTHROPIC_API_KEY]"' })).toBe('[]');
+ // Non-credential vars (PATH etc.) still pass through.
+ expect(await scoped.run({ command: 'test -n "$PATH" && echo has-path' })).toBe('has-path');
+ } finally {
+ scoped.close?.();
+ delete process.env['ANTHROPIC_API_KEY'];
+ }
+ });
+
+ test('ctx.env FULLY REPLACES the scrubbed default environment (verbatim, not merged)', async () => {
+ // A var that exists in the real process env must NOT leak through when the
+ // caller supplies its own env — the mapping is used verbatim.
+ process.env['LEAKY_VAR'] = 'should-not-be-visible';
+ const scoped = betaBashTool({ workdir: dir, env: { ONLY_THIS: 'yes', PATH: process.env['PATH'] } });
+ try {
+ expect(await scoped.run({ command: 'echo "[$ONLY_THIS]"' })).toBe('[yes]');
+ // Not merged with the scrubbed process env — LEAKY_VAR is absent.
+ expect(await scoped.run({ command: 'echo "[$LEAKY_VAR]"' })).toBe('[]');
+ } finally {
+ scoped.close?.();
+ delete process.env['LEAKY_VAR'];
+ }
+ });
+
+ test('concurrent run() calls are serialized so the shared shells stdin is not interleaved (e.g. BetaToolRunner Promise.all)', async () => {
+ // Each call prints a unique marker. With no mutex, both commands write to
+ // the same stdin and the sentinel-match logic would attribute output to
+ // the wrong call. With the mutex, the second call only runs once the
+ // first has finished, so outputs are clean and ordering is stable.
+ const results = await Promise.all([
+ tool.run({ command: "sleep 0.05; printf 'A'" }),
+ tool.run({ command: "sleep 0.05; printf 'B'" }),
+ ]);
+ expect(results).toEqual(['A', 'B']);
+ });
+});
+
+describeBash('BashSession (direct)', () => {
+ let dir: string;
+ let session: BashSession;
+
+ beforeEach(() => {
+ dir = tmpdir();
+ session = new BashSession(dir);
+ });
+ afterEach(() => {
+ session.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ test('changing directory persists so a later pwd reports the new location', async () => {
+ await session.exec('cd /tmp');
+ const r = await session.exec('pwd');
+ expect(r.output).toMatch(/\/tmp$/);
+ });
+
+ test('a non-zero exit is surfaced via exitCode without throwing', async () => {
+ const r = await session.exec('(exit 7)');
+ expect(r.exitCode).toBe(7);
+ });
+
+ test('exec rejects when the command exceeds the timeout budget', async () => {
+ await expect(session.exec('sleep 2', { timeoutMs: 200 })).rejects.toThrow(/timed out/);
+ });
+
+ test('a command that prints a hardcoded sentinel-like marker cannot truncate its own output or spoof the exit code', async () => {
+ const r = await session.exec("printf '__ANT_CMD_DONE__7\\nafter\\n'; (exit 3)");
+ expect(r.output).toContain('__ANT_CMD_DONE__7');
+ expect(r.output).toContain('after');
+ expect(r.exitCode).toBe(3);
+ });
+
+ test('a command that reads stdin gets immediate EOF instead of hanging until the timeout', async () => {
+ const r = await session.exec('cat; echo done', { timeoutMs: 2000 });
+ expect(r.output).toBe('done');
+ expect(r.exitCode).toBe(0);
+ });
+
+ test('a command that streams more output than the cap is truncated rather than buffered unbounded', async () => {
+ const r = await session.exec("head -c 300000 /dev/zero | tr '\\0' a; echo END");
+ expect(r.output).toMatch(/^\[output truncated\]\n/);
+ expect(r.output).toMatch(/END$/);
+ expect(r.output.length).toBeLessThanOrEqual(101 * 1024);
+ expect(r.exitCode).toBe(0);
+ });
+});
diff --git a/tests/tools/skills.test.ts b/tests/tools/skills.test.ts
new file mode 100644
index 00000000..0ac78712
--- /dev/null
+++ b/tests/tools/skills.test.ts
@@ -0,0 +1,95 @@
+import * as fs from 'node:fs';
+import * as fsp from 'node:fs/promises';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { execFileSync } from 'node:child_process';
+import { extractSkillArchive } from '@anthropic-ai/sdk/tools/agent-toolset/node';
+
+/**
+ * Skill version archives are packaged wrapped in a single directory named
+ * after the skill (e.g. `pdf/SKILL.md`). Extraction must strip that wrapper so
+ * files land at `dest/SKILL.md`, not the doubled `dest/pdf/SKILL.md` the
+ * agent's skill discovery does not find. It must also still refuse zip-slip.
+ */
+describe('extractSkillArchive', () => {
+ let work: string;
+
+ beforeEach(() => {
+ work = fs.mkdtempSync(path.join(os.tmpdir(), 'skilltest-'));
+ });
+ afterEach(() => {
+ fs.rmSync(work, { recursive: true, force: true });
+ });
+
+ /** Lay out `files` under a temp tree, then pack it with the given CLI. */
+ function pack(kind: 'zip' | 'targz', files: Record): Buffer {
+ const src = fs.mkdtempSync(path.join(os.tmpdir(), 'skillsrc-'));
+ try {
+ for (const [rel, body] of Object.entries(files)) {
+ const p = path.join(src, rel);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, body);
+ }
+ const archive = path.join(work, `a.${kind === 'zip' ? 'zip' : 'tgz'}`);
+ if (kind === 'zip') {
+ execFileSync('zip', ['-rq', archive, '.'], { cwd: src });
+ } else {
+ execFileSync('tar', ['-czf', archive, '-C', src, '.']);
+ }
+ const buf = fs.readFileSync(archive);
+ fs.rmSync(archive, { force: true });
+ return buf;
+ } finally {
+ fs.rmSync(src, { recursive: true, force: true });
+ }
+ }
+
+ async function extractInto(buf: Buffer, dest: string): Promise {
+ await fsp.mkdir(dest, { recursive: true });
+ await extractSkillArchive(new Response(buf), dest);
+ }
+
+ for (const kind of ['zip', 'targz'] as const) {
+ test(`${kind}: strips the skill wrapper directory (no doubling)`, async () => {
+ const buf = pack(kind, {
+ 'pdf/SKILL.md': '# PDF',
+ 'pdf/scripts/run.py': 'print(1)',
+ });
+ const dest = path.join(work, 'skills', 'pdf');
+ await extractInto(buf, dest);
+
+ expect(fs.readFileSync(path.join(dest, 'SKILL.md'), 'utf8')).toBe('# PDF');
+ expect(fs.readFileSync(path.join(dest, 'scripts', 'run.py'), 'utf8')).toBe('print(1)');
+ expect(fs.existsSync(path.join(dest, 'pdf'))).toBe(false);
+ });
+
+ test(`${kind}: flat archive (no wrapper) extracts unchanged`, async () => {
+ const buf = pack(kind, { 'SKILL.md': '# flat', 'scripts/run.py': 'x' });
+ const dest = path.join(work, 'skills', 'flat');
+ await extractInto(buf, dest);
+ expect(fs.readFileSync(path.join(dest, 'SKILL.md'), 'utf8')).toBe('# flat');
+ expect(fs.readFileSync(path.join(dest, 'scripts', 'run.py'), 'utf8')).toBe('x');
+ });
+ }
+
+ test('refuses a zip-slip member', async () => {
+ const src = fs.mkdtempSync(path.join(os.tmpdir(), 'evil-'));
+ const archive = path.join(work, 'evil.zip');
+ try {
+ fs.writeFileSync(path.join(src, 'escape.txt'), 'pwned');
+ execFileSync('zip', ['-q', archive, 'escape.txt'], { cwd: src });
+ // Rewrite the entry name to a traversal path via zipnote.
+ execFileSync('zipnote', ['-w', archive], { input: '@ escape.txt\n@=../escape.txt\n' });
+
+ const dest = path.join(work, 'skills', 'x');
+ await fsp.mkdir(dest, { recursive: true });
+ await expect(extractSkillArchive(new Response(fs.readFileSync(archive)), dest)).rejects.toThrow(
+ /unsafe archive member/,
+ );
+ expect(fs.existsSync(path.join(work, 'skills', 'escape.txt'))).toBe(false);
+ expect(fs.existsSync(path.join(work, 'escape.txt'))).toBe(false);
+ } finally {
+ fs.rmSync(src, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/tests/uploads.test.ts b/tests/uploads.test.ts
index 2e94efaf..8e23e356 100644
--- a/tests/uploads.test.ts
+++ b/tests/uploads.test.ts
@@ -1,7 +1,6 @@
import fs from 'fs';
import type { ResponseLike } from '@anthropic-ai/sdk/internal/to-file';
import { toFile } from '@anthropic-ai/sdk/core/uploads';
-import { File } from 'node:buffer';
class MyClass {
name: string = 'foo';
diff --git a/yarn.lock b/yarn.lock
index b8158ccb..6fdb9bd9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3858,9 +3858,9 @@ ts-node@^10.5.0:
v8-compile-cache-lib "^3.0.0"
yn "3.1.1"
-"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz":
- version "1.1.9"
- resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz#777f6f5d9e26bf0e94e5170990dd3a841d6707cd"
+"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz":
+ version "1.1.11"
+ resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz#010247051be13b55abdc98f787c017285149f4f2"
dependencies:
debug "^4.3.7"
fast-glob "^3.3.2"