spawn.sh: called once per claimed work item
managed-agents/self-hosted-sandboxes
Nearest release: v2.1.237, published an hour 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.
managed-agents/self-hosted-sandboxes Changed · +440 / -39 lines
## Use memory stores ### How the worker handles memory ### Prepare the host ### Run one sandbox per session ### Configure sync ### Read-only stores and conflicts ### Troubleshoot memory mounts
The two sides of this change are too far apart to line up, so this is the differ's own diff of it.
By default, Managed Agents executes tools and code inside [Anthropic-managed cloud sandboxes](https://platform.claude.com/docs/en/managed-agents/cloud-sandboxes-reference). Self-hosted sandboxes keep the orchestration on Anthropic's side but move tool execution into infrastructure you control, so the agent's code, filesystem, and network egress never leave your environment. -Tool execution stays on your host: the filesystem the agent reads and writes, the processes it spawns, and the network it can reach are all under your control. Tool inputs and outputs still flow to Anthropic's control plane (where Claude runs) so the model can see results and determine what to do next. See the [security model](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes-security) for the full data-flow boundary. +Tool execution stays on your host: the filesystem the agent reads and writes, the processes it spawns, and the network it can reach are all under your control. Tool inputs and outputs still flow to Anthropic's control plane (where Claude runs) so the model can see results and determine what to do next. The agent's [skills](https://platform.claude.com/docs/en/managed-agents/skills) and the contents of any [memory stores](https://platform.claude.com/docs/en/managed-agents/memory) attached to the session are stored by Anthropic and copied into your sandbox for the session; changes the agent makes to memory files sync back to the store. See the [security model](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes-security) for the full data-flow boundary. <Note> Self-hosted sandboxes support all Claude models available in Managed Agents, including Claude Opus 4.8 and Claude Opus 5. The model is configured on the [agent](https://platform.claude.com/docs/en/managed-agents/agent-setup), not the environment.
## How it differs from cloud environments -| | Cloud environment | Self-hosted sandbox | -| ----------------------------- | --------------------------- | ------------------- | -| Where tools run | Anthropic-managed sandboxes | Your infrastructure | -| Network reach | Anthropic's egress controls | Your network policy | -| File and GitHub repo mounting | Managed by Anthropic | Managed by you | -| Lifecycle | Managed by Anthropic | Managed by you | +| | Cloud environment | Self-hosted sandbox | +| ----------------------------- | -------------------------------------- | --------------------------------------------------------- | +| Where tools run | Anthropic-managed sandboxes | Your infrastructure | +| Network reach | Anthropic's egress controls | Your network policy | +| File and GitHub repo mounting | Managed by Anthropic | Managed by you | +| Memory stores | Mounted by Anthropic at `/mnt/memory/` | Downloaded to `/mnt/memory/` and synced by the SDK worker | +| Lifecycle | Managed by Anthropic | Managed by you | Self-hosting is a good fit when the agent needs to operate on data that cannot leave your network boundary, reach internal services that are not publicly routable, or run under your organization's own compliance and audit controls.
* **`/workspace`:** the system default working directory for tool execution and skill download. The CLI's `--workdir` flag defaults to the current directory; pass `--workdir /workspace` to match the system default. Skills are downloaded to `<workdir>/skills/<name>/`. If you use a different working directory, update your agent's system prompt so Claude can locate the skill files. * **Outputs:** on self-hosted environments the session's system prompt omits the `/mnt/session/outputs` instruction used on Anthropic-managed sandboxes, so final deliverables land wherever the agent writes them in your sandbox filesystem, typically under the working directory. +* **`/mnt/memory/`:** memory stores attached to the session are materialized here by the SDK worker, one directory per store at the store's `mount_path` (for example, `/mnt/memory/user-preferences/`). The worker creates these directories when it claims the session and removes them when the session ends; see [Use memory stores](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#use-memory-stores). ## Before you begin
* **An existing agent.** If you don't have one, complete the [Quickstart](https://platform.claude.com/docs/en/managed-agents/quickstart) first and note its agent ID. * **A Linux host** with `/bin/bash` at that exact path. The worker's bash tool invokes it directly, without consulting `PATH`. The TypeScript SDK additionally requires `unzip` and `tar` on the `PATH` and Node.js 22 or later; the Python and Go SDKs use their standard libraries for archive extraction and have no additional binary requirements. * **The `ant` CLI or an Anthropic SDK** (Python, TypeScript, or Go) on the worker host. -* **Two credentials:** an environment key (generated in the Console in the steps that follow) authenticates the worker to its queue; your Claude API key creates sessions and reads queue stats from outside the worker host. Key generation is Console-only. +* **Credentials:** an environment key (generated in the Console in the steps that follow) authenticates the worker to its queue; your Claude API key creates sessions and reads queue stats from outside the worker host. Key generation is Console-only. Claimed work items also carry a per-session `secret` that the worker uses to mount [memory stores](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#use-memory-stores); you don't generate it, but in the sandbox-per-session pattern you forward it into the sandbox yourself (see [Run one sandbox per session](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-one-sandbox-per-session)). +* **For memory stores, a prepared host.** If sessions on this environment will attach memory stores, prepare `/mnt/memory` on the worker host before you start the worker; see [Prepare the host](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#prepare-the-host). <Note> On [Claude Platform on AWS](https://platform.claude.com/docs/en/build-with-claude/claude-platform-on-aws), the worker authenticates with AWS IAM (SigV4) or an [API key generated in the AWS Console](https://platform.claude.com/docs/en/build-with-claude/claude-platform-on-aws#api-key-authentication), not an environment key. Attach the [`AnthropicSelfHostedEnvironmentAccess`](https://platform.claude.com/docs/en/api/claude-platform-on-aws-iam-actions#managed-policies) managed policy to the IAM principal your worker runs as. Environment keys generated in the Claude Console don't work with the Claude Platform on AWS endpoint. + + Memory stores cannot be attached to sessions on self-hosted environments on Claude Platform on AWS. </Note> <Steps>
ENTRYPOINT ["ant", "beta:worker", "run"] ``` - Then write a spawn script that forwards session details into a fresh sandbox. The poller injects `ANTHROPIC_SESSION_ID`, `ANTHROPIC_WORK_ID`, `ANTHROPIC_ENVIRONMENT_ID`, and `ANTHROPIC_ENVIRONMENT_KEY` into the script's environment. `ANTHROPIC_BASE_URL` is optional and is passed through only if it was set on the poller host; it overrides the default API endpoint. In the example, `/host/outputs` is a host directory you choose; it is bind-mounted to the sandbox's working directory (`/workspace`) so you can retrieve session deliverables after the sandbox exits. On self-hosted environments the agent writes deliverables under the working directory rather than `/mnt/session/outputs` (see [Sandbox filesystem](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#sandbox-filesystem)), so mounting the working directory is what captures them; the mount also picks up the downloaded `skills/` tree and any intermediate files the agent creates. + Then write a spawn script that forwards session details into a fresh sandbox. The poller injects `ANTHROPIC_SESSION_ID`, `ANTHROPIC_WORK_ID`, `ANTHROPIC_ENVIRONMENT_ID`, and `ANTHROPIC_ENVIRONMENT_KEY` into the script's environment, and writes the claimed work item to the script's standard input as JSON, including the work item's per-session `secret` when Anthropic issued one. `ANTHROPIC_BASE_URL` is optional and is passed through only if it was set on the poller host; it overrides the default API endpoint. In the example, `/host/outputs` is a host directory you choose; it is bind-mounted to the sandbox's working directory (`/workspace`) so you can retrieve session deliverables after the sandbox exits. On self-hosted environments the agent writes deliverables under the working directory rather than `/mnt/session/outputs` (see [Sandbox filesystem](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#sandbox-filesystem)), so mounting the working directory is what captures them; the mount also picks up the downloaded `skills/` tree and any intermediate files the agent creates. ```bash #!/bin/bash
your-image ``` + The `ant beta:worker run` entrypoint does not mount [memory stores](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#use-memory-stores). If sessions on this environment attach memory stores, keep the poller, but build the per-session image around the SDK worker and extend the spawn script to forward the work item's `secret` into the sandbox, as shown in [Run one sandbox per session](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-one-sandbox-per-session). + Start the poller pointing at the script: ```bash
<CodeGroup exclude="shell"> ```python Python import asyncio + import contextlib import os + import signal from anthropic import AsyncAnthropic from anthropic.lib.environments import EnvironmentWorker
environment_key = os.environ["ANTHROPIC_ENVIRONMENT_KEY"] environment_id = os.environ["ANTHROPIC_ENVIRONMENT_ID"] async with AsyncAnthropic(auth_token=environment_key) as client: - await EnvironmentWorker( + worker = EnvironmentWorker( client, environment_id=environment_id, environment_key=environment_key, workdir="/workspace", - ).run() + ) + 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())
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({
<Step title="Implement the webhook handler"> `EnvironmentWorker` claims the work item, downloads skills, executes tool calls in the working directory, posts results back, and exits. Invoke it when `session.status_run_started` fires. + When you hand a claimed work item to `handle_item()` yourself, as this handler does, pass the work item's `secret` along as `work_secret` (`workSecret` in TypeScript, `WorkSecret` in Go) so the session can mount any [memory stores](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#use-memory-stores) attached to it. A handler like this one runs every claimed item in one process on one host, so two sessions that attach the same memory store cannot run through it at the same time (see [Prepare the host](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#prepare-the-host)); if your sessions share stores, launch [one sandbox per session](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-one-sandbox-per-session) instead. + <CodeGroup exclude="shell"> ```python Python + import asyncio import os + import signal 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, ) - - - async def handle(raw: bytes, headers: dict[str, str]) -> dict: - event = client.beta.webhooks.unwrap(raw.decode(), headers=headers) + # Cancelled on SIGINT or SIGTERM (wired in handle) 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() + + + def cancel_inflight() -> None: + for task in inflight: + task.cancel() + + + 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"} + return {"status": "ignored"}, 200 + loop = asyncio.get_running_loop() + for signum in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(signum, cancel_inflight) + task = asyncio.create_task(run_queued_work()) + inflight.add(task) + task.add_done_callback(inflight.discard) + try: + await 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,
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, ) - return {"status": "ok"} ``` ```typescript TypeScript
const client = new Anthropic({ authToken: environmentKey }); + // Aborted on SIGINT or SIGTERM so an in-flight work item can upload changed memory + // files and remove its store directories before the process exits. + const shutdown = new AbortController(); + process.once("SIGINT", () => shutdown.abort()); + process.once("SIGTERM", () => shutdown.abort()); export async function handle(req: Request): Promise<Response> { const body = await req.text();
blockMs: null, reclaimOlderThanMs: 2000, drain: true, - autoStop: false + autoStop: false, + signal: shutdown.signal })) { await client.beta.environments.work.worker({ workdir: "/workspace" }).handleItem({ workId: work.id, environmentId, sessionId: work.data.id, - environmentKey + environmentKey, + // The per-session secret is what lets the worker mount the session's memory stores. + workSecret: work.secret ?? undefined, + signal: shutdown.signal }); } return Response.json({ status: "ok" });
import ( "context" "encoding/json" + "errors" "io" "log/slog" "net/http" "os" + "os/signal" + "syscall" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/lib/environments"
worker = environments.NewEnvironmentWorker(client, environments.EnvironmentWorkerOptions{ Workdir: "/workspace", }) + // Cancelled on SIGINT or SIGTERM (set in main) so an in-flight work item can + // upload changed memory files and remove its store directories before exit. + shutdown context.Context ) func handle(w http.ResponseWriter, r *http.Request) {
// The Go SDK does not provide a RunOne convenience: drain pending items // with WorkPoller and run each one with HandleItem. // Detach from r.Context(): the session can outlive the webhook delivery timeout. - ctx := context.Background() + // The process-wide shutdown context still ends the item cleanly on SIGTERM. + ctx := shutdown poller := environments.NewWorkPoller(ctx, client, environments.WorkPollerOptions{ EnvironmentID: environmentID, EnvironmentKey: environmentKey, BlockMs: param.Null[int64](), ReclaimOlderThanMs: param.NewOpt[int64](2000), Drain: true, + AutoStop: param.NewOpt(false), }) defer poller.Close() for poller.Next() {
EnvironmentID: item.EnvironmentID, SessionID: item.Data.ID, EnvironmentKey: environmentKey, + // The per-session secret is what lets the worker mount the session's memory stores. + WorkSecret: item.Secret, }); err != nil { slog.Error("handle work item", "work_id", item.ID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError)
} func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + shutdown = ctx + + server := &http.Server{Addr: ":8080"} http.HandleFunc("POST /webhook", handle) - if err := http.ListenAndServe(":8080", nil); err != nil { - slog.Error("http server", "err", err) - os.Exit(1) + go func() { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("http server", "err", err) + os.Exit(1) + } + }() + // On a signal, stop accepting deliveries and return only after in-flight + // handlers, and therefore their work items' memory teardown, have finished. + <-ctx.Done() + if err := server.Shutdown(context.Background()); err != nil { + slog.Error("http shutdown", "err", err) } }
* **`EnvironmentWorker`:** the out-of-the-box worker. Handles polling, setup, and execution end to end. * `.run()`: runs indefinitely, picking up sessions as they arrive. - * `.handle_item()`: handles a single claimed work item and exits. Pass the work, session, and environment identifiers explicitly, or let it read the `ANTHROPIC_*` variables that `ant beta:worker poll --on-work` sets for the process it spawns. + * `.handle_item()`: handles a single claimed work item and exits. Pass the work, session, and environment identifiers explicitly, or let it read the `ANTHROPIC_*` variables that `ant beta:worker poll --on-work` sets for the process it spawns. To let the session mount its [memory stores](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#use-memory-stores), also pass the work item's `secret` as `work_secret` (`workSecret` in TypeScript, `WorkSecret` in Go) or set `ANTHROPIC_WORK_SECRET`; `ant beta:worker poll --on-work` does not set that variable, so read the secret from the work item JSON it writes to your script's standard input, as shown in [Run one sandbox per session](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-one-sandbox-per-session). + * `memory_sync_interval` (`memorySyncIntervalMs` in TypeScript, `MemorySyncInterval` in Go) and `memory_sync_deletes` (`memoryRemoteDeletes`, `MemorySyncDeletes`): how often attached memory stores reconcile with the server while the session runs, and whether files the agent deletes locally are also deleted from the store. See [Configure sync](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#configure-sync) for units, defaults, and how to disable memory support. * **`work.poller()`:** polls the work queue on your behalf and gives you each claimed session. Use this when you want to decide what happens for each session, for example launching a sandbox rather than running tools in-process. * `drain`: whether to stop polling once the queue is empty rather than waiting for new work. * `block_ms`: how long to wait for work to arrive before returning, in milliseconds. Must be between 1 and 999 (per-poll wait; the helper re-polls automatically). Pass `null` (`None` in Python, `param.Null[int64]()` in Go) for a non-blocking check; omitting the parameter uses the default 999 ms long-poll. * `reclaim_older_than_ms`: re-claim work items that were claimed but never acknowledged within this many milliseconds. - * `auto_stop`: whether to post a stop signal for each work item once your loop body finishes with it. The Go poller has no opt-out and always posts the stop signal, so block in the loop body until the session completes rather than detaching. + * `auto_stop` (`autoStop` in TypeScript, `AutoStop` in Go): whether to post a stop signal for each work item once your loop body finishes with it. Turn it off when the process you launch for the session, rather than the loop body, runs the work item to completion. * **`client.beta.sessions.events.tool_runner()`:** runs tool calls for a single session, given the session ID and a tool list. Use when you've already claimed the work and only need the execution layer.
from anthropic import AsyncAnthropic from anthropic.types.beta.environments import BetaSelfHostedWork + SANDBOX_ENV = ( + "ANTHROPIC_ENVIRONMENT_ID", + "ANTHROPIC_ENVIRONMENT_KEY", + "ANTHROPIC_WORK_ID", + "ANTHROPIC_SESSION_ID", + "ANTHROPIC_WORK_SECRET", + "ANTHROPIC_BASE_URL", # forwarded only when set on this host + ) + async def launch_container(work: BetaSelfHostedWork) -> None: - # Replace with your own per-session sandbox launcher. Pass - # ANTHROPIC_ENVIRONMENT_KEY into the launched sandbox, never - # your API key. print(f"claimed session {work.data.id}") + # Replace `docker run` with your own sandbox launcher. Forward the environment + # key (never your API key) and the work item's per-session secret: the worker + # inside needs the secret to mount the session's memory stores. + env = os.environ | { + "ANTHROPIC_WORK_ID": work.id, + "ANTHROPIC_SESSION_ID": work.data.id, + "ANTHROPIC_WORK_SECRET": work.secret or "", + } + forward = [arg for name in SANDBOX_ENV for arg in ("-e", name)] + launcher = await asyncio.create_subprocess_exec( + "docker", "run", "--rm", "--detach", *forward, "your-sdk-worker-image", env=env + ) + await launcher.wait() async def main() -> None:
``` ```typescript TypeScript + import { spawn } from "node:child_process"; + import { once } from "node:events"; import Anthropic from "@anthropic-ai/sdk"; import { WorkPoller } from "@anthropic-ai/sdk/helpers/beta/environments"; import type { BetaSelfHostedWork } from "@anthropic-ai/sdk/resources/beta/environments"; + const SANDBOX_ENV = [ + "ANTHROPIC_ENVIRONMENT_ID", + "ANTHROPIC_ENVIRONMENT_KEY", + "ANTHROPIC_WORK_ID", + "ANTHROPIC_SESSION_ID", + "ANTHROPIC_WORK_SECRET", + "ANTHROPIC_BASE_URL" // forwarded only when set on this host + ]; + const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY!; const environmentId = process.env.ANTHROPIC_ENVIRONMENT_ID!; const client = new Anthropic({ authToken: environmentKey }); async function launchContainer(work: BetaSelfHostedWork): Promise<void> { - // Replace with your own per-session sandbox launcher. Pass - // ANTHROPIC_ENVIRONMENT_KEY into the launched sandbox, never - // your API key. console.log(`claimed session ${work.data.id}`); + // Replace `docker run` with your own sandbox launcher. Forward the environment + // key (never your API key) and the work item's per-session secret: the worker + // inside needs the secret to mount the session's memory stores. + const env = { + ...process.env, + ANTHROPIC_WORK_ID: work.id, + ANTHROPIC_SESSION_ID: work.data.id, + ANTHROPIC_WORK_SECRET: work.secret ?? "" + }; + const forward = SANDBOX_ENV.flatMap((name) => ["-e", name]); + const launcher = spawn( + "docker", + ["run", "--rm", "--detach", ...forward, "your-sdk-worker-image"], + { env, stdio: "inherit" } + ); + await once(launcher, "close"); } const poller = new WorkPoller({
"fmt" "log" "os" + "os/exec" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/lib/environments" "github.com/anthropics/anthropic-sdk-go/option" + "github.com/anthropics/anthropic-sdk-go/packages/param" ) - func launchContainer(work *anthropic.BetaSelfHostedWork) { - // Replace with your own per-session sandbox launcher. The Go poller - // calls work.Stop when this function returns (it has no auto-stop - // opt-out), so block here until the session completes rather than - // detaching as the Python and TypeScript tabs do. + var sandboxEnv = []string{ + "ANTHROPIC_ENVIRONMENT_ID", + "ANTHROPIC_ENVIRONMENT_KEY", + "ANTHROPIC_WORK_ID", + "ANTHROPIC_SESSION_ID", + "ANTHROPIC_WORK_SECRET", + "ANTHROPIC_BASE_URL", // forwarded only when set on this host + } + + func launchContainer(ctx context.Context, work *anthropic.BetaSelfHostedWork) error { fmt.Printf("claimed session %s\n", work.Data.ID) + // Replace `docker run` with your own sandbox launcher. Forward the environment + // key (never your API key) and the work item's per-session secret: the worker + // inside needs the secret to mount the session's memory stores. + args := []string{"run", "--rm", "--detach"} + for _, name := range sandboxEnv { + args = append(args, "-e", name) + } + launcher := exec.CommandContext(ctx, "docker", append(args, "your-sdk-worker-image")...) + launcher.Env = append(os.Environ(), + "ANTHROPIC_WORK_ID="+work.ID, + "ANTHROPIC_SESSION_ID="+work.Data.ID, + "ANTHROPIC_WORK_SECRET="+work.Secret, + ) + launcher.Stdout, launcher.Stderr = os.Stdout, os.Stderr + return launcher.Run() } func main() {
poller := environments.NewWorkPoller(ctx, client, environments.WorkPollerOptions{ EnvironmentID: environmentID, EnvironmentKey: environmentKey, + AutoStop: param.NewOpt(false), // the launched sandbox owns the stop call }) defer poller.Close()
if err != nil { log.Fatal(err) } - launchContainer(work) + if err := launchContainer(ctx, work); err != nil { + log.Fatal(err) + } } } ```
``` </CodeGroup> -**`AgentToolContext`** is the execution context for tool calls. It defines the working directory and path policy, and can download the session's skills. **`beta_agent_toolset_20260401(env)`** takes an `AgentToolContext` and returns the standard tool implementations (`bash`, `read`, `write`, `edit`, `glob`, `grep`). +Whatever launches the sandbox must forward the claimed work item's `secret` into it (for example as `ANTHROPIC_WORK_SECRET`) alongside the session, work, and environment identifiers, so the worker inside can mount the session's [memory stores](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#use-memory-stores); see [Run one sandbox per session](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-one-sandbox-per-session). + +**`AgentToolContext`** is the execution context for tool calls. It defines the working directory and path policy, and can download the session's skills. The file tools (`read`, `write`, `edit`, `glob`, `grep`) are confined to the working directory plus any directories listed in `allowed_roots` (`allowedRoots` in TypeScript, `AllowedRoots` in Go), and `write` and `edit` additionally refuse paths under `read_only_roots` (`readOnlyRoots`, `ReadOnlyRoots`). `EnvironmentWorker` adds the session's memory store directories to these lists itself. The confinement is a guardrail for the file tools only, not a sandbox; it does not constrain `bash`. **`beta_agent_toolset_20260401(env)`** takes an `AgentToolContext` and returns the standard tool implementations (`bash`, `read`, `write`, `edit`, `glob`, `grep`). **With `EnvironmentWorker`:** both are managed automatically. Pass a `tools` factory to customize the tool list:
</CodeGroup> <Note> - Self-hosted sandboxes don't support `resources` entries; a session that includes any resource on a self-hosted environment is rejected. + Self-hosted sandboxes support `memory_store` resources only; see [Use memory stores](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#use-memory-stores). A session on a self-hosted environment that includes a `file` or `github_repository` resource is rejected with a 400 error: + + ```text wrap + Environment env_... is a self-hosted environment. `resources` are not supported with self-hosted environments. + ``` + + [Deployments](https://platform.claude.com/docs/en/managed-agents/scheduled-deployments) that target a self-hosted environment follow the same rule. </Note> See [Self-hosted worker](https://platform.claude.com/docs/en/managed-agents/reference#self-hosted-worker) in the reference for the full list of CLI flags, and [SDK helpers](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#sdk-helpers) for the SDK helper options. + +## Use memory stores + +Sessions on a self-hosted environment attach [memory stores](https://platform.claude.com/docs/en/managed-agents/memory) exactly as sessions on cloud environments do: list them in `resources` when you create the session, as shown in [Attach a memory store to a session](https://platform.claude.com/docs/en/managed-agents/memory#attach-a-memory-store-to-a-session). A session accepts up to 8 memory stores. On a self-hosted environment the SDK worker, rather than Anthropic's infrastructure, materializes each store for the agent, so memory stores there require `EnvironmentWorker` (or its `handle_item()` method) from the Python, TypeScript, or Go SDK. + +The `ant` CLI worker (`ant beta:worker poll` and `ant beta:worker run`) does not mount memory stores. To combine the CLI poller with memory stores, run the SDK worker inside a per-session sandbox as described in [Run one sandbox per session](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-one-sandbox-per-session). + +Memory stores cannot be attached to sessions on self-hosted environments on [Claude Platform on AWS](https://platform.claude.com/docs/en/build-with-claude/claude-platform-on-aws). + +### How the worker handles memory + +When the worker claims a work item whose session has memory stores attached, it: + +1. Downloads each attached store to its `mount_path` on the worker host, authenticating with the work item's per-session `secret`. The `mount_path` is the same directory under `/mnt/memory/` that cloud sessions use (for example, `/mnt/memory/user-preferences/` for a store named "User Preferences"), and the session's system prompt describes it to the agent. +2. Adds those directories to the file tools' allowed roots, and the directories of stores attached with `access: "read_only"` to their read-only roots, so the agent works on memories with the same `read`, `write`, `edit`, `glob`, and `grep` tools it uses in the working directory. +3. Reconciles local and remote changes after tool calls, at most once per sync interval (15 seconds by default): memories that changed in the store are written to disk, and files the agent changed are uploaded to the store. +4. Runs a final sync when the session ends, flushes any uploads still pending for up to 30 seconds, and then removes the directories it created. A worker that is cancelled while a session runs skips the final sync but still uploads changed files and removes the directories before it exits. + +The memory store on Anthropic's side remains the source of truth. [Memory versions](https://platform.claude.com/docs/en/managed-agents/memory#audit-memory-changes), redaction, and viewing or editing memories in the Console work as they do for cloud sessions, and the agent's memory reads and writes appear in the [event stream](https://platform.claude.com/docs/en/managed-agents/events-and-streaming) as ordinary tool events. Because each worker syncs on an interval, a change written in one session becomes visible to another running session only after both have synced, typically well under a minute at the default interval; sessions on cloud sandboxes see each other's changes almost immediately. + +Each store directory contains a marker file named `.anthropic-memory-store` that ties the directory to its store. Leave it in place: the worker does not sync a directory whose marker is missing or altered. + +### Prepare the host + +Memory stores on self-hosted sandboxes need a POSIX filesystem on the worker host (the Linux host from [Before you begin](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#before-you-begin)); Windows hosts are not supported, because the worker requires `O_NOFOLLOW` when it opens memory files. A case-sensitive filesystem is recommended, so that memory paths that differ only in case do not collide. + +Before you start the worker, create the parent directory and make it writable by the user the worker runs as: + +```bash +sudo mkdir -p /mnt/memory && sudo chown "$USER" /mnt/memory +``` + +Do not create the per-store directories yourself. The worker creates each store's `mount_path` directory (for example, `/mnt/memory/user-preferences`) when a session starts, refuses to start the session's work if something already exists at that path, and removes the directory when the session ends. Two operating rules follow: + +* **Run one session per filesystem when sessions attach the same store.** Two sessions cannot mount the same store on one host at the same time, because both need the same path. Giving each session its own sandbox, as described in [Run one sandbox per session](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-one-sandbox-per-session), satisfies this rule. +* **Stop workers gracefully.** When you stop a worker while a session runs, `EnvironmentWorker` uploads the session's changed memory files and removes its store directories only if it is cancelled rather than killed: a killed process runs no teardown, and the worker does not install signal handlers itself. Wire SIGTERM and SIGINT to cancellation in the process that runs it: abort the `signal` you pass to the worker in TypeScript, cancel the context in Go, and in Python cancel the task that runs `run()` or `handle_item()` from a signal handler. Then stop workers with SIGTERM and give them at least 30 seconds to exit before any hard kill, because the final upload can take that long. If a worker is killed before its teardown runs, remove the leftover store directory under `/mnt/memory/` before the next session that attaches that store; any edits in it that had not synced are lost. + +### Run one sandbox per session + +The sandbox-per-session pattern in [Run a worker](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-a-worker) gives each session a fresh filesystem, which is what [Prepare the host](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#prepare-the-host) calls for when sessions attach the same store. Keep `ant beta:worker poll --on-work` (or the SDK's work poller) as the poller on the host. + +The `ant beta:worker run` entrypoint shown there does not mount memory stores, so build the per-session image around the SDK worker instead: its entrypoint constructs `EnvironmentWorker` and calls `handle_item()` (`handleItem` in TypeScript, `HandleItem` in Go), which reads the session, work, and environment identifiers from the `ANTHROPIC_*` variables and the work item's per-session `secret` from `ANTHROPIC_WORK_SECRET`. You can also pass the secret explicitly as `work_secret` (`workSecret` in TypeScript, `WorkSecret` in Go). + +<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: + async with AsyncAnthropic(auth_token=os.environ["ANTHROPIC_ENVIRONMENT_KEY"]) as client: + worker = EnvironmentWorker(client, workdir="/workspace") + # With no arguments, handle_item() reads the ANTHROPIC_* variables the spawn + # script forwarded, including ANTHROPIC_WORK_SECRET. + task = asyncio.create_task(worker.handle_item()) + # Cancelling the task when the container is stopped lets the worker upload + # changed memory files and remove the store directories 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 client = new Anthropic({ authToken: process.env.ANTHROPIC_ENVIRONMENT_KEY }); + const controller = new AbortController(); + // Aborting when the container is stopped lets the worker upload changed memory + // files and remove the store directories before it exits. + process.once("SIGTERM", () => controller.abort()); + process.once("SIGINT", () => controller.abort()); + + // With no arguments, handleItem() reads the ANTHROPIC_* variables the spawn + // script forwarded, including ANTHROPIC_WORK_SECRET. + await new EnvironmentWorker({ + client, + workdir: "/workspace", + signal: controller.signal + }).handleItem(); + ``` + + ```csharp C# + // EnvironmentWorker is not currently available in the C# SDK. + ``` + + ```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() { + // Cancelling the context when the container is stopped lets the worker upload + // changed memory files and remove the store directories before it exits. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + client := anthropic.NewClient(option.WithAuthToken(os.Getenv("ANTHROPIC_ENVIRONMENT_KEY"))) + worker := environments.NewEnvironmentWorker(client, environments.EnvironmentWorkerOptions{ + Workdir: "/workspace", + }) + // With zero-value options, HandleItem reads the ANTHROPIC_* variables the spawn + // script forwarded, including ANTHROPIC_WORK_SECRET. + if err := worker.HandleItem(ctx, environments.HandleItemOptions{}); err != nil { + log.Fatalf("worker: %v", err) + } + } + + ``` + + ```java Java + // EnvironmentWorker is not currently available in the Java SDK. + ``` + + ```php PHP + // EnvironmentWorker is not currently available in the PHP SDK. + ``` + + ```ruby Ruby + # EnvironmentWorker is not currently available in the Ruby SDK. + ``` +</CodeGroup> + +`ant beta:worker poll --on-work` does not set `ANTHROPIC_WORK_SECRET` for the script it spawns, so the spawn script reads the secret from the work item JSON on its standard input and passes it into the sandbox: + +```bash +#!/bin/bash +# spawn.sh: called once per claimed work item +# The claimed work item arrives as JSON on stdin. Its secret is the +# per-session credential that the memory store endpoints require. +ANTHROPIC_WORK_SECRET="$(jq -r '.secret // empty')" +export ANTHROPIC_WORK_SECRET +mkdir -p "/host/outputs/$ANTHROPIC_SESSION_ID" +exec docker run --rm \ + -e ANTHROPIC_SESSION_ID -e ANTHROPIC_ENVIRONMENT_KEY \ + -e ANTHROPIC_WORK_ID -e ANTHROPIC_ENVIRONMENT_ID -e ANTHROPIC_BASE_URL \ + -e ANTHROPIC_WORK_SECRET \ + -v "/host/outputs/$ANTHROPIC_SESSION_ID":/workspace \ + your-sdk-worker-image +``` + +If you claim work with the SDK's work poller instead, pass each claimed item's `secret` into the sandbox you launch in the same way. Pass it only into the sandbox that serves that session, and never log it. + +The sandbox image also needs a writable `/mnt/memory` (see [Prepare the host](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#prepare-the-host)). Because each sandbox serves one session and is discarded afterward, no leftover directories need cleanup, and the memory directories do not need to be bind-mounted to the host: the worker uploads their contents to the store before the sandbox exits. If you stop a container before its session ends, send a signal that the entrypoint turns into cancellation (see [Prepare the host](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#prepare-the-host)) rather than killing it, so that upload still runs. Give the container time to finish the upload as well: Docker follows the stop signal with SIGKILL after 10 seconds by default, so raise that limit to at least the 30 seconds that Prepare the host calls for, with `--stop-timeout` on `docker run` or your orchestrator's termination grace period. + +### Configure sync + +Two `EnvironmentWorker` options control memory behavior: + +* **`memory_sync_interval`** (Python, in seconds; `memorySyncIntervalMs` in TypeScript, in milliseconds; `MemorySyncInterval` in Go, a duration): how often attached stores reconcile with the server while the session runs. Defaults to 15 seconds; the minimum is 5 seconds. A shorter interval narrows the window in which another session sees stale memories, at the cost of more memory store requests. `None` in Python, `null` in TypeScript, or a negative duration in Go disables memory support entirely: the worker neither downloads nor syncs stores, and a session with memory stores attached runs without them even though its system prompt still describes them, so disable memory support only on workers whose sessions attach no memory stores. While memory support is enabled, a work item that arrives without a per-session `secret` for a session with attached stores fails rather than running without memory (see [Troubleshoot memory mounts](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#troubleshoot-memory-mounts)). +* **`memory_sync_deletes`** (`memoryRemoteDeletes` in TypeScript, `MemorySyncDeletes` in Go): whether a file the agent deletes locally is also deleted from the store. The value is one of `"enabled"` (the default), `"log_only"`, or `"disabled"` in Python and TypeScript, and one of the constants `environments.MemorySyncDeletesEnabled` (the zero value), `environments.MemorySyncDeletesLogOnly`, or `environments.MemorySyncDeletesDisabled` in Go. When enabled, the worker deletes the memory from the store once a later sync confirms the file is still gone; in log-only mode it runs the same checks but only logs what it would have deleted, which lets you watch what your workers would delete before you trust the enabled mode; when disabled, it never deletes from the store. Uploads and downloads are unaffected by this setting. + +Set these options where you construct the worker, whether through the `EnvironmentWorker` constructor or, in Python and TypeScript, the `client.beta.environments.work.worker()` factory that the webhook handler uses. + +For example, to sync every 10 seconds and only log the deletes the worker would have made: + +<CodeGroup exclude="shell"> + ```python Python + worker = EnvironmentWorker( + client, + environment_id=environment_id, + environment_key=environment_key, + workdir="/workspace", + memory_sync_interval=10, # seconds + memory_sync_deletes="log_only", + ) + ``` + + ```typescript TypeScript + const worker = new EnvironmentWorker({ + client, + environmentId, + environmentKey, + workdir: "/workspace", + memorySyncIntervalMs: 10_000, + memorySyncDeletes: "log_only" + }); + ``` + + ```csharp C# + // EnvironmentWorker is not currently available in the C# SDK. + ``` + + ```go Go + worker := environments.NewEnvironmentWorker(client, environments.EnvironmentWorkerOptions{ + EnvironmentID: environmentID, + EnvironmentKey: environmentKey, + Workdir: "/workspace", + MemorySyncInterval: 10 * time.Second, + MemorySyncDeletes: environments.MemorySyncDeletesLogOnly, + }) + ``` + + ```java Java + // EnvironmentWorker is not currently available in the Java SDK. + ``` + + ```php PHP + // EnvironmentWorker is not currently available in the PHP SDK. + ``` + + ```ruby Ruby + # EnvironmentWorker is not currently available in the Ruby SDK. + ``` +</CodeGroup> + +### Read-only stores and conflicts + +For a store attached with `access: "read_only"`, the `write` and `edit` tools refuse to change files inside its directory, and the worker never uploads anything from it. Changes made through `bash` are not blocked locally: they are never synced to the store, and the next remote change to that memory overwrites them. If you need the local copy itself to stay unchanged during the session, disable the `bash` tool for that agent; do not mount the store path read-only, because the worker itself must create the directory and write the downloaded memories into it. + +Conflicts resolve in favor of the store. When the agent changes a memory file that also changed in the store since the session last synced it, the worker keeps the store's version at the next sync, overwrites the local file with it, and logs a warning; the `write` and `edit` tools themselves succeed and no error reaches the agent. If the agent's change still applies, it can re-read the file after the sync and make the change again. + +### Troubleshoot memory mounts + +The worker logs mount and background sync failures rather than reporting them to the session; only read-only refusals reach the agent, as tool errors (see [Read-only stores and conflicts](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#read-only-stores-and-conflicts)). If a memory store cannot be mounted when the worker claims a session, the worker fails the work item: the session emits no error event and stays idle. + +| Symptom | Cause | Fix | +| --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The worker log contains `the work item carried no sessions token` (in Go, the `ErrSessionMemoryNoToken` error) and the work item fails. | The work item's per-session `secret` did not reach the worker: memory stores on self-hosted sandboxes are not enabled for your organization, or your spawn script did not forward the secret into the sandbox. | In the sandbox-per-session pattern, forward `ANTHROPIC_WORK_SECRET` into the sandbox as shown in [Run one sandbox per session](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#run-one-sandbox-per-session). If the worker polls and runs sessions in one process and still logs this, contact support. | +| The worker log contains `something already exists at the memory store's path`. | A directory left over from a previous session, usually one whose worker was killed before its teardown ran. | Remove the leftover directory that the log line names. Edits in it that had not synced are lost. | +| The worker log contains `cannot create the memory store's folder` and `the worker host must make this mount path writable`. | The user the worker runs as cannot create directories under `/mnt/memory`. | Create `/mnt/memory` and `chown` it to that user; see [Prepare the host](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#prepare-the-host). | +| The session sits `idle` with a `requires_action` stop reason and no error event shortly after a worker claimed it. | The worker failed the work item because it could not mount a memory store, for one of the preceding reasons. | Fix the cause on the host, then send a [`user.interrupt`](https://platform.claude.com/docs/en/managed-agents/events-and-streaming#integrating-events) event: the session's work is queued again and the next worker that claims it retries the mount. | ## Serve custom tools from your sandbox