self-hosted-sandboxes-workers changedmanaged-agents/self-hosted-sandboxes-workers
Nearest release: v2.1.288, published 3 hours after this site recorded the change. Shown because the two are within 24 hours of each other. Nothing here says the release caused the edit.
## Choose a deployment pattern ## Run an always-on worker ## Trigger workers from webhooks ## Run one sandbox per session ### Forward the work item's secret ### Launch sandboxes from the SDK poller ### Run the SDK worker inside the sandbox ## Stage files for a session ## Next steps
The whole hunk
919 lines, new pageA whole new page. There's nothing to diff it against, so here is what it says.
---
title: Deploy self-hosted workers
url: https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes-workers
description: "Choose how self-hosted sandbox workers claim work and where sessions run: always-on or webhook-triggered, in one process or one sandbox per session."
featureMetadata:
topic:
title: Managed Agents
url: https://platform.claude.com/docs/en/managed-agents/overview
status: beta
betaHeader: managed-agents-2026-04-01
---
The [quickstart](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#quickstart) runs one `ant` CLI worker that polls continuously and runs every session in one process. This page covers the other ways to run a worker and how to choose between them.
## Choose a deployment pattern
When deploying workers, you need to make two choices: how the worker claims work, and where each session runs.
**How the worker claims work:**
* **Always-on:** A long-running process polls the queue continuously and needs only outbound HTTPS. This is the simplest setup.
* **Webhook-triggered:** A handler wakes on `session.status_run_started` and starts polling. This avoids an idle poller, but requires a [webhook](https://platform.claude.com/docs/en/managed-agents/webhooks) endpoint that Anthropic can reach.
**Where each session runs:**
* **In process:** The worker that claims a session also runs its tool calls, in one shared working directory.
* **Sandbox per session:** A poller launches a fresh sandbox for each claimed session. Choose this for stronger isolation: a fresh filesystem, resource limits, or per-session network controls.
The CLI and SDK workers support different combinations:
| Capability | `ant` CLI | SDK (Python, TypeScript, Go) |
| ----------------------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------- |
| Always-on polling | Yes | Yes |
| Webhook-triggered | No | Yes |
| Sandbox per session | Yes | Yes |
| [Memory stores](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes-memory) | Yes, with default sync settings | Yes, with configurable sync |
| [Custom tools](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes-custom-tools) | No | Yes |
See [Self-hosted worker reference](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes-reference) for every CLI flag and SDK option. For more control, call the [Environments Work endpoints](https://platform.claude.com/docs/en/api/beta/environments/work) directly and implement your own worker.
## Run an always-on worker
Both workers authenticate with the [environment key](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-your-first-session) from the quickstart.
With the `ant` CLI:
```bash
ant beta:worker poll --workdir /workspace
```
With the SDK, `EnvironmentWorker` does the same work:
<CodeGroup exclude="shell">
```python Python
import asyncio
import contextlib
import os
import signal
from anthropic import AsyncAnthropic
from anthropic.lib.environments import EnvironmentWorker
async def main() -> None:
environment_key = os.environ["ANTHROPIC_ENVIRONMENT_KEY"]
environment_id = os.environ["ANTHROPIC_ENVIRONMENT_ID"]
async with AsyncAnthropic(auth_token=environment_key) as client:
worker = EnvironmentWorker(
client,
environment_id=environment_id,
environment_key=environment_key,
workdir="/workspace",
)
task = asyncio.create_task(worker.run())
# Cancelling the task, rather than killing the process, lets the worker stop its
# in-flight work item and upload changed memory files before it exits.
loop = asyncio.get_running_loop()
for signum in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(signum, task.cancel)
with contextlib.suppress(asyncio.CancelledError):
await task
asyncio.run(main())
```
```typescript TypeScript
import Anthropic from "@anthropic-ai/sdk";
import { EnvironmentWorker } from "@anthropic-ai/sdk/helpers/beta/environments";
const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY!;
const environmentId = process.env.ANTHROPIC_ENVIRONMENT_ID!;
const client = new Anthropic({ authToken: environmentKey });
const controller = new AbortController();
// Aborting on either signal lets the worker upload changed memory files and remove its
// store directories before the process exits.
process.once("SIGINT", () => controller.abort());
process.once("SIGTERM", () => controller.abort());
await new EnvironmentWorker({
client,
environmentId,
environmentKey,
workdir: "/workspace",
signal: controller.signal
}).run();
```
```csharp C#
// EnvironmentWorker is not currently available in the C# SDK. Use the ant CLI worker instead.
```
```go Go
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/lib/environments"
"github.com/anthropics/anthropic-sdk-go/option"
)
func main() {
environmentKey := os.Getenv("ANTHROPIC_ENVIRONMENT_KEY")
environmentID := os.Getenv("ANTHROPIC_ENVIRONMENT_ID")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
client := anthropic.NewClient(option.WithAuthToken(environmentKey))
worker := environments.NewEnvironmentWorker(client, environments.EnvironmentWorkerOptions{
EnvironmentID: environmentID,
EnvironmentKey: environmentKey,
Workdir: "/workspace",
})
if err := worker.Run(ctx); err != nil {
log.Fatalf("worker: %v", err)
}
}
```
```java Java
// EnvironmentWorker is not currently available in the Java SDK. Use the ant CLI worker instead.
```
```php PHP
// EnvironmentWorker is not currently available in the PHP SDK. Use the ant CLI worker instead.
```
```ruby Ruby
# EnvironmentWorker is not currently available in the Ruby SDK. Use the ant CLI worker instead.
```
</CodeGroup>
## Trigger workers from webhooks
<Steps>
<Step title="Subscribe to session webhooks">
In the [Console](https://platform.claude.com/settings/workspaces/default/webhooks), define a webhook endpoint that listens for `session.status_run_started` events. See [Webhooks](https://platform.claude.com/docs/en/managed-agents/webhooks) for details.
</Step>
<Step title="Export the webhook signing key">
Along with the environment ID and key from the [quickstart](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-your-first-session), export the webhook signing key on your handler host. The handler uses it to verify incoming payloads.
```bash
export ANTHROPIC_WEBHOOK_SIGNING_KEY="whsec_..."
```
</Step>
<Step title="Implement the webhook handler">
Invoke the worker when `session.status_run_started` fires. The handler drains the queue and hands each claimed work item to `handle_item()` (typescript: `handleItem()`; go: `HandleItem()`), which downloads skills, executes tool calls, posts results back, and returns.
<CodeGroup exclude="shell">
<CodeGroupItem>
To verify webhook signatures, install the webhooks extra: `pip install "anthropic[webhooks]"`.
```python Python
import asyncio
import os
import anthropic
import standardwebhooks # installed by the anthropic[webhooks] extra
environment_key = os.environ["ANTHROPIC_ENVIRONMENT_KEY"]
environment_id = os.environ["ANTHROPIC_ENVIRONMENT_ID"]
client = anthropic.AsyncAnthropic(
auth_token=environment_key,
)
# Cancelled by shutdown() so an in-flight work item can upload changed memory files and
# remove its store directories before the process exits.
inflight: set[asyncio.Task[None]] = set()
# Await this from the host's shutdown hook, such as an ASGI lifespan shutdown (the code after
# `yield` in a FastAPI lifespan), which uvicorn runs on SIGTERM. uvicorn lets open requests
# finish before that hook runs, so set --timeout-graceful-shutdown to bound the wait.
async def shutdown() -> None:
for task in inflight:
task.cancel()
await asyncio.gather(*inflight, return_exceptions=True)
async def handle(raw: bytes, headers: dict[str, str]) -> tuple[dict[str, str], int]:
try:
event = client.beta.webhooks.unwrap(raw.decode(), headers=headers)
except standardwebhooks.WebhookVerificationError:
return {"error": "signature verification failed"}, 401
if event.data.type != "session.status_run_started":
return {"status": "ignored"}, 200
task = asyncio.create_task(run_queued_work())
inflight.add(task)
task.add_done_callback(inflight.discard)
try:
# Shielded: a dropped or timed-out delivery must not cancel the item; shutdown() does.
await asyncio.shield(task)
except asyncio.CancelledError:
return {"status": "shutting down"}, 503
return {"status": "ok"}, 200
async def run_queued_work() -> None:
async for work in client.beta.environments.work.poller(
environment_id=environment_id,
environment_key=environment_key,
block_ms=None,
reclaim_older_than_ms=2000,
drain=True,
auto_stop=False,
):
await client.beta.environments.work.worker(workdir="/workspace").handle_item(
work_id=work.id,
environment_id=environment_id,
session_id=work.data.id,
environment_key=environment_key,
# The per-session secret is what lets the worker mount the session's memory stores.
work_secret=work.secret,
)
```
</CodeGroupItem>
```typescript TypeScript
import Anthropic from "@anthropic-ai/sdk";
const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY!;
const environmentId = process.env.ANTHROPIC_ENVIRONMENT_ID!;
const client = new Anthropic({
authToken: environmentKey
});
// Call shutdown.abort() from the host's SIGTERM/SIGINT handler, alongside closing the server,
// then wait for in-flight handle() calls before exiting: the abort lets a running work item
// upload changed memory files and remove its store directories first.
export const shutdown = new AbortController();
export async function handle(req: Request): Promise<Response> {
// Never acknowledge a delivery whose work will not run here; a 503 makes the sender retry.
if (shutdown.signal.aborted) {
return Response.json({ status: "shutting down" }, { status: 503 });
}
const body = await req.text();
let event;
try {
event = client.beta.webhooks.unwrap(body, { headers: Object.fromEntries(req.headers) });
} catch {
return new Response("signature verification failed", { status: 401 });
}
if (event.data.type !== "session.status_run_started") {
return Response.json({ status: "ignored" });
}
for await (const work of client.beta.environments.work.poller({
environmentId,
environmentKey,
blockMs: null,
reclaimOlderThanMs: 2000,
drain: true,
autoStop: false,
signal: shutdown.signal
})) {
await client.beta.environments.work.worker({ workdir: "/workspace" }).handleItem({
workId: work.id,
environmentId,
sessionId: work.data.id,
environmentKey,
// The per-session secret is what lets the worker mount the session's memory stores.
workSecret: work.secret ?? undefined,
signal: shutdown.signal
});
}
// The poller and handleItem return quietly on abort, so a drain cut short lands here.
if (shutdown.signal.aborted) {
return Response.json({ status: "shutting down" }, { status: 503 });
}
return Response.json({ status: "ok" });
}
```
Cut at 300 lines. The page has the rest.
No line in this hunk matches that.