spawn.sh: called once per claimed work item changedmanaged-agents/self-hosted-sandboxes
Nearest release: v2.1.285, published 2 hours before this site recorded the change. Shown because the two are within 24 hours of each other. Nothing here says the release caused the edit.
Recorded here
Lines+81added
Lines−77removed
From line
257
where the diff opens
First seen
14 Aug 2026
this site's first read of the page
Recorded edits17to this page, all time
The whole hunk
from line 257, old and new numbered
/
from line 257
257257
258258 If you need stronger isolation (a fresh filesystem, resource limits, or per-session network controls), run each session in its own sandbox. Build an image with `ant` installed and `ant beta:worker run` as the entrypoint. The base image must provide `/bin/bash`; `curl` is only used at build time. When a sandbox starts, it reads session details from environment variables, handles that session, and exits:
259259
260 ```text
260 ```dockerfile
261261 FROM your-base-image
262262 ARG ANT_VERSION=1.36.0
263263 ARG TARGETARCH
from line 416
416416 </Step>
417417
418418 <Step title="Export the webhook signing key">
419 In addition to the environment ID and key from [Before you begin](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#before-you-begin), export the webhook signing key on your handler host so the handler can verify incoming payloads. Signature verification in the Python handler needs the webhooks extra: `pip install "anthropic[webhooks]"`.
419 In addition to the environment ID and key from [Before you begin](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#before-you-begin), export the webhook signing key on your handler host so the handler can verify incoming payloads.
420420
421421 ```bash
422422 export ANTHROPIC_WEBHOOK_SIGNING_KEY="whsec_..."
from line 429
429429 When you hand a claimed work item to `handle_item()` (typescript: `handleItem()`; go: `HandleItem()`) yourself, as this handler does, pass the work item's `secret` along as `work_secret` (typescript: `workSecret`; go: `WorkSecret`) 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.
430430
431431 <CodeGroup exclude="shell">
432 ```python Python
433 import asyncio
434 import os
435 import anthropic
436 import standardwebhooks # installed by the anthropic[webhooks] extra
432 <CodeGroupItem>
433 To verify webhook signatures, install the webhooks extra: `pip install "anthropic[webhooks]"`.
437434
438 environment_key = os.environ["ANTHROPIC_ENVIRONMENT_KEY"]
439 environment_id = os.environ["ANTHROPIC_ENVIRONMENT_ID"]
440 client = anthropic.AsyncAnthropic(
441 auth_token=environment_key,
442 )
443 # Cancelled by shutdown() so an in-flight work item can upload changed memory files and
444 # remove its store directories before the process exits.
445 inflight: set[asyncio.Task[None]] = set()
435 ```python Python
436 import asyncio
437 import os
438 import anthropic
439 import standardwebhooks # installed by the anthropic[webhooks] extra
446440
441 environment_key = os.environ["ANTHROPIC_ENVIRONMENT_KEY"]
442 environment_id = os.environ["ANTHROPIC_ENVIRONMENT_ID"]
443 client = anthropic.AsyncAnthropic(
444 auth_token=environment_key,
445 )
446 # Cancelled by shutdown() so an in-flight work item can upload changed memory files and
447 # remove its store directories before the process exits.
448 inflight: set[asyncio.Task[None]] = set()
447449
448 # Await this from the host's shutdown hook, such as an ASGI lifespan shutdown (the code after
449 # `yield` in a FastAPI lifespan), which uvicorn runs on SIGTERM. uvicorn lets open requests
450 # finish before that hook runs, so set --timeout-graceful-shutdown to bound the wait.
451 async def shutdown() -> None:
452 for task in inflight:
453 task.cancel()
454 await asyncio.gather(*inflight, return_exceptions=True)
455450
451 # Await this from the host's shutdown hook, such as an ASGI lifespan shutdown (the code after
452 # `yield` in a FastAPI lifespan), which uvicorn runs on SIGTERM. uvicorn lets open requests
453 # finish before that hook runs, so set --timeout-graceful-shutdown to bound the wait.
454 async def shutdown() -> None:
455 for task in inflight:
456 task.cancel()
457 await asyncio.gather(*inflight, return_exceptions=True)
456458
457 async def handle(raw: bytes, headers: dict[str, str]) -> tuple[dict[str, str], int]:
458 try:
459 event = client.beta.webhooks.unwrap(raw.decode(), headers=headers)
460 except standardwebhooks.WebhookVerificationError:
461 return {"error": "signature verification failed"}, 401
462 if event.data.type != "session.status_run_started":
463 return {"status": "ignored"}, 200
464 task = asyncio.create_task(run_queued_work())
465 inflight.add(task)
466 task.add_done_callback(inflight.discard)
467 try:
468 # Shielded: a dropped or timed-out delivery must not cancel the item; shutdown() does.
469 await asyncio.shield(task)
470 except asyncio.CancelledError:
471 return {"status": "shutting down"}, 503
472 return {"status": "ok"}, 200
473459
460 async def handle(raw: bytes, headers: dict[str, str]) -> tuple[dict[str, str], int]:
461 try:
462 event = client.beta.webhooks.unwrap(raw.decode(), headers=headers)
463 except standardwebhooks.WebhookVerificationError:
464 return {"error": "signature verification failed"}, 401
465 if event.data.type != "session.status_run_started":
466 return {"status": "ignored"}, 200
467 task = asyncio.create_task(run_queued_work())
468 inflight.add(task)
469 task.add_done_callback(inflight.discard)
470 try:
471 # Shielded: a dropped or timed-out delivery must not cancel the item; shutdown() does.
472 await asyncio.shield(task)
473 except asyncio.CancelledError:
474 return {"status": "shutting down"}, 503
475 return {"status": "ok"}, 200
474476
475 async def run_queued_work() -> None:
476 async for work in client.beta.environments.work.poller(
477 environment_id=environment_id,
478 environment_key=environment_key,
479 block_ms=None,
480 reclaim_older_than_ms=2000,
481 drain=True,
482 auto_stop=False,
483 ):
484 await client.beta.environments.work.worker(workdir="/workspace").handle_item(
485 work_id=work.id,
486 environment_id=environment_id,
487 session_id=work.data.id,
488 environment_key=environment_key,
489 # The per-session secret is what lets the worker mount the session's memory stores.
490 work_secret=work.secret,
491 )
492 ```
493477
478 async def run_queued_work() -> None:
479 async for work in client.beta.environments.work.poller(
480 environment_id=environment_id,
481 environment_key=environment_key,
482 block_ms=None,
483 reclaim_older_than_ms=2000,
484 drain=True,
485 auto_stop=False,
486 ):
487 await client.beta.environments.work.worker(workdir="/workspace").handle_item(
488 work_id=work.id,
489 environment_id=environment_id,
490 session_id=work.data.id,
491 environment_key=environment_key,
492 # The per-session secret is what lets the worker mount the session's memory stores.
493 work_secret=work.secret,
494 )
495 ```
496 </CodeGroupItem>
497
494498 ```typescript TypeScript
495499 import Anthropic from "@anthropic-ai/sdk";
496500
from line 702
698702 * `drain` (go: `Drain`): whether to stop polling once the queue is empty rather than waiting for new work.
699703 * `block_ms` (python; typescript: `blockMs`; go: `BlockMs`): 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` (typescript; python: `None`; go: `param.Null[int64]()`) for a non-blocking check; omitting the parameter uses the default 999 ms long-poll.
700704 * `reclaim_older_than_ms` (typescript: `reclaimOlderThanMs`; go: `ReclaimOlderThanMs`): re-claim work items that were claimed but never acknowledged within this many milliseconds.
701 * `auto_stop` (typescript: `autoStop`; go: `AutoStop`): whether to post a stop signal for each work item once your loop body finishes with it. Turn it off whenever whatever runs the work item posts the stop itself: `handle_item()` (typescript: `handleItem()`; go: `HandleItem()`) does, so set it to false when you hand claimed items to `handle_item()` (typescript: `handleItem()`; go: `HandleItem()`) as the webhook handlers on this page do, and so does a sandbox you launch that owns the stop call.
705 * `auto_stop` (typescript: `autoStop`; go: `AutoStop`): whether to post a stop signal for each work item once your loop body finishes with it. Turn it off whenever whatever runs the work item posts the stop itself: `handle_item()` (typescript: `handleItem()`; go: `HandleItem()`) does, so set it to `false` (python: `False`; go: `param.NewOpt(false)`) when you hand claimed items to `handle_item()` (typescript: `handleItem()`; go: `HandleItem()`) as the webhook handlers on this page do, and so does a sandbox you launch that owns the stop call.
702706
703* **`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.
707* **`client.beta.sessions.events.tool_runner()` (typescript: `client.beta.sessions.events.toolRunner()`; go: `client.Beta.Sessions.Events.NewToolRunner()`):** 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.
704708
705709Use `work.poller()` (typescript: `new WorkPoller()`; go: `environments.NewWorkPoller()`) directly when you want to launch your own per-session process, for example spinning up a sandbox for each claimed session:
706710
from line 964
960964 ```
961965</CodeGroup>
962966
963**With `work.poller()` (typescript; go: `environments.NewWorkPoller()`) and `tool_runner()`:** pass a tool list as `tools` to `client.beta.sessions.events.tool_runner()`. To build that list, set up `AgentToolContext` yourself and call `beta_agent_toolset_20260401(env)` (typescript: `betaAgentToolset20260401(ctx)`; go: `agenttoolset.BetaAgentToolset20260401(env)`):
967**With `work.poller()` (typescript; go: `environments.NewWorkPoller()`) and `client.beta.sessions.events.tool_runner()` (typescript: `client.beta.sessions.events.toolRunner()`; go: `client.Beta.Sessions.Events.NewToolRunner()`):** pass it a tool list as `tools` (go: `Tools`). To build that list, set up `AgentToolContext` yourself and call `beta_agent_toolset_20260401(env)` (typescript: `betaAgentToolset20260401(ctx)`; go: `agenttoolset.BetaAgentToolset20260401(env)`):
964968
965969<CodeGroup exclude="shell">
966970 ```python Python
from line 1146
11421146When the worker claims a work item whose session has memory stores attached, it:
11431147
114411481. 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.
11452. 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.
11463. 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.
11492. Adds those directories to `allowed_roots` (typescript: `allowedRoots`; go: `AllowedRoots`), and the directories of stores attached with `access: "read_only"` to `read_only_roots` (typescript: `readOnlyRoots`; go: `ReadOnlyRoots`), so the agent works on memories with the same `read`, `write`, `edit`, `glob`, and `grep` tools it uses in the working directory.
11503. Reconciles local and remote changes after tool calls, at most once per `memory_sync_interval` (typescript: `memorySyncIntervalMs`; go: `MemorySyncInterval`) (15 seconds by default): memories that changed in the store are written to disk, and files the agent changed are uploaded to the store.
114711514. 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.
11481152
11491153The 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.
from line 1167
11631167Do 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:
11641168
11651169* **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.
1166* **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()`. Do that from a signal handler when your worker is the process, as the standalone workers on this page do, or from your server's own shutdown hook when the worker runs inside a webhook handler, which must not take over the server's signals. 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.
1170* **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 cancel the worker. Do that from a signal handler when your worker is the process, as the standalone workers on this page do, or from your server's own shutdown hook when the worker runs inside a webhook handler, which must not take over the server's signals. 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.
11671171
11681172### Run one sandbox per session
11691173
from line 1300
12961300
12971301Two `EnvironmentWorker` options control memory behavior:
12981302
1299* **`memory_sync_interval` (typescript: `memorySyncIntervalMs`; go: `MemorySyncInterval`)** (in seconds in Python, in milliseconds in TypeScript, a duration in Go): 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)).
1300* **`memory_sync_deletions` (typescript: `memorySyncDeletions`; go: `MemorySyncDeletions`)**: 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.MemorySyncDeletionsEnabled` (the zero value), `environments.MemorySyncDeletionsLogOnly`, or `environments.MemorySyncDeletionsDisabled` 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.
1303* **`memory_sync_interval` (typescript: `memorySyncIntervalMs`; go: `MemorySyncInterval`)** (for example, `10` (python; typescript: `10_000`; go: `10 * time.Second`) for 10 seconds): 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. Setting it to `None` (python; typescript: `null`; go: `-1`) 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)).
1304* **`memory_sync_deletions` (typescript: `memorySyncDeletions`; go: `MemorySyncDeletions`)**: whether a file the agent deletes locally is also deleted from the store. The value is one of `"enabled"` (go: `environments.MemorySyncDeletionsEnabled`) (the default), `"log_only"` (go: `environments.MemorySyncDeletionsLogOnly`), or `"disabled"` (go: `environments.MemorySyncDeletionsDisabled`). 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.
13011305
1302Set 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.
1306Set these options wherever you construct the worker, including in the webhook handler.
13031307
13041308For example, to sync every 10 seconds and only log the deletes the worker would have made:
13051309
from line 1566
156215662. The worker, inside your sandbox, forwards the call over its open MCP session to the server on your network.
156315673. The worker posts the server's response as the `user.custom_tool_result`.
15641568
1565The SDKs' [Client-side MCP helpers](https://platform.claude.com/docs/en/agents-and-tools/mcp-connector#client-side-mcp-helpers) convert the server's tools into the runnable tools the worker accepts; install an MCP SDK alongside the Anthropic SDK (`pip install "anthropic[mcp]" "mcp>=1.24"`, `npm install @modelcontextprotocol/sdk`, `go get github.com/modelcontextprotocol/go-sdk`). The examples connect without authentication; to send credentials, configure the HTTP client or request options you hand to the MCP transport (`http_client` (typescript: `requestInit`; go: `HTTPClient`)).
1569The SDK's [Client-side MCP helpers](https://platform.claude.com/docs/en/agents-and-tools/mcp-connector#client-side-mcp-helpers) convert the server's tools into the runnable tools the worker accepts. Install an MCP SDK alongside the Anthropic SDK: `pip install "anthropic[mcp]" "mcp>=1.24"` (python; typescript: `npm install @modelcontextprotocol/sdk`; go: `go get github.com/modelcontextprotocol/go-sdk`). The examples connect without authentication. To send credentials, configure the `http_client` (typescript: `requestInit`; go: `HTTPClient`) you hand to the MCP transport.
15661570
15671571<Steps>
15681572 <Step title="Declare the server's tools on the agent">
from line 1971
19671971
19681972### Read queue depth
19691973
1970`work.stats` returns the queue state for an environment:
1974`GET /v1/environments/{environment_id}/work/stats` (curl; python, typescript, ruby: `client.beta.environments.work.stats()`; go, csharp: `client.Beta.Environments.Work.Stats()`; java: `client.beta().environments().work().stats()`; php: `$client->beta->environments->work->stats()`; cli: `ant beta:environments:work stats`) returns the queue state for an environment:
19711975
19721976* `depth` is the number of items waiting to be claimed. Scale your worker fleet or alert on backlog based on this value.
19731977* `pending` is the number of items claimed by a worker but not yet acknowledged. The worker helpers acknowledge each item before processing it, so this value stays near zero in normal operation; a sustained non-zero value means a worker stalled between claiming and acknowledging.
from line 2103
20992103
21002104### Stop a session gracefully
21012105
2102Use `work.stop` to ask the worker handling a specific session to shut it down. By default the work item moves to `stopping`: the worker notices on its next lease heartbeat, cancels the session's in-flight tool call, and confirms the shutdown, at which point the work item becomes `stopped`. Pass `force: true` in the request body (with the CLI, pass `--force`) to mark the work item `stopped` immediately instead of waiting for the worker's confirmation.
2106Use `POST /v1/environments/{environment_id}/work/{work_id}/stop` (curl; python, typescript, ruby: `client.beta.environments.work.stop()`; go, csharp: `client.Beta.Environments.Work.Stop()`; java: `client.beta().environments().work().stop()`; php: `$client->beta->environments->work->stop()`; cli: `ant beta:environments:work stop`) to ask the worker handling a specific session to shut it down. By default the work item moves to `stopping`: the worker notices on its next lease heartbeat, cancels the session's in-flight tool call, and confirms the shutdown, at which point the work item becomes `stopped`. Pass `force: true` (python: `force=True`; cli: `--force`) to mark the work item `stopped` immediately instead of waiting for the worker's confirmation.
21032107
21042108Because these calls run from your operations tooling rather than the worker host, `ANTHROPIC_WORK_ID` isn't set automatically. Set it to the target work item's ID before running the following examples. To find a work item's ID, list the environment's work items through the [Environments Work endpoints](https://platform.claude.com/docs/en/api/beta/environments/work).
21052109
No line in this hunk matches that.