Bindings
Bindings let your JavaScript code talk to Python. Think of them as bridges: you expose Python functions and data, and JavaScript can use them naturally.
Why Use Bindings?
Sometimes you want JavaScript to do the heavy lifting (parsing, transforming data), but you need Python for specific tasks:
- Call a Python API or library
- Access Python data without copying everything
- Let JavaScript trigger Python side effects (logging, notifications, etc.)
Instead of passing data back and forth with eval(), you bind once and call many times.
Binding Functions
Use bind_function() to expose a Python function to JavaScript:
from pydeno import Runtime
with Runtime() as runtime:
# Define a Python function
def greet(name):
return f"Hello, {name}!"
# Bind it to JavaScript
runtime.bind_function("greet", greet)
# Now JavaScript can call it
result = runtime.eval("greet('World')")
print(result) # "Hello, World!"
That's it. JavaScript sees greet as a regular function.
Multiple Arguments
Python functions can accept any number of arguments:
with Runtime() as runtime:
def add(a, b, c=0):
return a + b + c
runtime.bind_function("add", add)
print(runtime.eval("add(1, 2)")) # 3
print(runtime.eval("add(1, 2, 3)")) # 6
Arguments are automatically converted between Python and JavaScript types (numbers, strings, lists, dicts, etc.).
Async Functions
Async Python functions work too. JavaScript receives a Promise:
import asyncio
async def main():
with Runtime() as runtime:
async def fetch_data(url):
await asyncio.sleep(0.1) # Simulate async work
return {"url": url, "status": 200}
runtime.bind_function("fetchData", fetch_data)
# JavaScript gets a Promise
result = await runtime.eval_async("""
fetchData('https://example.com')
""")
print(result) # {'url': 'https://example.com', 'status': 200}
asyncio.run(main())
JavaScript doesn't need to know the function is async, it just awaits the Promise.
Binding Objects
Use bind_object() to pass Python data to JavaScript:
with Runtime() as runtime:
config = {
"debug": True,
"timeout": 30,
"retries": 3
}
runtime.bind_object("config", config)
# JavaScript can read it
result = runtime.eval("config.debug && config.retries > 0")
print(result) # True
What Can You Bind?
You can bind Python values that can be converted to JavaScript:
- Primitives:
int,float,str,bool,None - Collections:
list,dict,tuple - Binary data:
bytes,bytearray,memoryview(becomesUint8Array)
with Runtime() as runtime:
# Bind various types (must be wrapped in a dict)
runtime.bind_object("numbers", {"items": [1, 2, 3, 4, 5]})
runtime.bind_object("user", {"name": "Alice", "age": 30})
runtime.bind_object("data", {"bytes": b'\x00\x01\x02\x03'})
# Use them in JavaScript
runtime.eval("numbers.items.reduce((a, b) => a + b)") # 15
runtime.eval("user.name.toUpperCase()") # "ALICE"
runtime.eval("data.bytes[0] + data.bytes[1]") # 1
Objects Are Snapshots
When you bind an object, it gets serialized and JavaScript receives a copy of the data at that moment:
with Runtime() as runtime:
counter = {"value": 0}
runtime.bind_object("counter", counter)
# JavaScript modifies its copy
runtime.eval("counter.value = 10")
# Python's original is unchanged
print(counter) # {'value': 0}
If you need shared state, bind a function that returns fresh data each time.
Practical Examples
Configuration and Feature Flags
with Runtime() as runtime:
runtime.bind_object("features", {
"darkMode": True,
"experimentalUI": False,
"maxUploadSize": 10_000_000
})
result = runtime.eval("""
if (features.darkMode) {
"dark-theme.css"
} else {
"light-theme.css"
}
""")
print(result) # "dark-theme.css"
Logging from JavaScript
with Runtime() as runtime:
def log(level, message):
print(f"[{level.upper()}] {message}")
runtime.bind_function("log", log)
runtime.eval("""
log('info', 'Starting process...');
log('error', 'Something went wrong!');
""")
# Output:
# [INFO] Starting process...
# [ERROR] Something went wrong!
Data Validation
with Runtime() as runtime:
def validate_email(email, *args):
# Accept extra args that JS array methods pass (index, array)
return "@" in email and "." in email
runtime.bind_function("validateEmail", validate_email)
result = runtime.eval("""
const emails = ['user@example.com', 'invalid', 'test@domain.org'];
emails.filter(validateEmail)
""")
print(result) # ['user@example.com', 'test@domain.org']
Processing with Python Libraries
with Runtime() as runtime:
def process_image(data):
# Imagine using Pillow, OpenCV, etc.
return f"Processed {len(data)} bytes"
runtime.bind_function("processImage", process_image)
# JavaScript sends binary data to Python
runtime.bind_object("image", {"data": b'\x89PNG\r\n...'})
result = runtime.eval("processImage(image.data)")
print(result) # "Processed 9 bytes"
Decorator Style
For a cleaner syntax, use the @runtime.bind() decorator:
import asyncio
async def main():
with Runtime() as runtime:
@runtime.bind()
def calculate(x, y):
return x * y + 10
@runtime.bind()
async def fetch_user(user_id):
# Simulate async database call
await asyncio.sleep(0.1)
return {"id": user_id, "name": "Alice"}
result = runtime.eval("calculate(5, 3)")
print(result) # 25
user = await runtime.eval_async("fetch_user(123)")
print(user) # {'id': 123, 'name': 'Alice'}
asyncio.run(main())
The decorator automatically uses the function's name as the binding name in JavaScript. If you want a different name, pass the name parameter:
with Runtime() as runtime:
@runtime.bind(name="add")
def my_addition_function(a, b):
return a + b
result = runtime.eval("add(2, 3)") # 5
Module-Level API
For quick scripts, use the module-level functions (they use a context-local runtime):
import pydeno
# Bind to the default runtime
pydeno.bind_function("add", lambda a, b: a + b)
pydeno.bind_object("config", {"version": "1.0"})
# Use them immediately
print(pydeno.eval("add(2, 3)")) # 5
print(pydeno.eval("config.version")) # "1.0"
This is perfect for interactive sessions or simple scripts where you don't need explicit runtime management.
Typed Tool Errors
When a bound Python function raises, JavaScript gets a real Error whose
name is the Python exception's class name and whose message is the
exception's message:
from pydeno import Runtime
class RateLimited(Exception):
pass
def get_weather(city):
if city == "Atlantis":
raise ValueError(f"unknown city: {city}")
if city == "Springfield":
raise RateLimited("try again in 30s")
return f"72F and sunny in {city}"
with Runtime() as runtime:
runtime.bind_function("getWeather", get_weather)
print(runtime.eval("""
const classify = (city) => {
try { return 'ok: ' + getWeather(city) }
catch (e) {
if (e.name === 'ValueError') return 'bad input';
if (e.name === 'RateLimited') return 'retry later';
return 'unexpected: ' + e.name;
}
};
[classify('Zurich'), classify('Atlantis'), classify('Springfield')].join(' | ')
"""))
# ok: 72F and sunny in Zurich | bad input | retry later
This is the same name/message shape that a JavaScript exception reaching
Python carries on JavaScriptError, so the two
directions are symmetric. An uncaught tool exception therefore arrives in
Python with the class name preserved as well:
with Runtime() as runtime:
runtime.bind_function("getWeather", get_weather)
try:
runtime.eval("getWeather('Atlantis')")
except Exception as exc:
print(exc.name) # "ValueError"
print(exc.message) # "unknown city: Atlantis"
Only the class name and message cross the boundary — never a Python traceback, module path or local variable.
Capturing console Output
By default console.log from sandboxed JavaScript goes nowhere. Pass
on_console to get it back in Python:
from pydeno import Runtime, RuntimeConfig
lines = []
config = RuntimeConfig(on_console=lambda level, args: lines.append((level, args)))
with Runtime(config) as runtime:
runtime.eval("console.log('processing', 3, {items: [1, 2]}); void 0;")
print(lines) # [('log', ['processing', 3, {'items': [1, 2]}])]
The callback receives (level, args), where level is the console method
name ("log", "info", "warn", "error", "debug", "trace") and
args is that call's argument list, converted with the same rules as any
other host callback — so you get the values, not a pre-rendered string.
This is what you want when showing a model what its own script printed.
on_console and enable_console are independent
enable_console controls only whether the process's own stdout/stderr
receives console output. on_console controls whether you receive it. They
compose:
enable_console |
on_console |
Result |
|---|---|---|
False (default) |
None |
console.* is a no-op; output discarded |
True |
None |
Output goes to the process's stdout/stderr |
False |
set | Output goes only to your callback |
True |
set | Callback fires, then the process's console |
Other semantics worth knowing:
- The callback must be synchronous.
console.logis synchronous in JavaScript, so an async callback would never be awaited. - Output from a
bootstrapscript is captured too — the hook is installed before bootstrap runs. - A
consolecall can never break the script that made it. If an argument list cannot be represented as a Python value (a circular object, or one over the serialization limits), the bridge retries with the arguments stringified, and drops the message entirely rather than throwing if even that fails. A callback that raises is likewise swallowed. - Passing a JS function (or a
Symbol) to a host tool is refused with aTypeErrornaming the argument path -- it does not arrive as{}. See Capabilities and revocation. null/undefinedboth arrive as theundefinedsentinel, as everywhere else on the host-callback path.
Capabilities and revocation
bind_function returns an op capability token, bind_object returns one
per callable key, and register_op returns the token for the op it created.
The token is an unguessable integer drawn from a CSPRNG, and it is the whole
authority to call that op: guest JS reaches a host handler only through a
token a completed bind step installed in its scope.
That matters for two reasons:
- Do not hand a token to guest code you do not mean to grant the capability to. The name is convenience; the token is authority.
- A namespace is not a trust boundary by itself, but a token is. Two
ToolBridgees with different trust levels on oneRuntimeno longer collapse into one trust level, because neither can address the other's ops.
Revoke with revoke_op, or
ToolBridge.detach for a whole bridge:
token = runtime.bind_function("dangerous", do_something)
runtime.eval("dangerous()") # works
runtime.revoke_op(token)
runtime.eval("dangerous()") # raises: the capability is gone
The global name stays on globalThis -- a guest may have captured the
function reference already anyway -- but the capability behind it is dropped,
so the call fails.
A guest-visible failure to reach an op is always the same, name-free
Unknown host op, whether the token is unknown, was never exposed, or was
revoked. That is deliberate: distinguishing them would let a guest enumerate
what a runtime has registered.
Passing a JS function to a host tool raises rather than silently arriving as
{}. Supporting it properly means a host-held reference with a documented
lifetime (ownership, release, behaviour after the supplying call returned),
which is a feature rather than a conversion detail. If a tool needs a
callback shape, have it return a value and let the guest apply its own
function to it.
ToolBridge: many tools, with a budget
bind_function and bind_object are the primitives. If what you actually
want is "give this sandbox N callable tools safely", ToolBridge packages
the three things you would otherwise write yourself: a total call budget,
fail-closed name checking, and the typed errors described above.
from pydeno import Runtime, ToolBridge
def get_weather(city: str) -> str:
return f"72F and sunny in {city}"
async def send_email(to: str, subject: str, body: str) -> bool:
return True
bridge = ToolBridge(
{"get_weather": get_weather, "send_email": send_email},
max_calls=50, # total across all tools; None = unlimited
namespace="tools", # tools.get_weather(...); None = bare globals
)
with Runtime() as runtime:
bridge.attach(runtime)
print(runtime.eval("tools.get_weather('Zurich')"))
print(bridge.calls_made, "of", 50)
- The budget is total, not per tool. It caps how many tool calls one
agent turn may make, which is the quantity you actually want to bound.
The call that exceeds it never reaches your Python function: JS gets a
catchable error named
ToolBudgetError(or, withon_exhausted="silent", anullresult). - Sync and async tools both work, detected automatically; an async tool becomes an awaitable JS function.
- Names are checked at construction, not when JS happens to call them.
Anything outside
[A-Za-z_][A-Za-z0-9_]*, and names that would corrupt JavaScript's object machinery (__proto__,constructor, ...), are rejected immediately. calls_made,calls_remaining,tool_namesandreset_budget()let you inspect and recycle the budget between turns.
pydeno ships ToolError, ToolBudgetError and ToolNotFoundError as a
shared vocabulary, but typed errors work for any Python exception class —
you do not have to inherit from them.
Only ToolBudgetError is raised by the library. ToolNotFoundError is there
for your tools to raise when a lookup inside one of them misses; pydeno
never raises it for an unknown tool name, because a name this bridge does
not expose is not a property on the namespace object at all, so guest JS gets
V8's own TypeError: tools.nope is not a function.
ToolBridge requires Runtime
ToolBridge.attach() takes a Runtime and raises
TypeError immediately for anything else, because binding a Python callable
needs a real op registry (deno_core::JsRuntime) to attach to.
Tips and Best Practices
Keep functions simple: Bound functions should be fast. If you have expensive operations, consider running them in a thread pool and returning a future.
Bind early: Set up all your bindings before running complex JavaScript. It's cleaner and easier to debug.
Use meaningful names: Make function names clear and follow JavaScript conventions (camelCase).
Don't bind everything: Only expose what JavaScript actually needs. Keep your API surface small.
Remember the copy: Objects are snapshots. For dynamic data, bind a function that returns fresh values.
Next Steps
- Learn about Type Conversion to understand how Python and JavaScript types map
- Explore Modules to organize code with imports and exports
- See
examples/tool_bridge.pyfor a runnableToolBridge+ console-capture walkthrough