How to Convert an Async OpenEnv EnvClient to Synchronous Usage with the `.sync()` Wrapper

OpenEnv provides a thin synchronous façade via the .sync() method that wraps the async EnvClient in a dedicated background event loop, allowing you to use reset(), step(), and other methods synchronously without blocking your main thread.

The huggingface/OpenEnv library defines its core environment client (EnvClient) as an asynchronous abstraction to maintain persistent WebSocket connections without blocking the caller. For scripts, REPLs, or libraries that require classic synchronous flow, OpenEnv offers a seamless conversion pathway through the .sync() wrapper method.

Understanding the Async-to-Sync Architecture

OpenEnv’s synchronous support relies on two primary components working together to bridge the async/sync divide while keeping the underlying WebSocket and Docker provisioning logic unchanged.

The sync() Factory Method

In src/openenv/core/env_client.py at line 85, the abstract EnvClient class defines the sync() method. This factory imports and returns an instance of SyncEnvClient, passing the original async client (self) to the wrapper. This design allows any concrete client inheriting from EnvClient to expose synchronous capabilities instantly.

The SyncEnvClient Wrapper Class

Implemented in src/openenv/core/sync_client.py, the SyncEnvClient class creates a dedicated background asyncio event loop running in its own daemon thread. When you call synchronous methods like reset() or step(), the wrapper schedules the underlying coroutine via asyncio.run_coroutine_threadsafe and blocks until the result is available.

The class implements __getattr__ to automatically wrap any coroutine-based method on the async client, caching the sync wrapper for subsequent calls. This ensures that new methods added to the async client become immediately available in the synchronous interface without additional boilerplate.

Converting Your Async Client to Synchronous

To convert async OpenEnv EnvClient to synchronous usage, instantiate your client as usual and chain the .sync() method. The resulting object behaves like a standard blocking Python API.

Basic Synchronous Usage

The most common pattern uses the context manager protocol to ensure proper cleanup:

from echo_env import CallToolAction, EchoEnv

# Obtain a synchronous wrapper from an async client

sync_client = EchoEnv(base_url="https://openenv-echo-env.hf.space").sync()

# Use it as a normal context manager

with sync_client:
    # Reset the environment (synchronous)

    result = sync_client.reset()
    print(result.observation.echoed_message)  # → "Echo environment ready!"

    # Step the environment

    result = sync_client.step(
        CallToolAction(
            tool_name="echo_message",
            arguments={"message": "Hello, World!"},
        )
    )
    print(result.observation.result)  # → "Hello, World!"

Explicit Connection Management

For scenarios requiring manual control over the WebSocket lifecycle, call connect() and disconnect() explicitly before closing the wrapper:

from openenv.core import GenericEnvClient

# Create an async client and wrap it

client = GenericEnvClient(base_url="ws://localhost:8000").sync()

# Manually manage the connection

client.connect()          # Establishes the WebSocket in the background loop

obs = client.reset()
obs = client.step({"code": "print('hello')"})
client.disconnect()       # Closes the WebSocket

client.close()            # Stops the background event loop

Accessing Async-Only Methods

Even after wrapping, you retain access to the underlying async client for mixed-mode workflows:

async_client = EchoEnv(base_url="...")          # async client

sync_client = async_client.sync()               # sync wrapper

# Directly call an async method (still async)

await async_client.some_async_helper()

Resource Management and Cleanup

The SyncEnvClient implements the context-manager protocol (__enter__/__exit__) and exposes a close() method that properly terminates resources. When invoked, these methods call the async client’s close() coroutine and stop the background event loop cleanly, ensuring no stray daemon threads remain. Always use the context manager pattern or explicitly call close() when finished to prevent thread leaks.

Summary

  • File location: The .sync() method is defined in src/openenv/core/env_client.py (line 85), returning a SyncEnvClient from src/openenv/core/sync_client.py.
  • Mechanism: The wrapper runs a dedicated asyncio event loop in a background daemon thread, using asyncio.run_coroutine_threadsafe to bridge sync calls to async methods.
  • Automatic wrapping: __getattr__ automatically makes any async method available as a synchronous equivalent without manual registration.
  • Cleanup: Use context managers (with statement) or call close() to stop the background loop and prevent resource leaks.

Frequently Asked Questions

How do I convert an async OpenEnv EnvClient to synchronous usage without rewriting my codebase?

Call the .sync() method on any EnvClient instance to receive a SyncEnvClient wrapper that exposes the same API as synchronous methods. This wrapper handles all async/await complexity internally by running a background event loop in a separate thread, allowing you to use standard blocking calls like reset() and step() immediately.

Does the synchronous wrapper support all methods available on the async client?

Yes. The SyncEnvClient class in src/openenv/core/sync_client.py implements __getattr__ to dynamically wrap any coroutine-based method it encounters. This means new methods added to EnvClient subclasses automatically become available in the synchronous interface without requiring updates to the wrapper code.

What happens if I don't close the synchronous client properly?

Failing to close the SyncEnvClient leaves a daemon thread running with an active asyncio event loop, which can prevent your program from exiting cleanly or cause resource leaks. Always use the context manager protocol (with sync_client:) or explicitly call sync_client.close() to shut down the background loop and release the WebSocket connection.

Can I mix synchronous and asynchronous calls in the same workflow?

Yes. You can maintain references to both the original async client and the wrapped synchronous client simultaneously. After calling sync_client = async_client.sync(), you can still await async_client methods directly while using sync_client for blocking operations, enabling hybrid async/sync architectures within the same process.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →