Skip to main content
The async bridge lets your tools and callbacks move between sync and async without crashing the event loop.
The user calls a sync tool that needs async I/O; the bridge runs the coroutine safely whether or not an event loop is already running. An async guardrail on a plain Agent, reached from a FastAPI route, runs on a worker thread with the caller’s contextvars β€” the timeout is enforced inside that worker’s loop.
The user posts a prompt from a FastAPI route; the agent’s async guardrail runs on a worker thread with the caller’s contextvars, and the timeout is enforced inside that worker’s loop.

Quick Start

1

From a sync tool

Use run_coroutine_from_any_context to call async code from a sync tool:
The user runs sync code that needs async I/O; the bridge executes coroutines without nested event loops.
2

From an async tool

Use run_sync_in_executor to call blocking code from an async tool without blocking the event loop:
3

Detecting the context

Use is_async_context to create dual-mode helpers:

How It Works

The bridge probes for a running event loop. If no loop exists, it drives the coroutine directly on the caller’s thread via asyncio.run (wrapped in asyncio.wait_for when a timeout is set). If a loop is already running (FastAPI, Jupyter, a bot handler calling agent.chat() from inside async def), it offloads onto a dedicated worker thread with its own loop β€” never a deadlock, never a RuntimeError. The caller’s contextvars are copied into the worker thread with contextvars.copy_context(), so trace, session, and approval context set by the caller stays visible inside the coroutine.

Which helper do I call?

Pick between await, run_coroutine_from_any_context, and the wrapper-layer run_sync:

Async guardrails, callbacks, and approvals inside a running loop (PR #5377)

Prior to PR #5377, run_coroutine_from_any_context raised RuntimeError when reached from inside a running event loop (FastAPI async route, Jupyter cell, Discord/Slack bot handler, any async def calling agent.start(), task.execute_callback_sync(), or an approval callback). That surfaced as a spurious β€œAgent guardrail validation error” and burned LLM retries on a valid async guardrail. It also masked real summariser errors as an β€œevent loop already running” fallback to naive truncation. From that PR onwards:
  • Async guardrails on Agent(...) and Task(...) return their real result inside a running loop.
  • Async task callbacks reached via Task.execute_callback_sync run to completion instead of raising.
  • Async ApprovalCallback(approve_fn=<async fn>) works from FastAPI / Textual / Jupyter without the caller having to opt in.
  • ContextVars set by the caller (trace ids, session ids, approval context) are visible inside the coroutine.
  • The timeout is enforced. A coroutine that ignores cancellation (e.g. a blocking C call) can no longer pin the caller past timeout β€” the worker is abandoned (shutdown(wait=False)).
  • LLM_SUMMARIZE context compaction runs the real async summariser under a running loop instead of silently falling back to naive truncation, and provider errors from the summariser now surface instead of being masked.
The five previously-broken sync→async call sites (Agent._process_guardrail, Task._process_guardrail, Task.execute_callback_sync, planning/approval.py, bus/bus.py) all keep their existing code and now work correctly. The sync tool-calling helper run_async_in_sync_context is a thin delegate to run_coroutine_from_any_context(coro, timeout=None).

Configuration Options

run_coroutine_from_any_context (the praisonaiagents.utils.async_bridge helper) takes two arguments:
This is a literal 300 default baked into run_coroutine_from_any_context β€” it does not read PRAISONAI_RUN_SYNC_TIMEOUT. That env var applies to the wrapper-layer praisonai._async_bridge helpers documented below.

Timeout resolution: omitted vs None vs number

The timeout argument on the wrapper-layer helpers β€” run_sync, run_sync_or_offload, arun_sync_or_offload, and AsyncBridge.run_sync β€” uses a private _UNSET sentinel as its default, so the bridge can tell β€œargument omitted” apart from an explicit timeout=None. The sync-scheduler bridges (praisonai/integration/bridges/schedules_runner.py, praisonai/cli/commands/schedule.py) pass timeout=None so a long-running claimed job is never cancelled after 300 s.
_default_timeout() reads and parses PRAISONAI_RUN_SYNC_TIMEOUT per call, not at import. A malformed value (e.g. notanumber) falls back to 300.0 instead of crashing import praisonai, and a late-set value (dotenv loaded after import, per-request reconfig) takes effect on the next call.

Common Patterns

Reusing async SDKs from sync tools

Offloading blocking calls from async tools

Context-aware dual-mode helper


Best Practices

Calling run_coroutine_from_any_context inside async def now works β€” it offloads to a worker thread rather than raising. But await is still the right choice from an existing coroutine: it keeps the work on the caller’s loop and avoids the worker-thread and context-copy overhead the bridge takes on to stay safe.
Only wrap at the true sync/async boundary. Avoid creating unnecessary bridge calls in the middle of your call stack:
The default 300 seconds is large for most use cases. Tighten for latency-critical tools:
When building utilities that work in both sync and async contexts, check the context first:

Used by

The following synchronous APIs route through run_sync() and therefore honour PRAISONAI_RUN_SYNC_TIMEOUT consistently:
  • praisonai.bots.WebhookApproval.request_approval_sync()
  • praisonai.bots.HTTPApproval.request_approval_sync()
  • praisonai.integrations.get_available_integrations()
  • praisonai._run_praisonai (added PR #1681) β€” boots the InteractiveRuntime on the persistent background loop. If you call PraisonAI.run() from inside a running event loop, you now get a clear RuntimeError instead of a silent deadlock.
  • All ~77 wrapper-side run_sync call sites (gateway, a2u, mcp_server, scheduler) β€” see PR #1583 for the full list.
  • praisonai.auto.BaseAutoGenerator._structured_completion / _run_coro_sync runner threads (added in the fix for #3340) β€” inherit the caller’s scoped_bridge() via contextvars.copy_context().
  • praisonai_code.cli.features.agent_tools._run_sync (added via PR #3361) β€” a separate, module-local bridge in the Tier-2 praisonai-code package that ACP/LSP agent-centric tools route through. Does not import praisonai._async_bridge (C7 gate), but reads the same PRAISONAI_RUN_SYNC_TIMEOUT env var and enforces the same 300 s default. Safe under a running loop (offloads to a ThreadPoolExecutor), and returns promptly on timeout without waiting for the abandoned worker.
  • Ten CLI subcommand leaves route through the public run_cli_coro() helper (PR #5322) β€” background {list, status, cancel, clear}, managed {list-check, stop}, standardise, sandbox {execute, shell}, chat REPL /tasks {list, cancel, status}, and the direct_prompt external-agent pass-through β€” so per-loop connection pools survive across invocations instead of a fresh asyncio.run(...) per call.
These sync wrappers now raise RuntimeError("run_sync() cannot be called from a running event loop; await the coroutine directly instead.") when called from inside an active asyncio loop. Previously they would silently spawn a worker thread. If you call any of these from async code, switch to await request_approval(...) (or the equivalent async method) directly. This is a deliberate fail-fast change β€” the silent thread spawn was masking architectural bugs in multi-agent setups.PR #1692 β€” cancellation on timeout (May 2026). When a run_sync() call hits its timeout (default 300 s, or whatever PRAISONAI_RUN_SYNC_TIMEOUT is set to), the underlying coroutine is now actively cancelled on the background loop. The bridge waits up to 1 s for cancellation to propagate before re-raising TimeoutError. This means slow DB queries (SurrealDB, async MySQL), HTTP calls, and subprocess waits now release their connection / socket / pipe instead of leaking. Cancellation also fires on KeyboardInterrupt, SystemExit, and GeneratorExit.
The wrapper-layer bridge (praisonai._async_bridge) creates its background loop lazily on the first run_sync() call. Pure imports do not allocate a loop or thread. Calling the module-level shutdown() before any run_sync() is a safe no-op β€” it only affects the shared default bridge, not any AsyncBridge() instances you create yourself.The shared default’s atexit teardown hook is also registered lazily, on the first real use of the shared default bridge (inside AsyncBridge._spawn_locked(), guarded by self is globals().get("_BG")). A bare import praisonai no longer installs a process-wide atexit hook, so Django/Airflow/Streamlit embedders are unaffected until they actually call run_sync.

Troubleshooting

RuntimeError: run_coroutine_from_any_context() cannot be called from async context

This specific RuntimeError no longer exists. PR #5377 removed it β€” previously-broken async guardrails, callbacks, and approvals now succeed inside a running loop by offloading to a worker thread. If you still see this message, you are on an older release; upgrade to the version that ships PR #5377.

My async guardrail / callback / approval now works but I’d rather await it directly

You can. Inside async def code, await the coroutine yourself instead of routing through the bridge. The bridge is there for the sync-only surfaces (Agent.start, Task.execute_callback_sync, sync tool functions) that cannot await.

asyncio.run() cannot be called from a running event loop

This error used to leak from SDK internals before the async bridge was implemented. If you see this on current versions, upgrade to the latest release. Test reference: praisonai/tests/unit/test_async_bridge.py::TestBridgeIntegration::test_timeout_cancels_coroutine_and_runs_finally β€” quote this in the page so users can verify the behaviour locally.

TimeoutError: run_sync_or_offload() worker did not complete within s

This timeout branch is only reachable when the caller is inside a running loop and has opted into PRAISONAI_ALLOW_LOOP_BLOCKING=true (the offload path). A plain-sync caller still hits the same TimeoutError via run_sync. The offloaded coroutine did not finish inside timeout + 1s β€” check for blocking I/O or a missing await, or raise PRAISONAI_RUN_SYNC_TIMEOUT if the work is legitimately long-running. Prefer migrating to await arun_sync_or_offload(...) / await praisonai.arun(...) so the loop is never blocked.

PermissionError in approval system

The approval system now fails fast in async contexts. Configure a non-console backend:

Wrapper Bridge (praisonai._async_bridge)

The wrapper layer provides a module-level run_sync() for CLI scripts and single-tenant servers, plus a public AsyncBridge class when you need an isolated loop per tenant or service.

When to use a per-instance bridge

API Reference: Environment:
  • PRAISONAI_RUN_SYNC_TIMEOUT: Default timeout in seconds (300). Resolved per call (not at import) via _default_timeout(); a malformed value falls back to 300.0, and late-set env vars (dotenv loaded after import, per-request reconfig) are honoured. An explicit timeout=None opts into an unbounded wait (used by the sync-scheduler bridges for long-lived jobs). Read by both praisonai._async_bridge (this page) and praisonai_code.cli.features.agent_tools._run_sync (see Agent-Centric Tools β†’ Timeouts & Cancellation).
Do not call run_sync from inside async def β€” use await instead. The function raises RuntimeError if called from within a running event loop to prevent deadlocks.The module-level shutdown() only stops the shared default bridge. Per-instance AsyncBridge objects must be shut down via bridge.shutdown() on each instance.
Used by:
  • CLI approval protocol (ACP/LSP tools)
  • Interactive runtime start/stop operations
  • Deployment scheduler
  • Gateway operations
See also: Approval Protocol and Gateway.

run_cli_coro() β€” the CLI-leaf entry point

run_cli_coro() is the public helper a synchronous CLI leaf should call instead of a bare asyncio.run(coro). It routes the coroutine onto the shared background loop so per-loop connection pools survive across invocations, any embedder-installed scoped_bridge() binding is honoured, and no fresh loop is spawned on every call (PR #5322).
The timeout default is unbounded (None) β€” deliberately different from run_sync’s configurable 300 s default. A CLI leaf owns its runtime: an interactive sandbox shell, a multi-artifact standardise run, or a long background job must not be aborted mid-flight. A caller that wants a deadline passes an explicit timeout=.

Migration from asyncio.run()

Replace the bare asyncio.run(...) at a CLI leaf with run_cli_coro(...) β€” the coroutine now shares the background loop instead of tearing down a fresh one on every invocation.

Migrated CLI sites

Ten CLI subcommand leaves now dispatch through run_cli_coro() so repeated invocations reuse the background loop:
praisonai sandbox shell keeps its blocking input() on the main thread so Ctrl-C / SIGINT is still delivered correctly β€” only the async sandbox operations dispatch through run_cli_coro().
Two call sites intentionally keep asyncio.run(): acp/server.py (a long-running server entry point) and integrations/compute_managed_agent.py (an integration process that owns its own runtime). These are not CLI leaves, so they own a top-level loop for the life of the process.

dispatch_maybe_awaitable β€” one helper for sync-hook dispatch

dispatch_maybe_awaitable() is the single owner of the β€œa sync call may return a coroutine; run it correctly” policy. It picks one of four branches from the running-loop state and an explicit DispatchKind. DispatchKind makes read-vs-write intent explicit at the call site instead of depending on a name-string allow-list. The four branches: This is the shared helper the PraisonAIDB._call_store, _merge_and_set, and on_agent_end paths route through β€” the split policy is no longer hand-rolled per call site (PR #5038). See Async DB Hooks β†’ Shared dispatch for the db-adapter view.
dispatch_maybe_awaitable is a wrapper internal used by the db adapter β€” end users do not call it directly. The READ strict-inside-a-loop behaviour (PR #4821) and the completion-hook persistence guarantee (PR #4948) are pre-existing behaviours the helper now enforces in one place; PR #5038 is DRY consolidation with no user-visible behaviour change.
The contract tests in praisonai/tests/unit/test_async_bridge.py::TestDispatchMaybeAwaitable pin the four branches:
  • test_non_awaitable_passthrough β€” a plain value is returned unchanged.
  • test_no_loop_read_blocks_and_returns_value β€” no running loop + READ blocks and returns the value.
  • test_no_loop_write_blocks_and_returns_value β€” no running loop + WRITE runs to completion (fire-and-forget is a running-loop concern).
  • test_running_loop_write_is_tracked_fire_and_forget β€” running loop + WRITE submits, tracks, and returns None while the write still completes.
  • test_running_loop_failed_write_logs_and_does_not_raise β€” a failed deferred WRITE is swallowed (logged) via the done-callback, never surfacing to the sync caller.

run_sync_or_offload β€” strict inside a running loop

Use run_sync_or_offload() on a code path that can be reached from a plain script or from inside a running event loop (FastAPI, Jupyter, async tests). On a plain-sync caller it dispatches to run_sync. Inside a running loop it now raises RuntimeError by default (PR #4261) and steers you to the awaitable siblings β€” it never silently pins the loop.
1

Plain sync caller β€” works

2

Inside a FastAPI handler β€” await instead

A sync call inside a running loop now raises RuntimeError. Make the handler async and await the coroutine directly:
For an existing sync entry point you cannot convert to async def, await the awaitable sibling:

Configuration Options

Called from a plain sync caller, it dispatches to the active AsyncBridge via run_sync, sharing its background loop and connection pools. Called from inside a running loop with PRAISONAI_ALLOW_LOOP_BLOCKING=true, it copies the caller’s ContextVars onto a worker thread that hands the coroutine to the same bridge β€” never a fresh asyncio.new_event_loop() β€” so a caller-installed scoped_bridge() binding still wins and LiteLLM/HTTPX per-loop connection pools are preserved. Exceptions re-raise on the caller thread.

The strict RuntimeError

In the default (strict) mode, a call from inside a running loop raises with this message β€” grep for it in a traceback:

How it differs from run_sync

Under the PRAISONAI_ALLOW_LOOP_BLOCKING opt-in, run_sync_or_offload() bounds thread.join() at timeout + 1.0s. If the worker is still alive it cancels the in-flight future and raises TimeoutError("run_sync_or_offload() worker did not complete within {timeout}s"). The extra second covers thread hand-off/teardown so the join does not spuriously time out before the worker records its own error.

Migration

Any caller that today relies on offload-inside-a-loop must either (a) migrate to the awaitable sibling (await praisonai.arun(...), await adapter.arun(...), or await arun_sync_or_offload(...)), or (b) set PRAISONAI_ALLOW_LOOP_BLOCKING=true as an interim measure. See PR #4261.

Best Practices

Inside a running loop, await the awaitable sibling so the loop stays responsive. The sync helper now raises RuntimeError by design rather than parking the loop.
On a CLI or plain-script entry point there is no running loop, so run_sync_or_offload dispatches to run_sync and reuses the shared bridge and its connection pools.
The opt-in restores the pre-#4261 offload-and-join behaviour, which blocks the caller’s event loop for up to timeout + 1s. Use it only as a bounded interim measure while migrating a known-safe path to the awaitable sibling β€” never as a long-term default.
This helper landed in PraisonAI #3492; the strict-inside-a-loop default landed in PR #4261. Its callers (praisonai.auto, persistence.orchestrator, api.agent_invoke) are migrated onto it.

run_sync

Module-level runner for sync-only paths.

scoped_bridge

Per-session bridge preserved across the offload hop when PRAISONAI_ALLOW_LOOP_BLOCKING=true.

Agent-Centric Tools

The Tier-2 _run_sync (PR #3361) mirrors the same design.

arun_sync_or_offload β€” await from an async context

Use arun_sync_or_offload() from async callers (FastAPI/Starlette handlers, Jupyter cells, async tests) β€” await it instead of parking the loop thread with the sync helper.
1

Import and await

2

Inside a FastAPI handler

await, don’t park β€” the loop stays responsive while the agent runs:
The user calls an async endpoint; the agent’s coroutine runs on the shared background bridge while the request loop stays free to serve other traffic.

Configuration Options

Unlike run_sync_or_offload, this variant does not park the running loop thread. It submits coro to the active AsyncBridge and awaits the result, so the loop keeps serving other work. The coroutine runs on the shared background bridge loop β€” never a fresh asyncio.new_event_loop() β€” so a caller-installed scoped_bridge() binding still wins and per-loop LiteLLM/HTTPX connection pools are preserved. On timeout or cancellation, the in-flight future is cancelled before the exception re-raises.

Best Practices

Inside a running loop, run_sync_or_offload raises RuntimeError by default (and only offloads-and-blocks under PRAISONAI_ALLOW_LOOP_BLOCKING=true). arun_sync_or_offload awaits instead, so the loop keeps serving other requests.
Plain scripts and CLI entry points can’t await. Use run_sync_or_offload (or run_sync) there and reserve arun_sync_or_offload for code already inside a coroutine.

run_sync_or_offload

Sync sibling for non-async callers.

scoped_bridge

Per-session bridge preserved across the await.

Per-Session Scoped Bridge

Servers and gateways that handle multiple concurrent sessions need each session to run on its own loop+thread binding. current_bridge() and scoped_bridge() provide ContextVar-backed per-session isolation so sessions never share a bridge accidentally.
A single PraisonAIDB (or any adapter that inherits from it, e.g. NeonDB) is safe to share across multiple scoped_bridge() loops. Its async init/close no longer caches an event-loop-bound asyncio.Lock on the instance β€” sync serialisation happens off-loop via asyncio.to_thread under a threading.Lock, so per-session loops can each call on_run_end / aclose against the shared adapter without RuntimeError: <lock> is bound to a different event loop.

When to use scoped bridges

Use scoped_bridge() inside any request handler that may run concurrently with other handlers β€” for example a FastAPI endpoint, a Starlette WebSocket handler, or a custom bot session dispatcher. The isolation now extends into sync-completion runner threads used by AutoGenerator.generate() β€” a scoped_bridge() set on the caller is honoured inside the worker thread via contextvars.copy_context().
  • Multi-tenant sync embedders no longer need a manual scoped_bridge() around praisonai workflow run (or a process: workflow YAML dispatched through generate_crew_and_kickoff()) β€” _run_yaml_workflow wraps workflow.start(...) in its own scoped_bridge().

scoped_bridge() context manager

The context manager uses a ContextVar so nested scopes work correctly in async tasks and threads β€” each concurrent task sees only its own bridge.
When scoped_bridge() creates the bridge for you (no argument), it shuts down with permanent=True on exit. If code tries to call run_sync or submit on that bridge afterward, you get:RuntimeError: AsyncBridge has been shut down and cannot be reused; this usually means a context outlived its scoped_bridge() blockThat guard stops an orphaned loop and thread from outliving the scope that owned them. The shared default bridge always shuts down with permanent=False.

Scope-owned bridge (preferred)

With no argument, scoped_bridge() creates a fresh bridge and tears it down with permanent=True when the with block ends. Internal code that calls module-level run_sync() inside the block uses the scoped bridge via contextvars β€” no import changes required.

current_bridge() for introspection

current_bridge() returns the bridge bound to the current async task, or None when no scope is active. Use it to inspect which bridge is in use without passing it explicitly through call stacks.

Multi-session server example

API Reference


async_scoped_bridge() β€” async-safe context manager

async_scoped_bridge() is the async-safe sibling of scoped_bridge() for async def callers β€” same binding semantics, but the scope-owned bridge is torn down off-loop so scope exit never parks the caller’s event loop. Wrap your async handler in one line β€” a stuck coroutine in one tenant’s request can no longer freeze the loop for every other tenant:

When to use it

Pick the context manager that matches your caller.
  • scoped_bridge() β€” sync callers (CLI, generate_crew_and_kickoff, sync request handlers).
  • async_scoped_bridge() β€” inside async def (FastAPI handlers, agenerate_crew_and_kickoff, custom async gateways). Preferred over the sync sibling for any async def, because scope exit on the sync context manager parks the loop thread for up to ~10s while shutdown() waits for cancellation and joins the background thread.

FastAPI multi-tenant handler

Do not use the sync scoped_bridge() from inside async def. Its teardown runs on the caller thread and will park the loop for up to ~10s while AsyncBridge.shutdown() cancels in-flight tasks and joins the background thread β€” the exact β€œone stuck tenant stalls every other tenant” pathology async_scoped_bridge() exists to prevent.

Teardown internals

A scope-owned bridge (no argument) is torn down via asyncio.to_thread(bridge.shutdown, permanent=True) under asyncio.shield, so the blocking cancel-and-join runs on a worker thread instead of the event loop. asyncio.shield keeps that teardown running to completion even when the surrounding scope is being cancelled, so the loop+thread is neither leaked nor blocked. A caller-provided bridge is left untouched β€” the caller owns its lifecycle.

Behavioural contract

API Reference


Per-run auto-scoping (sequential, hierarchical, workflow)

Both generate_crew_and_kickoff() (sync) and agenerate_crew_and_kickoff() (async) now auto-wrap each run in a per-run scoped bridge β€” the sync path uses scoped_bridge(), the async path uses async_scoped_bridge(). Every run isolates its run_sync-driven work onto its own loop+thread, so a stuck coroutine in one agent/tenant can no longer park the shared default loop for the rest. Each entry point has two arms β€” a sequential/hierarchical arm and a YAML-workflow arm β€” so there are four corners in total. All four are now symmetric. The same per-run scoping now covers process: workflow on the sync path. _run_yaml_workflow (invoked by praisonai workflow run ... and AgentsGenerator.generate_crew_and_kickoff() when the YAML’s process: is workflow) wraps workflow.start(...) in scoped_bridge(). The async workflow path (_arun_yaml_workflow) already runs on the caller’s loop and remains unchanged. PR #5360 closes the last asymmetry: the sync YAML workflow arm now wraps its _run_yaml_workflow(config) call in scoped_bridge(), matching the other three corners so a stuck coroutine in one tenant’s YAML workflow can no longer park the shared default loop for the rest. Callers doing multi-tenant runs no longer need to add their own scoped_bridge() around either entry point β€” with this PR that is now true for all four corners β€” a stuck coroutine in one run cannot affect another. On the async path this closes a live multi-tenancy regression: praisonai serve, the gateway, and FastAPI-embed deployments no longer let a stuck coroutine in one tenant park the shared default loop+thread for the others. The earlier reasoning that async callers await arun directly did not hold in practice β€” adapter internals, user tools, the sync DeliveryRouter._finalize_delivery, and BlueprintAgent.start() (via run_sync(...)) all reach back into the sync bridge, which previously resolved to the process-default bridge under the async path. See Wrapper β†’ Lifecycle / cleanup for the embedder view.
Async Agents Guide
Thread Safety & Concurrency