Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
aed70da
feat(acp-python): add e2b-acp package with ACP framing helpers
max-sudolabs Oct 15, 2026
48b0157
feat(acp-python): add connect_acp_agent
max-sudolabs Oct 15, 2026
7704b93
test(acp-python): cover connect_acp_agent against a scripted agent
max-sudolabs Oct 15, 2026
358e386
ci: test and release the e2b-acp package
max-sudolabs Oct 23, 2026
0530696
chore: add changeset for e2b-acp
max-sudolabs Oct 23, 2026
6ba181b
chore: add acp-python to the pnpm lockfile
max-sudolabs Oct 16, 2026
8b2c413
fix(acp-python): raise the e2b floor to the released core version
max-sudolabs Oct 16, 2026
dd7ba13
fix(acp-python): raise from close() when the agent could not be killed
max-sudolabs Oct 16, 2026
564d44f
docs(acp-python): describe how requests fail when the agent exits
max-sudolabs Oct 16, 2026
660e76a
fix(acp-python): drop the unused xdist flag from make test
max-sudolabs Oct 16, 2026
52ecfa8
test(acp-python): drop live tests the offline tests already cover
max-sudolabs Oct 16, 2026
785d3c7
fix(acp-python): let kill() stop output that waits for the client
max-sudolabs Oct 19, 2026
2e49ccd
fix(acp-python): split agent output without rescanning a pending line
max-sudolabs Oct 19, 2026
114176d
refactor(acp-python): make options keyword-only, rename close() to st…
max-sudolabs Oct 19, 2026
4517842
chore(acp-python): refresh uv.lock for the current Python SDK
max-sudolabs Oct 23, 2026
3135ded
fix(acp-python): kill the agent when its output stream fails
max-sudolabs Oct 23, 2026
e3050c9
refactor(acp-python): rename AcpAgentExit to AcpAgentResult, point th…
max-sudolabs Oct 23, 2026
e0073d2
refactor(acp-python): start the agent with the public retain_output o…
mishushakov Oct 28, 2026
c5734e6
chore(acp-python): refresh uv.lock for the current Python SDK
mishushakov Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/acp-python-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@e2b/acp-python": minor
---

Add `e2b-acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `connect_acp_agent(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox.
53 changes: 53 additions & 0 deletions .github/workflows/acp_python_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Test ACP Python SDK

on:
workflow_call:
inputs:
E2B_DOMAIN:
required: false
type: string
default: ''
secrets:
E2B_API_KEY:
required: true

permissions:
contents: read

jobs:
test:
defaults:
run:
working-directory: ./packages/acp-python
shell: bash
name: ACP Python SDK - Build and test
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Parse .tool-versions
uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1
with:
filename: '.tool-versions'
uppercase: 'true'
prefix: 'tool_version_'

- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with:
version: '${{ env.TOOL_VERSION_UV }}'
python-version: '${{ env.TOOL_VERSION_PYTHON }}'
enable-cache: true

- name: Install dependencies
run: uv sync --locked --package e2b-acp

- name: Test build
run: uv build

- name: Run tests
run: uv run pytest --verbose
env:
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }}
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
desktop_python: ${{ steps.desktop_python.outputs.release }}
dockerfile_utils_js: ${{ steps.dockerfile_utils_js.outputs.release }}
dockerfile_utils_python: ${{ steps.dockerfile_utils_python.outputs.release }}
acp_python: ${{ steps.acp_python.outputs.release }}
itinerary: ${{ steps.itinerary.outputs.itinerary }}
steps:
- name: Check the ref
Expand Down Expand Up @@ -148,6 +149,13 @@ jobs:
IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/dockerfile-utils-python" "$RUNNER_TEMP/release-plan.json")
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"

- name: Check ACP Python SDK Release
id: acp_python
if: steps.version.outputs.release == 'true'
run: |
IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/acp-python")
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"

- name: Build release itinerary
id: itinerary
if: steps.version.outputs.release == 'true'
Expand Down Expand Up @@ -223,6 +231,13 @@ jobs:
if: needs.preflight.outputs.dockerfile_utils_python == 'true'
uses: ./.github/workflows/dockerfile_utils_python_tests.yml

acp-python-tests:
name: ACP Python SDK Tests
needs: [preflight]
if: needs.preflight.outputs.acp_python == 'true'
uses: ./.github/workflows/acp_python_tests.yml
secrets: inherit

publish:
name: Publish
needs:
Expand All @@ -236,6 +251,7 @@ jobs:
- desktop-python-tests
- dockerfile-utils-js-tests
- dockerfile-utils-python-tests
- acp-python-tests
if: (!cancelled()) && !contains(needs.*.result, 'failure') && needs.preflight.outputs.release == 'true'
uses: ./.github/workflows/publish_packages.yml
with:
Expand Down Expand Up @@ -275,6 +291,7 @@ jobs:
- desktop-python-tests
- dockerfile-utils-js-tests
- dockerfile-utils-python-tests
- acp-python-tests
- publish
if: failure()
name: Release Failed - Slack Notification
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/sdk_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
desktop_python: ${{ steps.filter.outputs.desktop_python }}
dockerfile_utils_js: ${{ steps.filter.outputs.dockerfile_utils_js }}
dockerfile_utils_python: ${{ steps.filter.outputs.dockerfile_utils_python }}
acp_python: ${{ steps.filter.outputs.acp_python }}
steps:
- name: Filter changed paths
# On workflow_dispatch there is no PR diff and paths-filter would fall
Expand Down Expand Up @@ -71,6 +72,12 @@ jobs:
- 'packages/cli/**/!(*.md)'
- 'packages/js-sdk/**/!(*.md)'
- 'packages/dockerfile-utils-js/**/!(*.md)'
acp_python:
- *shared
- '.github/workflows/acp_python_tests.yml'
- 'packages/acp-python/**/!(*.md)'
- 'packages/python-sdk/**/!(*.md)'
- 'packages/dockerfile-utils-python/**/!(*.md)'
code_interpreter_js:
- *shared
- '.github/workflows/code_interpreter_js_tests.yml'
Expand Down Expand Up @@ -136,6 +143,14 @@ jobs:
secrets:
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}

acp-python-tests:
name: Production / ACP Python SDK Tests
needs: changes
if: ${{ needs.changes.outputs.acp_python == 'true' || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/acp_python_tests.yml
secrets:
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}

code-interpreter-js-tests:
name: Production / Code Interpreter JS SDK Tests
needs: changes
Expand Down Expand Up @@ -269,6 +284,7 @@ jobs:
- js-tests
- python-tests
- cli-tests
- acp-python-tests
- code-interpreter-js-tests
- code-interpreter-python-tests
- desktop-js-tests
Expand Down
9 changes: 9 additions & 0 deletions packages/acp-python/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
MIT License

Copyright (c) 2025 FOUNDRYLABS, INC.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
13 changes: 13 additions & 0 deletions packages/acp-python/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
typecheck:
ty check e2b_acp

lint:
ruff check .
ruff format --check .

format:
ruff format .

.PHONY: test
test:
uv run pytest --verbose
77 changes: 77 additions & 0 deletions packages/acp-python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# E2B ACP

Run [Agent Client Protocol](https://agentclientprotocol.com) (ACP) agents such as goose, OpenCode or Claude Code inside an [E2B](https://e2b.dev) sandbox, and drive them from your code with the official ACP client.

Source: [packages/acp-python](https://github.com/e2b-dev/E2B/tree/main/packages/acp-python)

## Install

```
pip install e2b e2b-acp agent-client-protocol
```

Set `E2B_API_KEY` to your [E2B API key](https://e2b.dev/dashboard?tab=keys), and the provider key the agent needs.

## Usage

Create a sandbox from a template with the agent installed, then start the agent and connect your ACP client to it. The ACP Python SDK is asynchronous, so use `AsyncSandbox`.

```python
import asyncio
import os

import acp
from acp import schema
from e2b import AsyncSandbox
from e2b_acp import connect_acp_agent


class Client:
async def request_permission(self, session_id, tool_call, options, **kwargs):
allow = next((o for o in options if o.kind == "allow_once"), options[0])
return schema.RequestPermissionResponse(
outcome=schema.AllowedOutcome(outcome="selected", option_id=allow.option_id)
)

async def session_update(self, session_id, update, **kwargs):
if isinstance(update, schema.AgentMessageChunk) and update.content.type == "text":
print(update.content.text, end="")


async def main():
sandbox = await AsyncSandbox.create("goose", timeout=600)
try:
agent = await connect_acp_agent(
sandbox,
"goose acp --with-builtin developer",
Client(),
envs={
"GOOSE_PROVIDER": "openai",
"GOOSE_MODEL": "gpt-5.6-luna",
"OPENAI_API_KEY": os.environ["OPENAI_API_KEY"],
},
)
await agent.connection.initialize(protocol_version=acp.PROTOCOL_VERSION)
session = await agent.connection.new_session(cwd="/home/user", mcp_servers=[])
await agent.connection.prompt(
session_id=session.session_id,
prompt=[acp.text_block("Create fizzbuzz.py for 1 to 15 and run it.")],
)
await agent.stop()
finally:
await sandbox.kill()


asyncio.run(main())
```

## Where the agent's tools run

The agent talks ACP over its stdin and stdout. `connect_acp_agent` turns off the client filesystem and terminal capabilities in `initialize` and answers agent requests for `fs/*` or `terminal/*` with an error, so the agent edits files and runs commands with its own tools inside the sandbox. It does not restrict the agent's network access or the MCP servers you pass to it.

## Lifecycle

- `agent.stop()` sends EOF to the agent and kills it if it has not exited within `timeout` (5 seconds by default) of the call. Messages the agent sends after that are not delivered.
- `agent.wait()` returns the exit code once the agent exits.
- If the agent exits, pending requests on the connection fail with a connection error. Use `agent.wait()` for the exit code and pass `on_stderr` to receive the agent's logs.
- The agent runs as long as the sandbox does, so set the sandbox timeout for the longest task you expect.
130 changes: 130 additions & 0 deletions packages/acp-python/e2b_acp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""
Run [Agent Client Protocol](https://agentclientprotocol.com) agents in E2B sandboxes.
"""

from typing import Dict, Optional

import acp
from e2b import AsyncSandbox
from e2b.connection_config import Username

from .transport import DEFAULT_STOP_TIMEOUT, AcpAgentResult, AcpTransport, StderrHandler
from .utils import DEFAULT_MAX_MESSAGE_BYTES


class AcpAgent:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-26 — anything open-ended (a running command, a PTY, a watch) returns a dedicated *Handle class whose constructor is internal. AcpAgent is exactly that shape (pid, wait(), stop(), kill() over a running process, obtained only from connect_acp_agent), so name it like CommandHandle / WatchHandle and keep __init__ out of the documented surface. Rename the references in __all__, the return annotation and the docstrings too.

Suggested change
class AcpAgent:
class AcpAgentHandle:

@max-sudolabs max-sudolabs Oct 23, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving the name for now, together with the related T-4 comment on #2025 (AcpAgent.start instead of connectAcpAgent). AcpAgent also carries the ACP connection, not only the process.

@mishushakov, if you want AcpAgentHandle and a start factory, I'll rename both packages and the templates, cookbook and docs at once.

"""
ACP agent running in a sandbox, connected to your client.
"""

def __init__(self, transport: AcpTransport, connection: acp.ClientSideConnection):
self._transport = transport
self._connection = connection

@property
def connection(self) -> acp.ClientSideConnection:
"""
Connection to the agent. Use it to initialize, create sessions and send prompts.
"""
return self._connection

@property
def pid(self) -> int:
"""
Process ID of the agent in the sandbox.
"""
return self._transport.pid

async def wait(self) -> AcpAgentResult:
"""
Wait for the agent process to exit.

It raises if the agent's output stream failed before its exit status was known.

:return: Exit status of the agent
"""
return await self._transport.wait()

async def stop(self, *, timeout: float = DEFAULT_STOP_TIMEOUT) -> None:
"""
Stop the agent: send EOF to its stdin and kill it if it has not
exited within `timeout` of this call. Messages the agent sends after
this call are not delivered.

:param timeout: Time for the agent to exit in **seconds**; defaults to ``DEFAULT_STOP_TIMEOUT`` (5 seconds)

:raises SandboxException: If the agent could not be killed
"""
await self._transport.close(timeout)

async def kill(self) -> bool:
"""
Kill the agent process with `SIGKILL`.

:return: `True` if the agent was killed, `False` if it was not found
"""
return await self._transport.kill()


async def connect_acp_agent(
sandbox: AsyncSandbox,
command: str,
client: acp.Client,
*,
cwd: Optional[str] = None,
envs: Optional[Dict[str, str]] = None,
user: Optional[Username] = None,
on_stderr: Optional[StderrHandler] = None,
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
request_timeout: Optional[float] = None,
) -> AcpAgent:
"""
Start an ACP agent in the sandbox and connect your ACP client to it over
the agent's stdin/stdout.

The client filesystem and terminal capabilities are turned off in
`initialize`, and agent requests for `fs/*` or `terminal/*` are answered
with an error without reaching your client, so the agent edits files and
runs commands with its own tools in the sandbox. Network access and MCP
servers you pass to the agent are not restricted.

```python
import acp
from e2b import AsyncSandbox
from e2b_acp import connect_acp_agent

sandbox = await AsyncSandbox.create("goose")
agent = await connect_acp_agent(sandbox, "goose acp", client)
await agent.connection.initialize(protocol_version=acp.PROTOCOL_VERSION)
# ...
await agent.stop()
```

:param sandbox: Sandbox with the agent installed
:param command: Command that starts the agent in ACP mode, for example `goose acp`
:param client: Your ACP client. It answers permission requests and receives the agent's session updates
:param cwd: Working directory of the agent
:param envs: Environment variables for the agent
:param user: User to run the agent as
:param on_stderr: Callback for the agent's stderr output (logs). The agent's stdout is reserved for the protocol
:param max_message_bytes: Maximum size of a single message from the agent in **bytes**; defaults to ``DEFAULT_MAX_MESSAGE_BYTES`` (32 MiB)
:param request_timeout: Timeout for starting the agent in **seconds**

:return: `AcpAgent` with the ACP connection

:raises InvalidArgumentException: If `max_message_bytes` is not a positive integer
:raises SandboxException: If the agent could not be started
"""
transport = AcpTransport(sandbox, max_message_bytes, on_stderr)
await transport.start(command, envs, user, cwd, request_timeout)
return AcpAgent(transport, acp.connect_to_agent(client, transport))


__all__ = [
"DEFAULT_MAX_MESSAGE_BYTES",
"DEFAULT_STOP_TIMEOUT",
"AcpAgent",
"AcpAgentResult",
"StderrHandler",
"connect_acp_agent",
]
Loading