OpenAI Agents SDK

The OpenAI Agents SDK runs the agent loop over the tools and capabilities you give it. The same two shapes as the Claude Agent SDK apply: run that loop inside a Hiver sandbox, the preferred shape, or keep it on your host and hand the SDK a sandbox client that routes execution into Hiver.

Run the loop inside the sandbox

This is the preferred shape. Ship the agent as the sandbox's own service: run the agent behind an HTTP server that runs inside the sandbox. The server's bash tool, and any file reads or writes the agent makes, execute locally, which is the sandbox, so the whole loop lives in one place with no sandbox client to write or maintain.

i
The server's bash tool runs arbitrary commands, but exposing it is safe because Hiver, not the agent, enforces the boundary. Each sandbox runs in its own microVM or container with its own network namespace, isolated from the host and every other sandbox at the kernel level, and it only ever sees its own filesystem. Set an egress policy and that allow-list is enforced outside the guest, so even a prompt-injected agent reaches only the hosts you permit, everything else is denied.
agent/index.ts
import express from "express";
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { Agent, run, tool } from "@openai/agents";
import { z } from "zod";

const sh = promisify(execFile);

// The SDK ships no local file tools, so define them as function tools. The
// agent runs inside the sandbox, so they resolve against its filesystem.
const bash = tool({
  name: "bash",
  description: "Run a shell command in /workspace and return its output.",
  parameters: z.object({ command: z.string() }),
  execute: async ({ command }) => {
    const { stdout, stderr } = await sh("bash", ["-lc", command], {
      cwd: "/workspace",
    });
    return stdout || stderr;
  },
});

const readFileTool = tool({
  name: "read_file",
  description: "Read a file from /workspace.",
  parameters: z.object({ path: z.string() }),
  execute: ({ path }) => readFile(`/workspace/${path}`, "utf8"),
});

const writeFileTool = tool({
  name: "write_file",
  description: "Write content to a file in /workspace.",
  parameters: z.object({ path: z.string(), content: z.string() }),
  execute: async ({ path, content }) => {
    await writeFile(`/workspace/${path}`, content);
    return `wrote ${path}`;
  },
});

const agent = new Agent({
  name: "coder",
  model: "gpt-5",
  instructions: "You are a coding agent working in /workspace.",
  tools: [bash, readFileTool, writeFileTool],
});

const app = express();
app.use(express.json());

app.post("/chat", async (req, res) => {
  const result = await run(agent, req.body.prompt);
  res.json({ reply: result.finalOutput });
});

app.listen(3000, () => console.log("listening on :3000"));
agent/main.py
import subprocess
from pathlib import Path
from flask import Flask, request, jsonify
from agents import Agent, Runner, function_tool


# The SDK ships no local file tools, so define them as function tools. The
# agent runs inside the sandbox, so they resolve against its filesystem.
@function_tool
def bash(command: str) -> str:
    """Run a shell command in /workspace and return its output."""
    result = subprocess.run(
        ["bash", "-lc", command],
        cwd="/workspace",
        capture_output=True,
        text=True,
    )
    return result.stdout or result.stderr


@function_tool
def read_file(path: str) -> str:
    """Read a file from /workspace."""
    return (Path("/workspace") / path).read_text()


@function_tool
def write_file(path: str, content: str) -> str:
    """Write content to a file in /workspace."""
    (Path("/workspace") / path).write_text(content)
    return f"wrote {path}"


agent = Agent(
    name="coder",
    model="gpt-5",
    instructions="You are a coding agent working in /workspace.",
    tools=[bash, read_file, write_file],
)

app = Flask(__name__)


@app.post("/chat")
async def chat():
    result = await Runner.run(agent, request.json["prompt"])
    return jsonify(reply=result.final_output)


app.run(host="0.0.0.0", port=3000)

Package the server as an image Hiver can run. Alongside the server file, declare its dependencies and a Dockerfile that installs them, EXPOSEs port 3000, and starts the server:

agent/package.json
{
  "name": "openai-agents-sdk-example",
  "scripts": {
    "start": "tsx index.ts"
  },
  "dependencies": {
    "@openai/agents": "latest",
    "express": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "tsx": "latest"
  }
}
agent/Dockerfile
FROM node:22-slim

ENV IS_SANDBOX=1
WORKDIR /app

COPY package.json .
RUN npm install
COPY index.ts .

EXPOSE 3000
CMD ["npx", "tsx", "index.ts"]

For Python, install openai-agents instead and swap the base image:

agent/requirements.txt
openai-agents
flask[async]
agent/Dockerfile
FROM python:3.13-slim

ENV IS_SANDBOX=1
WORKDIR /app

COPY requirements.txt .
RUN pip install -r requirements.txt
COPY main.py .

EXPOSE 3000
CMD ["python", "main.py"]

Bundle the agent/ directory into a tagged image with the CLI (see OCI Image for more):

hiver bundle ./agent --tag my-agent

Now start a sandbox from that image and send it a prompt. getOrCreateSandbox boots the sandbox, proxyUrl(3000) builds a URL that routes to the server inside it:

index.ts
import { getOrCreateSandbox } from "@hiver.sh/client";

const sandbox = await getOrCreateSandbox("my-agent", {
  image: "my-agent",
  env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY! },
});

const res = await fetch(`${sandbox.proxyUrl(3000)}chat`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ prompt: "Create /workspace/fib.py and run it." }),
});
console.log((await res.json()).reply);

Run the loop outside the sandbox

Keep the loop, and your OPENAI_API_KEY, on your host. The SDK has a first-class sandbox abstraction: you attach capabilities like shell() and filesystem() to a SandboxAgent, and the runtime binds them to whatever sandbox client you pass in the run config, so the provider is part of the run configuration, not the agent definition. Implement that SandboxClient interface once, backing its session methods with sandbox.exec, sandbox.readFile, and sandbox.listDirectory, the same shape as the SDK's built-in DockerSandboxClient, so the model's shell() and filesystem() tools, reads included, run in the sandbox and never on your machine:

hiver-sandbox.ts
import {
  Manifest,
  type SandboxClient,
  type SandboxSession,
  type ExecCommandArgs,
  type SandboxExecResult,
  type ReadFileArgs,
  type ListDirectoryArgs,
  type SandboxDirectoryEntry,
  type ExposedPortEndpoint,
} from "@openai/agents/sandbox";
import { getOrCreateSandbox, type Sandbox } from "@hiver.sh/client";

// Adapt a Hiver sandbox to the Agents SDK session interface. The shell()
// and filesystem() capabilities drive these methods at run time.
class HiverSession implements SandboxSession {
  state = { manifest: new Manifest() };
  constructor(private sandbox: Sandbox) {}

  async exec({ cmd, workdir }: ExecCommandArgs): Promise<SandboxExecResult> {
    const startedAt = Date.now();
    const { stdout, stderr, exit_code } = await this.sandbox.exec(
      ["bash", "-lc", cmd],
      { cwd: workdir ?? "/workspace" },
    );
    return {
      output: stdout || stderr,
      stdout,
      stderr,
      exitCode: exit_code,
      wallTimeSeconds: (Date.now() - startedAt) / 1000,
    };
  }

  readFile({ path }: ReadFileArgs): Promise<Uint8Array> {
    return this.sandbox.readFile(path); // raw bytes, straight from the mount
  }

  async listDir({ path }: ListDirectoryArgs): Promise<SandboxDirectoryEntry[]> {
    const entries = await this.sandbox.listDirectory(path);
    return entries.map((e) => ({ name: e.name, path: e.path, type: e.is_dir ? "dir" : "file" }));
  }

  async resolveExposedPort(port: number): Promise<ExposedPortEndpoint> {
    const url = this.sandbox.proxyUrl(port);
    return { host: new URL(url).host, port, tls: url.startsWith("https"), url };
  }

  async close() {} // the keyed sandbox outlives the run; nothing to tear down
}

export class HiverSandboxClient implements SandboxClient {
  readonly backendId = "hiver";
  constructor(
    private key: string,
    private options: { image?: string; env?: Record<string, string> } = {},
  ) {}

  async create(): Promise<SandboxSession> {
    return new HiverSession(await getOrCreateSandbox(this.key, this.options));
  }
}
i
Python's SandboxSession / BaseSandboxClient cover more ground than the TypeScript interface above (they also handle snapshots, PTY exec, and archive transfer), so the sketch below is a starting point, not a verified drop-in: check the sandbox client reference for the full method set before shipping it.
hiver_sandbox.py
import io
import time
from agents.sandbox import BaseSandboxClient, SandboxSession
from hiver import get_or_create_sandbox, Sandbox

# Adapt a Hiver sandbox to the Agents SDK session interface. Only the
# methods Capabilities.default() (filesystem + shell) actually calls are
# implemented here — fill in the rest from the reference above.
class HiverSession(SandboxSession):
    def __init__(self, sandbox: Sandbox):
        self.sandbox = sandbox

    async def exec(self, *command, timeout=None, shell=True, user=None):
        started = time.monotonic()
        result = await self.sandbox.exec(["bash", "-lc", " ".join(command)], cwd="/workspace")
        # Build `agents.sandbox.ExecResult` from `result` — check the
        # reference for its exact fields.
        return ExecResult(output=result["stdout"] or result["stderr"])

    async def read(self, path, *, user=None):
        return io.BytesIO(await self.sandbox.read_file(str(path)))  # raw bytes, straight from the mount

    async def write(self, path, data, *, user=None):
        await self.sandbox.write_file(str(path), data.read())

    async def ls(self, path, *, user=None):
        entries = await self.sandbox.list_directory(str(path))
        return [FileEntry(name=e["name"], path=e["path"]) for e in entries]

    async def resolve_exposed_port(self, port: int):
        url = self.sandbox.proxy_url(port)
        return ExposedPortEndpoint(url=url)

    async def aclose(self):
        pass  # the keyed sandbox outlives the run; nothing to tear down


class HiverSandboxClient(BaseSandboxClient):
    backend_id = "hiver"

    def __init__(self, key: str, **options):
        self.key = key
        self.options = options

    async def create(self, *, snapshot=None, manifest=None, options=None):
        return HiverSession(await get_or_create_sandbox(self.key, self.options))

Define the agent with the capabilities it needs, then pass the client at run time. The agent definition stays provider-agnostic:

index.ts
import { run } from "@openai/agents";
import { SandboxAgent, shell, filesystem } from "@openai/agents/sandbox";
import { HiverSandboxClient } from "./hiver-sandbox";

const agent = new SandboxAgent({
  name: "coder",
  model: "gpt-5",
  instructions: "Work in /workspace to answer the user.",
  capabilities: [shell(), filesystem()],
});

const result = await run(agent, "List the Python files under /workspace.", {
  sandbox: { client: new HiverSandboxClient("agent-tools", { image: "python" }) },
});
console.log(result.finalOutput);

Swapping providers, or dropping to DockerSandboxClient for a local run, is a one-line change to the run config, the agent stays the same.

Drive it from another language

The in-sandbox server is just an HTTP service, so you can start and call it from any Hiver client. Provision the my-agent sandbox and POST to proxyUrl(3000)/chat (proxy_url / ProxyURL).

main.py
import os, httpx
from hiver import get_or_create_sandbox, SandboxConfig

sandbox = await get_or_create_sandbox("my-agent", SandboxConfig(
    image="my-agent",
    env={"OPENAI_API_KEY": os.environ["OPENAI_API_KEY"]},
))

async with httpx.AsyncClient() as http:
    res = await http.post(
        f"{sandbox.proxy_url(3000)}chat",
        json={"prompt": "Create /workspace/fib.py and run it."},
    )
    print(res.json()["reply"])
i
Whichever shape you pick, your provider key stays where the loop runs. Run the loop outside and the key never enters the sandbox; run it inside and it lives only in the sandbox env (or inject it with an egress override). The sandbox reaches only the hosts your egress policy allows.

Next: Browser Use