# Understanding the Async/Sync Bridging Pattern in Open Notebook's LangGraph Nodes

> Discover Open Notebook's async-sync bridging pattern in LangGraph nodes. Learn how it safely calls async services from sync nodes, preventing RuntimeErrors and ensuring smooth execution.

- Repository: [Luis Novo/open-notebook](https://github.com/lfnovo/open-notebook)
- Tags: deep-dive
- Published: 2026-07-01

---

**Open Notebook uses an async-to-sync bridging pattern in its LangGraph nodes to safely call asynchronous services like model provisioning from synchronous graph nodes, preventing `RuntimeError` when no event loop is running.**

Open Notebook orchestrates AI workflows using LangGraph, where some graph nodes must invoke asynchronous utilities from synchronous implementations. This architectural requirement creates a runtime conflict that the **async/sync bridging pattern** resolves by intelligently managing event loop states across different execution contexts.

## Why Open Notebook Needs Async/Sync Bridging

LangGraph workflows in Open Notebook are fundamentally async, but certain nodes like `call_model_with_messages` in the chat graph are implemented as synchronous functions. These nodes need to call async services such as `provision_langchain_model` from [`open_notebook/ai/provision.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/ai/provision.py).

Directly awaiting an async function inside a sync node raises `RuntimeError: no running event loop`. The bridging pattern detects whether the code runs inside an existing async context and adapts its execution strategy accordingly.

## How the Async/Sync Bridging Pattern Works

The implementation in [`open_notebook/graphs/chat.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/chat.py) follows a three-step detection and execution strategy.

### Detecting the Event Loop State

The code first attempts to retrieve the current event loop using `asyncio.get_running_loop()`. If this succeeds, the graph is running inside an async pipeline.

### Thread-Based Execution for Nested Loops

When an existing loop is detected, the pattern spawns a separate thread with its own event loop to avoid nested loop conflicts. It uses `concurrent.futures.ThreadPoolExecutor` to submit the async work:

```python
try:
    asyncio.get_running_loop()  # Check if we're inside an async graph

    with concurrent.futures.ThreadPoolExecutor() as executor:
        future = executor.submit(run_in_new_loop)
        model = future.result()

```

The `run_in_new_loop` helper creates a fresh event loop in the new thread, executes the async call, and returns the result:

```python
def run_in_new_loop():
    new_loop = asyncio.new_event_loop()
    asyncio.set_event_loop(new_loop)
    try:
        return new_loop.run_until_complete(
            provision_langchain_model(str(payload), model_id, "chat", max_tokens=8192)
        )
    finally:
        new_loop.close()
        asyncio.set_event_loop(None)

```

### Direct Execution for Sync Contexts

If `asyncio.get_running_loop()` raises `RuntimeError`, no loop exists (typical in CLI tools or tests). The pattern falls back to `asyncio.run()`:

```python
except RuntimeError:  # No loop - e.g., CLI call

    model = asyncio.run(
        provision_langchain_model(str(payload), model_id, "chat", max_tokens=8192)
    )

```

## Implementation in the Chat Graph

The complete implementation appears in [`open_notebook/graphs/chat.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/chat.py) within the `call_model_with_messages` function. This synchronous node prepares a message payload, bridges to the async `provision_langchain_model` utility, and then invokes the model:

```python
def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:
    # …prepare payload…

    # -------- Async-to-Sync bridge ----------

    def run_in_new_loop():
        new_loop = asyncio.new_event_loop()
        asyncio.set_event_loop(new_loop)
        try:
            return new_loop.run_until_complete(
                provision_langchain_model(str(payload), model_id, "chat", max_tokens=8192)
            )
        finally:
            new_loop.close()
            asyncio.set_event_loop(None)

    try:
        asyncio.get_running_loop()  # We are inside an async graph

        with concurrent.futures.ThreadPoolExecutor() as exe:
            model = exe.submit(run_in_new_loop).result()
    except RuntimeError:  # No loop – e.g., CLI call

        model = asyncio.run(
            provision_langchain_model(str(payload), model_id, "chat", max_tokens=8192)
        )
    # -----------------------------------------

    ai_message = model.invoke(payload)
    # …process response…

```

This ensures the node works whether invoked from LangGraph's async pipeline or a synchronous script.

## Contrast with Pure Async Nodes

Not all nodes require this bridging. The source graph in [`open_notebook/graphs/source.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/source.py) implements nodes as pure async functions, eliminating the need for loop detection:

```python

# open_notebook/graphs/source.py

async def content_process(state: SourceState) -> dict:
    # async calls to content_core and ModelManager

    processed_state = await extract_content(content_state)
    ...

```

This contrast illustrates Open Notebook's architectural flexibility: fully async workflows use native `await`, while mixed sync/async contexts employ the bridging pattern to maintain compatibility.

## Summary

- The **async/sync bridging pattern** prevents `RuntimeError` when synchronous LangGraph nodes call async services like `provision_langchain_model`.
- It detects the current event loop state using `asyncio.get_running_loop()` to determine the execution strategy.
- For nested async contexts, it spawns a `ThreadPoolExecutor` with an isolated event loop via `run_in_new_loop`.
- For pure sync contexts, it falls back to `asyncio.run()` for straightforward execution.
- This pattern is implemented in [`open_notebook/graphs/chat.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/chat.py) but unnecessary in fully async nodes like those in [`open_notebook/graphs/source.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/source.py).

## Frequently Asked Questions

### What causes the "no running event loop" error in LangGraph?

This error occurs when synchronous Python code attempts to await a coroutine or call `asyncio.get_running_loop()` while no event loop is active. In Open Notebook's chat graph, the `call_model_with_messages` node runs as a sync function but needs to invoke the async `provision_langchain_model` utility, triggering this error without the bridging pattern.

### Why not make all nodes async instead of using a bridge?

While Open Notebook's source graph ([`source.py`](https://github.com/lfnovo/open-notebook/blob/main/source.py)) uses pure async nodes, the chat graph requires synchronous nodes for specific LangGraph integration patterns or legacy compatibility. The bridging pattern allows selective async service usage without refactoring entire graph segments to async/await syntax.

### Is the ThreadPoolExecutor approach thread-safe?

Yes. The pattern creates a new event loop within a dedicated thread for each async invocation, preventing conflicts with the parent loop. The `run_in_new_loop` helper properly cleans up by closing the loop and resetting the thread-local event loop in its `finally` block, ensuring deterministic resource management.

### Where else in Open Notebook is this pattern used?

Currently, the async/sync bridging pattern is primarily implemented in [`open_notebook/graphs/chat.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/chat.py) for the `call_model_with_messages` node. Other nodes like those in [`open_notebook/graphs/source.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/source.py) are fully async and do not require this bridge, as they can directly await async services.