> ## Documentation Index
> Fetch the complete documentation index at: https://oma-codex-339-workspace-permissions.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Self-hosted sandboxes

> Run Open Managed Agents sessions in self-hosted sandboxes, keeping tool execution, files, and network egress in your own infrastructure.

By default, Managed Agents executes tools and code inside [OMA-managed cloud sandboxes](/docs/en/cloud-sandboxes-reference). Self-hosted sandboxes keep the orchestration on OMA'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 OMA's control plane so the model can see results and determine what to do next. See the [security model](/docs/en/self-hosted-sandboxes-security) for the full data-flow boundary.

<Note>
  Self-hosted sandboxes support the models exposed by your OMA deployment. The model is configured on the [agent](/docs/en/agent-setup), not the environment.
</Note>

## How it differs from cloud environments

|                               | Cloud environment     | Self-hosted sandbox |
| ----------------------------- | --------------------- | ------------------- |
| Where tools run               | OMA-managed sandboxes | Your infrastructure |
| Network reach                 | OMA's egress controls | Your network policy |
| File and GitHub repo mounting | Managed by OMA        | Managed by you      |
| Lifecycle                     | Managed by OMA        | 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.

For Zero Data Retention and HIPAA BAA eligibility, see API and data retention.

## When to combine with MCP tunnels

Self-hosting controls *where the agent's code executes*. [MCP tunnels](/docs/en/mcp-connector) control *how OMA reaches MCP servers in your network*. They are independent: a session running in OMA's cloud sandboxes can still reach private MCP servers through a tunnel, and a self-hosted session can use either tunneled or public MCP servers. Use both when you want execution and tool access to stay inside your boundary. To give the agent tools from an MCP server inside your network without running a tunnel, you can also [wrap the server as custom tools](/docs/en/self-hosted-sandboxes#wrap-an-mcp-server-as-custom-tools) served by your worker.

## Environment worker

<Tip>
  This guide describes how to build a worker with any generic sandboxing platform. Additional, platform-specific guides are available for AWS Lambda MicroVMs, Blaxel, Cloudflare, Daytona, E2B, Fly.io, GKE Agent Sandbox, Modal, Namespace, Superserve, and Vercel.
</Tip>

An environment worker is a process you run on your own infrastructure. It receives tool execution requests from OMA and runs them locally. The `self_hosted` environment acts as a work queue: when a [session](/docs/en/sessions) is assigned to it, OMA enqueues the session as a work item. Your worker claims work items from that queue, spawns an execution context for each one, downloads the agent's [skills](/docs/en/skills) (reusable, filesystem-based resources that give the agent domain-specific expertise), runs the tool calls, and posts the results back.

Work items are claimed by polling the environment's queue: either by an **always-on worker** that polls continuously, or a **webhook-triggered handler** that wakes on `session.status_run_started` and starts polling.

The CLI and SDK both ship pre-built workers. The `ant` CLI supports the always-on pattern only; the SDK supports both always-on and webhook-triggered. Both are configurable: see [Self-hosted worker](/docs/en/reference#self-hosted-worker) in the reference for CLI flags, and [SDK helpers](/docs/en/self-hosted-sandboxes#sdk-helpers) on this page for the SDK options. For more control, call the [Environments Work endpoints](/docs/en/api/environment-work/list-work-items) directly and implement your own worker.

### Sandbox filesystem

* **`/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 the agent can locate the skill files.
* **Outputs:** on self-hosted environments the session's system prompt omits the `/mnt/session/outputs` instruction used on OMA-managed sandboxes, so final deliverables land wherever the agent writes them in your sandbox filesystem, typically under the working directory.

## Before you begin

You need:

* **An existing agent.** If you don't have one, complete the [Quickstart](/docs/en/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 OMA 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 OMA API key creates sessions and reads queue stats from outside the worker host. Key generation is Console-only.

<Note>
  Environment workers authenticate to the queue with an environment key generated by OMA. When a worker runs on AWS or another cloud, continue to protect the host with your deployment's IAM, secret manager, and network policies; OMA does not depend on third-party managed policies.
</Note>

<Steps>
  <Step title="Create a self-hosted environment">
    In the Console: **Workspace > Environments > New > Self-hosted**

    Or through the API:

    <CodeGroup>
      ```bash cURL theme={null}
      curl -sS --fail-with-body http://localhost:38080/v1/environments \
        -H "x-api-key: $OMA_API_KEY" \
        -H "anthropic-version: 2023-06-01" \
        -H "anthropic-beta: managed-agents-2026-04-01" \
        -H "content-type: application/json" \
        -d '{
          "name": "self-hosted",
          "config": {"type": "self_hosted"}
        }'
      ```

      ```bash CLI theme={null}
      ant beta:environments create \
        --name self-hosted \
        --config '{"type": "self_hosted"}'
      ```

      ```python Python theme={null}
      client = anthropic.Anthropic()

      environment = client.beta.environments.create(
          name="self-hosted", config={"type": "self_hosted"}
      )
      print(environment.id)
      ```

      ```typescript TypeScript theme={null}
      const client = new Anthropic();

      const environment = await client.beta.environments.create({
        name: "self-hosted",
        config: { type: "self_hosted" }
      });
      console.log(environment.id);
      ```

      ```csharp C# theme={null}
      using Anthropic.Models.Beta.Environments;

      var client = new AnthropicClient();

      var environment = await client.Beta.Environments.Create(
          new EnvironmentCreateParams
          {
              Name = "self-hosted",
              Config = new BetaSelfHostedConfigParams(),
          }
      );
      Console.WriteLine(environment.ID);
      ```

      ```go Go theme={null}
      client := anthropic.NewClient()

      environment, err := client.Beta.Environments.New(context.Background(), anthropic.BetaEnvironmentNewParams{
          Name: "self-hosted",
          Config: anthropic.BetaEnvironmentNewParamsConfigUnion{
              OfSelfHosted: &anthropic.BetaSelfHostedConfigParams{},
          },
      })
      if err != nil {
          panic(err)
      }
      fmt.Println(environment.ID)
      ```

      ```java Java theme={null}
      import com.anthropic.models.beta.environments.BetaSelfHostedConfigParams;
      import com.anthropic.models.beta.environments.EnvironmentCreateParams;

      void main() {
          var client = AnthropicOkHttpClient.fromEnv();

          var environment = client.beta().environments().create(
              EnvironmentCreateParams.builder()
                  .name("self-hosted")
                  .config(BetaSelfHostedConfigParams.builder().build())
                  .build()
          );
          IO.println(environment.id());
      }
      ```

      ```php PHP theme={null}
      $client = new Anthropic\Client();

      $environment = $client->beta->environments->create(
          name: 'self-hosted',
          config: ['type' => 'self_hosted'],
      );
      echo $environment->id, PHP_EOL;
      ```

      ```ruby Ruby theme={null}
      client = Anthropic::Client.new

      environment = client.beta.environments.create(
        name: "self-hosted",
        config: {type: :self_hosted}
      )
      puts environment.id
      ```
    </CodeGroup>
  </Step>

  <Step title="Generate an environment key">
    In the Console, open the environment and click **Generate environment key**. Key generation is Console-only, regardless of whether you created the environment through the Console or the API. Then export the environment ID and key on the worker host:

    ```bash theme={null}
    export ANTHROPIC_ENVIRONMENT_KEY="sk-ant-oat01-..."
    export ANTHROPIC_ENVIRONMENT_ID="env_..."
    ```
  </Step>
</Steps>

<Note>
  Skills can include executables that the agent may run directly. The CLI and SDK workers preserve the executable permissions recorded in the skill bundle when they extract it. If you implement skills download manually, you are responsible for setting executable permissions.
</Note>

## Run a worker

Choose **always-on** for the simplest setup: a long-running process polls the queue continuously and needs only outbound HTTPS. Choose **webhook-triggered** to avoid running an idle poller; it requires a webhook endpoint that OMA can reach (see [Webhooks](/docs/en/webhooks) for endpoint setup and signature verification).

<Tabs>
  <Tab title="Always-on (ant CLI)">
    <Steps>
      <Step title="Install the ant CLI">
        Run this on the worker host.

        <Tabs>
          <Tab title="curl (Linux/WSL)">
            For Linux environments, download the release binary directly.

            ```bash theme={null}
            VERSION=1.22.1
            OS=$(uname -s | tr '[:upper:]' '[:lower:]')
            case $(uname -m) in
              x86_64) ARCH=amd64 ;;
              aarch64) ARCH=arm64 ;;
            esac
            curl -fsSL "https://github.com/anthropics/anthropic-cli/releases/download/v${VERSION}/ant_${VERSION}_${OS}_${ARCH}.tar.gz" \
              | sudo tar -xz -C /usr/local/bin ant
            ```

            You can find all releases on the GitHub releases page.
          </Tab>

          <Tab title="Homebrew (macOS)">
            ```bash theme={null}
            brew install anthropics/tap/ant
            ```
          </Tab>
        </Tabs>
      </Step>

      <Step title="Run the worker">
        **In-process**

        `ant beta:worker poll` claims work items assigned to the environment, downloads skills, executes tool calls in the working directory, and posts results back. It reads `ANTHROPIC_ENVIRONMENT_KEY` and `ANTHROPIC_ENVIRONMENT_ID` from the environment.

        ```bash theme={null}
        ant beta:worker poll \
          --workdir "/workspace"
        ```

        The worker exits cleanly on SIGTERM or SIGINT: it cancels any in-flight tool call, posts its error result, and releases the work item before stopping.

        **Sandbox per session**

        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:

        ```text theme={null}
        FROM your-base-image
        ARG ANT_VERSION=1.22.1
        ARG TARGETARCH
        RUN ARCH=$([ "$TARGETARCH" = "arm64" ] && echo arm64 || echo amd64) && \
            curl -fsSL "https://github.com/anthropics/anthropic-cli/releases/download/v${ANT_VERSION}/ant_${ANT_VERSION}_linux_${ARCH}.tar.gz" \
              | tar -xz -C /usr/local/bin ant
        WORKDIR /workspace
        VOLUME /workspace
        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](/docs/en/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 theme={null}
        #!/bin/bash
        # spawn.sh: called once per claimed work item
        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 \
          -v "/host/outputs/$ANTHROPIC_SESSION_ID":/workspace \
          your-image
        ```

        Start the poller pointing at the script:

        ```bash theme={null}
        ant beta:worker poll \
          --on-work ./spawn.sh
        ```
      </Step>
    </Steps>
  </Tab>

  <Tab title="Always-on (SDK)">
    <Steps>
      <Step title="Run the worker">
        `EnvironmentWorker` claims work items assigned to the environment, downloads skills, executes tool calls in the working directory, and posts results back. Authenticate with the environment key you generated in [Before you begin](/docs/en/self-hosted-sandboxes#before-you-begin).

        <CodeGroup exclude="shell">
          ```python Python theme={null}
          import asyncio
          import os
          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:
                  await EnvironmentWorker(
                      client,
                      environment_id=environment_id,
                      environment_key=environment_key,
                      workdir="/workspace",
                  ).run()


          asyncio.run(main())
          ```

          ```typescript TypeScript theme={null}
          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();
          process.once("SIGTERM", () => controller.abort());

          await new EnvironmentWorker({
            client,
            environmentId,
            environmentKey,
            workdir: "/workspace",
            signal: controller.signal
          }).run();
          ```

          ```csharp C# theme={null}
          // EnvironmentWorker is not currently available in the C# SDK. See the Always-on (ant CLI) tab.
          ```

          ```go Go theme={null}
          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 theme={null}
          // EnvironmentWorker is not currently available in the Java SDK. See the Always-on (ant CLI) tab.
          ```

          ```php PHP theme={null}
          // EnvironmentWorker is not currently available in the PHP SDK. See the Always-on (ant CLI) tab.
          ```

          ```ruby Ruby theme={null}
          # EnvironmentWorker is not currently available in the Ruby SDK. See the Always-on (ant CLI) tab.
          ```
        </CodeGroup>
      </Step>
    </Steps>
  </Tab>

  <Tab title="Webhook-triggered (SDK)">
    <Steps>
      <Step title="Subscribe to session webhooks">
        In the Console, define a webhook endpoint that listens for `session.status_run_started` events. See [Webhooks](/docs/en/webhooks) for details.
      </Step>

      <Step title="Export the webhook signing key">
        In addition to the environment ID and key from [Before you begin](/docs/en/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]"`.

        ```bash theme={null}
        export ANTHROPIC_WEBHOOK_SIGNING_KEY="whsec_..."
        ```
      </Step>

      <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.

        <CodeGroup exclude="shell">
          ```python Python theme={null}
          import os
          import anthropic

          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)
              if event.data.type != "session.status_run_started":
                  return {"status": "ignored"}
              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,
                  )
              return {"status": "ok"}
          ```

          ```typescript TypeScript theme={null}
          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
          });

          export async function handle(req: Request): Promise<Response> {
            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
            })) {
              await client.beta.environments.work.worker({ workdir: "/workspace" }).handleItem({
                workId: work.id,
                environmentId,
                sessionId: work.data.id,
                environmentKey
              });
            }
            return Response.json({ status: "ok" });
          }
          ```

          ```csharp C# theme={null}
          // EnvironmentWorker is not currently available in the C# SDK.
          // To handle work items directly, see the Environments Work endpoints.
          ```

          ```go Go theme={null}
          package main

          import (
              "context"
              "encoding/json"
              "io"
              "log/slog"
              "net/http"
              "os"

              "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"
          )

          var (
              environmentKey = os.Getenv("ANTHROPIC_ENVIRONMENT_KEY")
              environmentID  = os.Getenv("ANTHROPIC_ENVIRONMENT_ID")
              client         = anthropic.NewClient(
                  option.WithAuthToken(environmentKey),
                  option.WithWebhookKey(os.Getenv("ANTHROPIC_WEBHOOK_SIGNING_KEY")),
              )
              worker = environments.NewEnvironmentWorker(client, environments.EnvironmentWorkerOptions{
                  Workdir: "/workspace",
              })
          )

          func handle(w http.ResponseWriter, r *http.Request) {
              body, err := io.ReadAll(r.Body)
              if err != nil {
                  http.Error(w, "bad request", http.StatusBadRequest)
                  return
              }
              event, err := client.Beta.Webhooks.Unwrap(body, r.Header)
              if err != nil {
                  http.Error(w, "signature verification failed", http.StatusUnauthorized)
                  return
              }
              if event.Data.Type != "session.status_run_started" {
                  json.NewEncoder(w).Encode(map[string]string{"status": "ignored"})
                  return
              }

              // 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()
              poller := environments.NewWorkPoller(ctx, client, environments.WorkPollerOptions{
                  EnvironmentID:      environmentID,
                  EnvironmentKey:     environmentKey,
                  BlockMs:            param.Null[int64](),
                  ReclaimOlderThanMs: param.NewOpt[int64](2000),
                  Drain:              true,
              })
              defer poller.Close()
              for poller.Next() {
                  item := poller.Current()
                  if err := worker.HandleItem(ctx, environments.HandleItemOptions{
                      WorkID:         item.ID,
                      EnvironmentID:  item.EnvironmentID,
                      SessionID:      item.Data.ID,
                      EnvironmentKey: environmentKey,
                  }); err != nil {
                      slog.Error("handle work item", "work_id", item.ID, "err", err)
                      http.Error(w, "internal error", http.StatusInternalServerError)
                      return
                  }
              }
              if err := poller.Err(); err != nil {
                  slog.Error("poll work queue", "err", err)
                  http.Error(w, "internal error", http.StatusInternalServerError)
                  return
              }
              json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
          }

          func main() {
              http.HandleFunc("POST /webhook", handle)
              if err := http.ListenAndServe(":8080", nil); err != nil {
                  slog.Error("http server", "err", err)
                  os.Exit(1)
              }
          }

          ```

          ```java Java theme={null}
          // EnvironmentWorker is not currently available in the Java SDK.
          // To handle work items directly, see the Environments Work endpoints.
          ```

          ```php PHP theme={null}
          // EnvironmentWorker is not currently available in the PHP SDK.
          // To handle work items directly, see the Environments Work endpoints.
          ```

          ```ruby Ruby theme={null}
          # EnvironmentWorker is not currently available in the Ruby SDK.
          # To handle work items directly, see the Environments Work endpoints.
          ```
        </CodeGroup>
      </Step>
    </Steps>
  </Tab>
</Tabs>

### SDK helpers

The SDK provides three helpers at different levels of control. `EnvironmentWorker` covers most use cases; drop to the lower-level helpers when you need to launch your own per-session process or run tools against an already-claimed session.

* **`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.

* **`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.

* **`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.

Use the work poller directly when you want to launch your own per-session process, for example spinning up a sandbox for each claimed session:

<CodeGroup>
  ```bash cURL theme={null}
  # The work poller is an SDK helper (Python, TypeScript, Go), not a raw
  # endpoint. From the shell, use `ant beta:worker poll --on-work` instead;
  # see the Always-on (ant CLI) tab.
  ```

  ```bash CLI theme={null}
  # The work poller is an SDK helper (Python, TypeScript, Go), not a raw
  # endpoint. From the shell, use `ant beta:worker poll --on-work` instead;
  # see the Always-on (ant CLI) tab.
  ```

  ```python Python theme={null}
  import asyncio
  import os

  from anthropic import AsyncAnthropic
  from anthropic.types.beta.environments import BetaSelfHostedWork


  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}")


  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:
          async for work in client.beta.environments.work.poller(
              environment_id=environment_id,
              environment_key=environment_key,
              auto_stop=False,  # the launched sandbox owns the stop call
          ):
              await launch_container(work)


  asyncio.run(main())
  ```

  ```typescript TypeScript theme={null}
  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 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}`);
  }

  const poller = new WorkPoller({
    client,
    environmentId,
    environmentKey,
    autoStop: false // the launched sandbox owns the stop call
  });

  for await (const work of poller) {
    await launchContainer(work);
  }
  ```

  ```csharp C# theme={null}
  // A work-polling helper is not currently available in the C# SDK.
  // To claim work directly, see the Environments Work endpoints.
  ```

  ```go Go theme={null}
  package main

  import (
      "context"
      "fmt"
      "log"
      "os"

      "github.com/anthropics/anthropic-sdk-go"
      "github.com/anthropics/anthropic-sdk-go/lib/environments"
      "github.com/anthropics/anthropic-sdk-go/option"
  )

  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.
      fmt.Printf("claimed session %s\n", work.Data.ID)
  }

  func main() {
      environmentID := os.Getenv("ANTHROPIC_ENVIRONMENT_ID")
      environmentKey := os.Getenv("ANTHROPIC_ENVIRONMENT_KEY")

      client := anthropic.NewClient(option.WithAuthToken(environmentKey))

      ctx := context.Background()

      poller := environments.NewWorkPoller(ctx, client, environments.WorkPollerOptions{
          EnvironmentID:  environmentID,
          EnvironmentKey: environmentKey,
      })
      defer poller.Close()

      for work, err := range poller.All() {
          if err != nil {
              log.Fatal(err)
          }
          launchContainer(work)
      }
  }
  ```

  ```java Java theme={null}
  // A work-polling helper is not currently available in the Java SDK.
  // To claim work directly, see the Environments Work endpoints.
  ```

  ```php PHP theme={null}
  // A work-polling helper is not currently available in the PHP SDK.
  // To claim work directly, see the Environments Work endpoints.
  ```

  ```ruby Ruby theme={null}
  # A work-polling helper is not currently available in the Ruby SDK.
  # To claim work directly, see the Environments Work endpoints.
  ```
</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`).

**With `EnvironmentWorker`:** both are managed automatically. Pass a `tools` factory to customize the tool list:

<CodeGroup exclude="shell">
  ```python Python theme={null}
  EnvironmentWorker(client, ..., tools=lambda env: [beta_bash_tool(env), my_custom_tool])
  ```

  ```typescript TypeScript theme={null}
  new EnvironmentWorker({
    client,
    environmentId,
    environmentKey,
    tools: (ctx) => [betaBashTool(ctx), myCustomTool]
  });
  ```

  ```csharp C# theme={null}
  // EnvironmentWorker is not currently available in the C# SDK.
  // To answer custom tool calls directly, see the session event stream.
  ```

  ```go Go theme={null}
  worker := environments.NewEnvironmentWorker(client, environments.EnvironmentWorkerOptions{
      EnvironmentID:  environmentID,
      EnvironmentKey: environmentKey,
      ToolsFunc: func(env *agenttoolset.AgentToolContext) []anthropic.BetaTool {
          return []anthropic.BetaTool{agenttoolset.BetaBashTool(env), myCustomTool}
      },
  })
  ```

  ```java Java theme={null}
  // EnvironmentWorker is not currently available in the Java SDK.
  // To answer custom tool calls directly, see the session event stream.
  ```

  ```php PHP theme={null}
  // EnvironmentWorker is not currently available in the PHP SDK.
  // To answer custom tool calls directly, see the session event stream.
  ```

  ```ruby Ruby theme={null}
  # EnvironmentWorker is not currently available in the Ruby SDK.
  # To answer custom tool calls directly, see the session event stream.
  ```
</CodeGroup>

**With `work.poller()` 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)`:

<CodeGroup exclude="shell">
  ```python Python theme={null}
  from anthropic.lib.tools.agent_toolset import (
      AgentToolContext,
      beta_agent_toolset_20260401,
  )

  async with AgentToolContext(
      workdir="/workspace", client=client, session_id=work.data.id
  ) as env:
      # skills downloaded to /workspace/skills/<name>/
      tools = beta_agent_toolset_20260401(env)
  ```

  ```typescript TypeScript theme={null}
  import {
    setupSkills,
    betaAgentToolset20260401
  } from "@anthropic-ai/sdk/tools/agent-toolset/node";

  const ctx = { workdir: "/workspace", client, sessionId: work.data.id };
  await setupSkills(ctx);
  const tools = betaAgentToolset20260401(ctx);
  ```

  ```csharp C# theme={null}
  // AgentToolContext is not currently available in the C# SDK.
  ```

  ```go Go theme={null}
  env := &agenttoolset.AgentToolContext{Workdir: "/workspace"}
  if err := env.SetupSkills(ctx, client, work.Data.ID); err != nil {
      panic(err)
  }
  // skills downloaded to /workspace/skills/<name>/
  tools := agenttoolset.BetaAgentToolset20260401(env)
  ```

  ```java Java theme={null}
  // AgentToolContext is not currently available in the Java SDK.
  ```

  ```php PHP theme={null}
  // AgentToolContext is not currently available in the PHP SDK.
  ```

  ```ruby Ruby theme={null}
  # AgentToolContext is not currently available in the Ruby SDK.
  ```
</CodeGroup>

### Verify the worker is connected

From a separate shell, with `OMA_API_KEY` set to your OMA API key (not the environment key), confirm `workers_polling` is at least 1:

```bash theme={null}
ant beta:environments:work stats --environment-id "$ANTHROPIC_ENVIRONMENT_ID"
```

If `workers_polling` stays at 0, the worker isn't reaching the queue: confirm `ANTHROPIC_ENVIRONMENT_KEY` and `ANTHROPIC_ENVIRONMENT_ID` are set on the worker host. See [Read queue depth](/docs/en/self-hosted-sandboxes#read-queue-depth) for the full stats response and other language examples.

## Start a session

Once your worker is running, create a session that targets the environment. Set `AGENT_ID` to the agent ID you noted in [Before you begin](/docs/en/self-hosted-sandboxes#before-you-begin). The session enters the environment's work queue and waits there until a worker claims it; if no worker is connected, the session stays queued rather than failing.

OMA doesn't mount files or GitHub repositories into self-hosted sandboxes. To make session-specific files available, pass file references (such as an S3 path or commit SHA) in the session `metadata` field. The claimed work item doesn't carry the session's metadata, but it does carry the session ID: your spawn script or `--on-work` handler retrieves the session (`GET /v1/sessions/{session_id}`) to read the `metadata` field, then stages the files into the working directory before tool execution begins.

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS --fail-with-body http://localhost:38080/v1/sessions \
    -H "x-api-key: $OMA_API_KEY" \
    -H "anthropic-version: 2023-06-01" \
    -H "anthropic-beta: managed-agents-2026-04-01" \
    -H "content-type: application/json" \
    -d @- <<EOF
  {
    "agent": "$AGENT_ID",
    "environment_id": "$ANTHROPIC_ENVIRONMENT_ID",
    "metadata": {"input_file": "s3://my-bucket/data.csv"}
  }
  EOF
  ```

  ```bash CLI theme={null}
  ant beta:sessions create \
    --agent "$AGENT_ID" \
    --environment-id "$ANTHROPIC_ENVIRONMENT_ID" \
    --metadata '{"input_file": "s3://my-bucket/data.csv"}'
  ```

  ```python Python theme={null}
  session = client.beta.sessions.create(
      agent=agent.id,
      environment_id=environment.id,
      metadata={"input_file": "s3://my-bucket/data.csv"},
  )
  ```

  ```typescript TypeScript theme={null}
  const session = await client.beta.sessions.create({
    agent: agent.id,
    environment_id: environment.id,
    metadata: { input_file: "s3://my-bucket/data.csv" }
  });
  ```

  ```csharp C# theme={null}
  var session = await client.Beta.Sessions.Create(new()
  {
      Agent = agent.ID,
      EnvironmentID = environment.ID,
      Metadata = new Dictionary<string, string> { ["input_file"] = "s3://my-bucket/data.csv" },
  });
  ```

  ```go Go theme={null}
  session, err := client.Beta.Sessions.New(ctx, anthropic.BetaSessionNewParams{
      Agent:         anthropic.BetaSessionNewParamsAgentUnion{OfString: anthropic.String(agent.ID)},
      EnvironmentID: environment.ID,
      Metadata: map[string]string{
          "input_file": "s3://my-bucket/data.csv",
      },
  })
  if err != nil {
      panic(err)
  }
  ```

  ```java Java theme={null}
  var session = client.beta().sessions().create(SessionCreateParams.builder()
      .agent(agent.id())
      .environmentId(environment.id())
      .metadata(SessionCreateParams.Metadata.builder()
          .putAdditionalProperty("input_file", JsonValue.from("s3://my-bucket/data.csv"))
          .build())
      .build());
  ```

  ```php PHP theme={null}
  $session = $client->beta->sessions->create(
      agent: $agent->id,
      environmentID: $environment->id,
      metadata: ['input_file' => 's3://my-bucket/data.csv'],
  );
  ```

  ```ruby Ruby theme={null}
  session = client.beta.sessions.create(
    agent: agent.id,
    environment_id: environment.id,
    metadata: {input_file: "s3://my-bucket/data.csv"}
  )
  ```
</CodeGroup>

<Note>
  Self-hosted sandboxes don't support `resources` entries; a session that includes any resource on a self-hosted environment is rejected.
</Note>

See [Self-hosted worker](/docs/en/reference#self-hosted-worker) in the reference for the full list of CLI flags, and [SDK helpers](/docs/en/self-hosted-sandboxes#sdk-helpers) for the SDK helper options.

## Serve custom tools from your sandbox

[Custom tools](/docs/en/tools#custom-tools) are tools your own code executes: the agent emits an `agent.custom_tool_use` event and waits for a matching `user.custom_tool_result`. The worker can be that code, and because it runs inside your sandbox, the tool reaches the internal services, credentials, and network egress you configured for the sandbox, and nothing more. The environment key authorizes posting custom tool results, so your OMA API key stays off the worker host.

<Note>
  Serving custom tools requires the SDK worker: the `ant` CLI worker has no way to register a custom tool implementation. In the sandbox-per-session pattern, run `EnvironmentWorker` inside the sandbox with `handle_item()` (`handleItem` in TypeScript, `HandleItem` in Go) in place of `ant beta:worker run`.
</Note>

<Steps>
  <Step title="Declare the tool on the agent">
    Add a `custom` entry to the agent's `tools` whose `name` matches the tool your worker registers. See [Custom tools](/docs/en/tools#custom-tools) for the full declaration shape.

    ```json theme={null}
    {
      "type": "custom",
      "name": "get_order_status",
      "description": "Look up an order in the internal fulfillment system by order ID.",
      "input_schema": {
        "type": "object",
        "properties": {
          "order_id": { "type": "string", "description": "The order ID" }
        },
        "required": ["order_id"]
      }
    }
    ```
  </Step>

  <Step title="Register the implementation with the worker">
    Pass the tool through the worker's `tools` factory (see [SDK helpers](/docs/en/self-hosted-sandboxes#sdk-helpers)), alongside the built-in toolset:

    <CodeGroup exclude="shell">
      ```python Python theme={null}
      import asyncio
      import os
      from anthropic import AsyncAnthropic, beta_async_tool
      from anthropic.lib.environments import EnvironmentWorker
      from anthropic.lib.tools.agent_toolset import beta_agent_toolset_20260401


      @beta_async_tool
      async def get_order_status(order_id: str) -> str:
          """Look up an order in the internal fulfillment system by order ID."""
          # Runs on the worker host: call anything the sandbox can reach.
          return f"Order {order_id}: shipped"


      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:
              await EnvironmentWorker(
                  client,
                  environment_id=environment_id,
                  environment_key=environment_key,
                  workdir="/workspace",
                  tools=lambda env: [*beta_agent_toolset_20260401(env), get_order_status],
              ).run()


      asyncio.run(main())
      ```

      ```typescript TypeScript theme={null}
      import Anthropic from "@anthropic-ai/sdk";
      import { EnvironmentWorker } from "@anthropic-ai/sdk/helpers/beta/environments";
      import { betaTool } from "@anthropic-ai/sdk/helpers/beta/json-schema";
      import { betaAgentToolset20260401 } from "@anthropic-ai/sdk/tools/agent-toolset/node";

      const getOrderStatus = betaTool({
        name: "get_order_status",
        description: "Look up an order in the internal fulfillment system by order ID.",
        inputSchema: {
          type: "object",
          properties: { order_id: { type: "string", description: "The order ID" } },
          required: ["order_id"]
        },
        // Runs on the worker host: call anything the sandbox can reach.
        run: async ({ order_id }) => `Order ${order_id}: shipped`
      });

      const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY!;
      const environmentId = process.env.ANTHROPIC_ENVIRONMENT_ID!;
      const client = new Anthropic({ authToken: environmentKey });
      const controller = new AbortController();
      process.once("SIGTERM", () => controller.abort());

      await new EnvironmentWorker({
        client,
        environmentId,
        environmentKey,
        workdir: "/workspace",
        signal: controller.signal,
        tools: (ctx) => [...betaAgentToolset20260401(ctx), getOrderStatus]
      }).run();
      ```

      ```csharp C# theme={null}
      // EnvironmentWorker is not currently available in the C# SDK.
      // To answer custom tool calls directly, see the session event stream.
      ```

      ```go Go theme={null}
      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"
          "github.com/anthropics/anthropic-sdk-go/toolrunner"
          "github.com/anthropics/anthropic-sdk-go/tools/agenttoolset"
      )

      type orderStatusInput struct {
          OrderID string `json:"order_id"`
      }

      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()

          getOrderStatus := toolrunner.NewBetaTool(
              "get_order_status",
              "Look up an order in the internal fulfillment system by order ID.",
              anthropic.BetaToolInputSchemaParam{
                  Properties: map[string]any{
                      "order_id": map[string]any{"type": "string", "description": "The order ID"},
                  },
                  Required: []string{"order_id"},
              },
              // Runs on the worker host: call anything the sandbox can reach.
              func(ctx context.Context, input orderStatusInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
                  return anthropic.BetaToolResultBlockParamContentUnion{
                      OfText: &anthropic.BetaTextBlockParam{Text: "Order " + input.OrderID + ": shipped"},
                  }, nil
              },
          )

          client := anthropic.NewClient(option.WithAuthToken(environmentKey))

          worker := environments.NewEnvironmentWorker(client, environments.EnvironmentWorkerOptions{
              EnvironmentID:  environmentID,
              EnvironmentKey: environmentKey,
              Workdir:        "/workspace",
              ToolsFunc: func(env *agenttoolset.AgentToolContext) []anthropic.BetaTool {
                  return append(agenttoolset.BetaAgentToolset20260401(env), getOrderStatus)
              },
          })
          if err := worker.Run(ctx); err != nil {
              log.Fatalf("worker: %v", err)
          }
      }

      ```

      ```java Java theme={null}
      // EnvironmentWorker is not currently available in the Java SDK.
      // To answer custom tool calls directly, see the session event stream.
      ```

      ```php PHP theme={null}
      // EnvironmentWorker is not currently available in the PHP SDK.
      // To answer custom tool calls directly, see the session event stream.
      ```

      ```ruby Ruby theme={null}
      # EnvironmentWorker is not currently available in the Ruby SDK.
      # To answer custom tool calls directly, see the session event stream.
      ```
    </CodeGroup>
  </Step>
</Steps>

The worker answers only the tools registered with it. A custom tool that is declared on the agent but registered with no worker or client leaves the session paused with a `requires_action` stop reason until something posts its result; see [Handling custom tool calls](/docs/en/events-and-streaming#handling-custom-tool-calls) for the event flow.

### Wrap an MCP server as custom tools

The [MCP connector](/docs/en/mcp-connector) connects to MCP servers from OMA's side, so a server must expose an HTTP endpoint that OMA can reach, directly or through an [MCP tunnel](/docs/en/mcp-connector). To use a server that only your network can reach, make the worker the MCP client instead and declare the server's tools as custom tools. The MCP server needs no inbound connectivity from outside your network; OMA receives the tool definitions you declare on the agent, each call's input, and the result your worker posts back. At runtime the model calls a wrapped tool like any other custom tool:

1. The agent emits an `agent.custom_tool_use` event.
2. The worker, inside your sandbox, forwards the call over its open MCP session to the server on your network.
3. The worker posts the server's response as the `user.custom_tool_result`.

The SDKs' [Client-side MCP helpers](/docs/en/mcp-connector) convert the server's tools into the runnable tools the worker accepts; install an MCP SDK alongside the OMA 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` in Python, `requestInit` in TypeScript, `HTTPClient` in Go).

<Steps>
  <Step title="Declare the server's tools on the agent">
    List the MCP server's tools and declare each one as a `custom` tool; the MCP `name`, `description`, and `inputSchema` map one to one onto the custom tool's fields. If the server paginates its tool list, declare every page; the worker must list the same pages.

    <CodeGroup exclude="shell">
      ```python Python theme={null}
      import asyncio
      from typing import Any, cast
      from anthropic import AsyncAnthropic
      from anthropic.types.beta import BetaManagedAgentsCustomToolParams
      from mcp import ClientSession, types
      # Requires mcp >= 1.24, which renamed streamablehttp_client to streamable_http_client.
      from mcp.client.streamable_http import streamable_http_client

      MCP_SERVER_URL = "http://mcp.internal.example.com:8000/mcp"


      def to_custom_tool(tool: types.Tool) -> BetaManagedAgentsCustomToolParams:
          # The MCP fields map one to one onto a custom tool declaration. The cast
          # hands the schema dictionary to the SDK's typed parameter unchanged.
          return {
              "type": "custom",
              "name": tool.name,
              "description": tool.description or tool.name,
              "input_schema": cast(Any, tool.inputSchema),
          }


      async def main() -> None:
          # Run this wherever you create agents, not on the worker host: it
          # authenticates with your OMA API key (OMA_API_KEY).
          async with (
              streamable_http_client(MCP_SERVER_URL) as (read, write, _),
              ClientSession(read, write) as mcp_session,
              AsyncAnthropic() as client,
          ):
              await mcp_session.initialize()
              listed = await mcp_session.list_tools()
              agent = await client.beta.agents.create(
                  name="Internal tools agent",
                  model="claude-opus-5",
                  tools=[
                      {"type": "agent_toolset_20260401"},
                      *[to_custom_tool(tool) for tool in listed.tools],
                  ],
              )
              print(agent.id)


      asyncio.run(main())
      ```

      ```typescript TypeScript theme={null}
      import Anthropic from "@anthropic-ai/sdk";
      import { Client } from "@modelcontextprotocol/sdk/client/index.js";
      import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

      const MCP_SERVER_URL = "http://mcp.internal.example.com:8000/mcp";

      // Run this wherever you create agents, not on the worker host: it
      // authenticates with your OMA API key (OMA_API_KEY).
      const client = new Anthropic();

      const mcpClient = new Client({ name: "declare-agent-tools", version: "1.0.0" });
      await mcpClient.connect(new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL)));
      const { tools } = await mcpClient.listTools();

      const agent = await client.beta.agents.create({
        name: "Internal tools agent",
        model: "claude-opus-5",
        tools: [
          { type: "agent_toolset_20260401" },
          // The MCP fields map one to one onto a custom tool declaration.
          ...tools.map((tool) => ({
            type: "custom" as const,
            name: tool.name,
            description: tool.description || tool.name,
            input_schema: tool.inputSchema
          }))
        ]
      });
      console.log(agent.id);

      await mcpClient.close();
      ```

      ```csharp C# theme={null}
      // See the Python, TypeScript, and Go tabs. Declaring custom tools from
      // C# works the same way once you list the server's tools with an MCP client.
      ```

      ```go Go theme={null}
      package main

      import (
          "context"
          "encoding/json"
          "fmt"
          "log"

          "github.com/anthropics/anthropic-sdk-go"
          mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
      )

      const mcpServerURL = "http://mcp.internal.example.com:8000/mcp"

      // toCustomTool maps one MCP tool definition onto a custom tool declaration.
      // The fields map one to one: the typed parameter carries `properties` and
      // `required`, and every other JSON Schema keyword the server emits travels in
      // ExtraFields so the declared schema matches the server's schema.
      func toCustomTool(tool *mcpsdk.Tool) (anthropic.BetaAgentNewParamsToolUnion, error) {
          raw, err := json.Marshal(tool.InputSchema)
          if err != nil {
              return anthropic.BetaAgentNewParamsToolUnion{}, err
          }
          var schema map[string]any
          if err := json.Unmarshal(raw, &schema); err != nil {
              return anthropic.BetaAgentNewParamsToolUnion{}, err
          }

          inputSchema := anthropic.BetaManagedAgentsCustomToolInputSchemaParam{ExtraFields: map[string]any{}}
          for keyword, value := range schema {
              switch keyword {
              case "type":
                  // The parameter type always marshals "type": "object".
              case "properties":
                  properties, _ := value.(map[string]any)
                  inputSchema.Properties = properties
              case "required":
                  entries, _ := value.([]any)
                  for _, entry := range entries {
                      if name, isString := entry.(string); isString {
                          inputSchema.Required = append(inputSchema.Required, name)
                      }
                  }
              default:
                  inputSchema.ExtraFields[keyword] = value
              }
          }

          description := tool.Description
          if description == "" {
              description = tool.Name
          }
          return anthropic.BetaAgentNewParamsToolUnion{
              OfCustom: &anthropic.BetaManagedAgentsCustomToolParams{
                  Type:        anthropic.BetaManagedAgentsCustomToolParamsTypeCustom,
                  Name:        tool.Name,
                  Description: description,
                  InputSchema: inputSchema,
              },
          }, nil
      }

      func main() {
          ctx := context.Background()

          // Run this wherever you create agents, not on the worker host: it
          // authenticates with your OMA API key (OMA_API_KEY).
          client := anthropic.NewClient()

          mcpClient := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "declare-agent-tools", Version: "1.0.0"}, nil)
          session, err := mcpClient.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: mcpServerURL}, nil)
          if err != nil {
              log.Fatalf("connect to MCP server: %v", err)
          }
          defer session.Close()

          listed, err := session.ListTools(ctx, nil)
          if err != nil {
              log.Fatalf("list MCP tools: %v", err)
          }

          tools := []anthropic.BetaAgentNewParamsToolUnion{
              {OfAgentToolset20260401: &anthropic.BetaManagedAgentsAgentToolset20260401Params{
                  Type: anthropic.BetaManagedAgentsAgentToolset20260401ParamsTypeAgentToolset20260401,
              }},
          }
          for _, tool := range listed.Tools {
              custom, err := toCustomTool(tool)
              if err != nil {
                  log.Fatalf("convert MCP tool %s: %v", tool.Name, err)
              }
              tools = append(tools, custom)
          }

          agent, err := client.Beta.Agents.New(ctx, anthropic.BetaAgentNewParams{
              Name:  "Internal tools agent",
              Model: anthropic.BetaManagedAgentsModelConfigParams{ID: anthropic.BetaManagedAgentsModelClaudeOpus5},
              Tools: tools,
          })
          if err != nil {
              log.Fatalf("create agent: %v", err)
          }
          fmt.Println(agent.ID)
      }

      ```

      ```java Java theme={null}
      // See the Python, TypeScript, and Go tabs. Declaring custom tools from
      // Java works the same way once you list the server's tools with an MCP client.
      ```

      ```php PHP theme={null}
      // See the Python, TypeScript, and Go tabs. Declaring custom tools from
      // PHP works the same way once you list the server's tools with an MCP client.
      ```

      ```ruby Ruby theme={null}
      # See the Python, TypeScript, and Go tabs. Declaring custom tools from
      # Ruby works the same way once you list the server's tools with an MCP client.
      ```
    </CodeGroup>
  </Step>

  <Step title="Serve the tools from the worker">
    Connect to the same MCP server at startup, convert its tools with the MCP helpers, and register them alongside the built-in toolset. Keep one MCP session open for the life of the worker.

    <CodeGroup exclude="shell">
      ```python Python theme={null}
      import asyncio
      import os
      from datetime import timedelta
      from anthropic import AsyncAnthropic
      from anthropic.lib.environments import EnvironmentWorker
      from anthropic.lib.tools.agent_toolset import beta_agent_toolset_20260401
      from anthropic.lib.tools.mcp import async_mcp_tool
      from mcp import ClientSession
      # Requires mcp >= 1.24, which renamed streamablehttp_client to streamable_http_client.
      from mcp.client.streamable_http import streamable_http_client

      MCP_SERVER_URL = "http://mcp.internal.example.com:8000/mcp"


      async def main() -> None:
          environment_key = os.environ["ANTHROPIC_ENVIRONMENT_KEY"]
          environment_id = os.environ["ANTHROPIC_ENVIRONMENT_ID"]
          # Connect to the MCP server once at startup and keep the session open for
          # the life of the worker. The timeout turns a hung tool call into an error
          # result instead of a stalled call.
          async with (
              streamable_http_client(MCP_SERVER_URL) as (read, write, _),
              ClientSession(read, write, read_timeout_seconds=timedelta(seconds=60)) as mcp_session,
              AsyncAnthropic(auth_token=environment_key) as client,
          ):
              await mcp_session.initialize()
              listed = await mcp_session.list_tools()
              mcp_tools = [async_mcp_tool(tool, mcp_session) for tool in listed.tools]
              await EnvironmentWorker(
                  client,
                  environment_id=environment_id,
                  environment_key=environment_key,
                  workdir="/workspace",
                  tools=lambda env: [*beta_agent_toolset_20260401(env), *mcp_tools],
              ).run()


      asyncio.run(main())
      ```

      ```typescript TypeScript theme={null}
      import Anthropic from "@anthropic-ai/sdk";
      import { EnvironmentWorker } from "@anthropic-ai/sdk/helpers/beta/environments";
      import {
        mcpTools,
        type MCPCallToolResultLike,
        type MCPClientLike
      } from "@anthropic-ai/sdk/helpers/beta/mcp";
      import { betaAgentToolset20260401 } from "@anthropic-ai/sdk/tools/agent-toolset/node";
      import { Client } from "@modelcontextprotocol/sdk/client/index.js";
      import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

      const MCP_SERVER_URL = "http://mcp.internal.example.com:8000/mcp";

      const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY!;
      const environmentId = process.env.ANTHROPIC_ENVIRONMENT_ID!;
      const client = new Anthropic({ authToken: environmentKey });
      const controller = new AbortController();
      process.once("SIGTERM", () => controller.abort());

      // Connect to the MCP server once at startup and keep the connection open for
      // the life of the worker.
      const mcpClient = new Client({ name: "sandbox-worker", version: "1.0.0" });
      await mcpClient.connect(new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL)));
      const { tools } = await mcpClient.listTools();

      // The MCP SDK's callTool return type still includes a legacy result shape that
      // mcpTools does not accept; narrow it. Drop this once MCPClientLike widens.
      const mcpClientForTools: MCPClientLike = {
        callTool: (params) => mcpClient.callTool(params) as Promise<MCPCallToolResultLike>
      };

      await new EnvironmentWorker({
        client,
        environmentId,
        environmentKey,
        workdir: "/workspace",
        signal: controller.signal,
        tools: (ctx) => [...betaAgentToolset20260401(ctx), ...mcpTools(tools, mcpClientForTools)]
      }).run();
      ```

      ```csharp C# theme={null}
      // EnvironmentWorker is not currently available in the C# SDK.
      ```

      ```go Go theme={null}
      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/mcp"
          "github.com/anthropics/anthropic-sdk-go/option"
          "github.com/anthropics/anthropic-sdk-go/tools/agenttoolset"
          mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
      )

      const mcpServerURL = "http://mcp.internal.example.com:8000/mcp"

      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))

          // Connect to the MCP server once at startup and keep the session open for
          // the life of the worker.
          mcpClient := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "sandbox-worker", Version: "1.0.0"}, nil)
          session, err := mcpClient.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: mcpServerURL}, nil)
          if err != nil {
              log.Fatalf("connect to MCP server: %v", err)
          }
          defer session.Close()

          listed, err := session.ListTools(ctx, nil)
          if err != nil {
              log.Fatalf("list MCP tools: %v", err)
          }
          mcpTools, err := mcp.NewBetaTools(listed.Tools, session)
          if err != nil {
              log.Fatalf("convert MCP tools: %v", err)
          }

          worker := environments.NewEnvironmentWorker(client, environments.EnvironmentWorkerOptions{
              EnvironmentID:  environmentID,
              EnvironmentKey: environmentKey,
              Workdir:        "/workspace",
              ToolsFunc: func(env *agenttoolset.AgentToolContext) []anthropic.BetaTool {
                  return append(agenttoolset.BetaAgentToolset20260401(env), mcpTools...)
              },
          })
          if err := worker.Run(ctx); err != nil {
              log.Fatalf("worker: %v", err)
          }
      }

      ```

      ```java Java theme={null}
      // EnvironmentWorker is not currently available in the Java SDK.
      ```

      ```php PHP theme={null}
      // EnvironmentWorker is not currently available in the PHP SDK.
      ```

      ```ruby Ruby theme={null}
      # EnvironmentWorker is not currently available in the Ruby SDK.
      ```
    </CodeGroup>
  </Step>
</Steps>

Keep the following in mind when you wrap an MCP server:

* **Tools are declared, not discovered at runtime.** The worker lists the MCP server's tools once at startup and cannot add tools to a running session. When the server's tools change, declare them again, on the agent or on an idle session through [Updating the agent configuration](/docs/en/session-operations#updating-the-agent-configuration), and restart the worker.
* **Names and descriptions must fit the Managed Agents API.** Custom tool names are unique per agent and use letters, digits, underscores, and hyphens (1–128 characters); a non-empty description is required; and an agent's `tools` array takes at most 128 entries (each wrapped tool is one entry, and the built-in toolset is one more). The API rejects a declaration that reuses a tool name, names a custom tool after a built-in agent tool such as `bash` or `read`, or uses the reserved `mcp__` prefix. The MCP helpers keep the server's names and descriptions, so rename or trim where needed. When two servers expose the same tool name, define the wrapper yourself under a prefixed name and have it call the server's original tool name.
* **Most schemas pass through unchanged.** The API accepts the JSON Schema keywords MCP servers commonly emit, such as `additionalProperties` and `title`. It rejects reference keywords such as `$ref` anywhere in a custom tool's `input_schema`, so inline the schemas that generators such as pydantic factor into `$defs`. It also rejects top-level `oneOf`, `anyOf`, and `allOf`, and property names outside letters, digits, underscores, dots, and hyphens (1–64 characters).
* **Tool failures surface as error tool results.** When the MCP server reports a tool error, the worker posts an error tool result the model can react to. MCP content with no tool result equivalent, such as audio blocks and resource links, also surfaces as an error. Set a timeout on the MCP client for a faster and clearer failure, as the Python worker example does with `read_timeout_seconds`. Without one, a hung call becomes an error result only when the TypeScript MCP SDK's default request timeout fires (about a minute) or when the worker's own backstop does: about two and a half minutes in Python, and two minutes in Go, where the worker cancels a tool call that outlives its 120-second default and posts an error result.
* **Wrap servers you operate or trust.** A wrapped tool's name, description, and results enter the model's context like any other tool's: untrusted input that can influence what the agent does with its other tools, including `bash` on the worker host. Declare only the tools you intend the agent to use.
* **Permission policies do not apply to custom tools.** [Permission policies](/docs/en/permission-policies#custom-tools) govern the built-in and MCP toolsets; the worker executes every wrapped tool call the model makes, so put any approval step in your own tool code.

## Monitoring and operations

These calls run from your monitoring or operations tooling, authenticated with your OMA API key, to observe and manage the worker fleet. The claim and keep-alive loop is handled inside the worker helpers, so you don't call those endpoints directly.

<Warning>
  These endpoints accept either your organization API key or the environment key. Call them from outside the worker host with your organization API key. Setting `OMA_API_KEY` on the worker host exposes an organization-scoped credential to agent tool calls.
</Warning>

### Read queue depth

`work.stats` returns the queue state for an environment:

* `depth` is the number of items waiting to be claimed. Scale your worker fleet or alert on backlog based on this value.
* `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.
* `oldest_queued_at` is the timestamp of the oldest item still in the queue, waiting to be claimed or claimed but not yet acknowledged, or `null` when there is none.
* `workers_polling` is the number of workers that have polled in the last 30 seconds. Use this for liveness alerting.

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS "http://localhost:38080/v1/environments/$ANTHROPIC_ENVIRONMENT_ID/work/stats" \
    -H "x-api-key: $OMA_API_KEY" \
    -H "anthropic-beta: managed-agents-2026-04-01" \
    -H "anthropic-version: 2023-06-01"
  ```

  ```bash CLI theme={null}
  ant beta:environments:work stats --environment-id "$ANTHROPIC_ENVIRONMENT_ID"
  ```

  ```python Python theme={null}
  import os

  import anthropic

  client = anthropic.Anthropic()

  stats = client.beta.environments.work.stats(os.environ["ANTHROPIC_ENVIRONMENT_ID"])
  print(f"depth={stats.depth} pending={stats.pending}")
  ```

  ```typescript TypeScript theme={null}
  import Anthropic from "@anthropic-ai/sdk";

  const client = new Anthropic();

  const stats = await client.beta.environments.work.stats(process.env.ANTHROPIC_ENVIRONMENT_ID!);

  console.log(`depth=${stats.depth} pending=${stats.pending}`);
  ```

  ```csharp C# theme={null}
  using Anthropic;

  var client = new AnthropicClient();

  var environmentId = Environment.GetEnvironmentVariable("ANTHROPIC_ENVIRONMENT_ID")!;

  var stats = await client.Beta.Environments.Work.Stats(environmentId);

  Console.WriteLine($"depth={stats.Depth} pending={stats.Pending}");
  ```

  ```go Go theme={null}
  package main

  import (
      "context"
      "fmt"
      "os"

      "github.com/anthropics/anthropic-sdk-go"
  )

  func main() {
      client := anthropic.NewClient()
      environmentID := os.Getenv("ANTHROPIC_ENVIRONMENT_ID")

      stats, err := client.Beta.Environments.Work.Stats(
          context.Background(),
          environmentID,
          anthropic.BetaEnvironmentWorkStatsParams{},
      )
      if err != nil {
          panic(err)
      }

      fmt.Printf("depth=%d pending=%d\n", stats.Depth, stats.Pending)
  }
  ```

  ```java Java theme={null}
  import com.anthropic.client.AnthropicClient;
  import com.anthropic.client.okhttp.AnthropicOkHttpClient;
  import com.anthropic.models.beta.environments.work.BetaSelfHostedWorkQueueStats;

  void main() {
      AnthropicClient client = AnthropicOkHttpClient.fromEnv();

      BetaSelfHostedWorkQueueStats stats = client.beta()
          .environments()
          .work()
          .stats(System.getenv("ANTHROPIC_ENVIRONMENT_ID"));

      IO.println("depth=" + stats.depth() + " pending=" + stats.pending());
  }
  ```

  ```php PHP theme={null}
  <?php

  use Anthropic\Client;

  $client = new Client();

  $stats = $client->beta->environments->work->stats(getenv('ANTHROPIC_ENVIRONMENT_ID'));

  printf("depth=%d pending=%d\n", $stats->depth, $stats->pending);
  ```

  ```ruby Ruby theme={null}
  require "anthropic"

  client = Anthropic::Client.new

  stats = client.beta.environments.work.stats(ENV.fetch("ANTHROPIC_ENVIRONMENT_ID"))

  puts "depth=#{stats.depth} pending=#{stats.pending}"
  ```
</CodeGroup>

```text wrap theme={null}
{
  "type": "work_queue_stats",
  "depth": 0,
  "pending": 0,
  "oldest_queued_at": null,
  "workers_polling": 0
}
```

### Stop a session gracefully

Use `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.

Because 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](/docs/en/api/environment-work/list-work-items).

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS "http://localhost:38080/v1/environments/$ANTHROPIC_ENVIRONMENT_ID/work/$ANTHROPIC_WORK_ID/stop" \
    -H "x-api-key: $OMA_API_KEY" \
    -H "anthropic-beta: managed-agents-2026-04-01" \
    -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d '{}'
  ```

  ```bash CLI theme={null}
  ant beta:environments:work stop \
    --environment-id "$ANTHROPIC_ENVIRONMENT_ID" \
    --work-id "$ANTHROPIC_WORK_ID"
  ```

  ```python Python theme={null}
  import os

  import anthropic

  client = anthropic.Anthropic()

  work = client.beta.environments.work.stop(
      os.environ["ANTHROPIC_WORK_ID"],
      environment_id=os.environ["ANTHROPIC_ENVIRONMENT_ID"],
  )
  print(work.state)
  ```

  ```typescript TypeScript theme={null}
  import Anthropic from "@anthropic-ai/sdk";

  const client = new Anthropic();

  const work = await client.beta.environments.work.stop(process.env.ANTHROPIC_WORK_ID!, {
    environment_id: process.env.ANTHROPIC_ENVIRONMENT_ID!
  });

  console.log(work.state);
  ```

  ```csharp C# theme={null}
  using Anthropic;

  var client = new AnthropicClient();

  var work = await client.Beta.Environments.Work.Stop(
      Environment.GetEnvironmentVariable("ANTHROPIC_WORK_ID")!,
      new()
      {
          EnvironmentID = Environment.GetEnvironmentVariable("ANTHROPIC_ENVIRONMENT_ID")!
      }
  );

  Console.WriteLine(work.State);
  ```

  ```go Go theme={null}
  package main

  import (
      "context"
      "fmt"
      "os"

      "github.com/anthropics/anthropic-sdk-go"
  )

  func main() {
      client := anthropic.NewClient()

      work, err := client.Beta.Environments.Work.Stop(
          context.Background(),
          os.Getenv("ANTHROPIC_WORK_ID"),
          anthropic.BetaEnvironmentWorkStopParams{
              EnvironmentID: os.Getenv("ANTHROPIC_ENVIRONMENT_ID"),
          },
      )
      if err != nil {
          panic(err)
      }
      fmt.Println(work.State)
  }
  ```

  ```java Java theme={null}
  import com.anthropic.client.AnthropicClient;
  import com.anthropic.client.okhttp.AnthropicOkHttpClient;
  import com.anthropic.models.beta.environments.work.BetaSelfHostedWork;
  import com.anthropic.models.beta.environments.work.BetaSelfHostedWorkStopRequest;
  import com.anthropic.models.beta.environments.work.WorkStopParams;

  void main() {
      AnthropicClient client = AnthropicOkHttpClient.fromEnv();

      BetaSelfHostedWork work = client.beta().environments().work().stop(
          WorkStopParams.builder()
              .environmentId(System.getenv("ANTHROPIC_ENVIRONMENT_ID"))
              .workId(System.getenv("ANTHROPIC_WORK_ID"))
              .betaSelfHostedWorkStopRequest(BetaSelfHostedWorkStopRequest.builder().build())
              .build()
      );

      IO.println(work.state());
  }
  ```

  ```php PHP theme={null}
  <?php

  use Anthropic\Client;

  $client = new Client();

  $work = $client->beta->environments->work->stop(
      getenv('ANTHROPIC_WORK_ID'),
      environmentID: getenv('ANTHROPIC_ENVIRONMENT_ID'),
  );

  echo $work->state . "\n";
  ```

  ```ruby Ruby theme={null}
  require "anthropic"

  client = Anthropic::Client.new

  work = client.beta.environments.work.stop(
    ENV.fetch("ANTHROPIC_WORK_ID"),
    environment_id: ENV.fetch("ANTHROPIC_ENVIRONMENT_ID")
  )

  puts work.state
  ```
</CodeGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Security model" href="/docs/en/self-hosted-sandboxes-security">
    Shared responsibility model for self-hosted sandbox environments.
  </Card>

  <Card title="Start a session" href="/docs/en/sessions">
    Create a session to run your agent and begin executing tasks.
  </Card>

  <Card title="MCP tunnels" href="/docs/en/mcp-connector">
    Securely connect agents to MCP servers running in your private network without opening inbound ports or exposing services to the public internet.
  </Card>
</CardGroup>
