Python API
May 6, 2026 · View on GitHub
pip install zymi-core ships the zymi CLI plus the zymi Python module for two use cases: writing @tool-decorated Python functions auto-discovered from tools/*.py, and embedding zymi inside your own Python application.
Overview
The Python module is a thin wrapper over the Rust runtime, built with pyo3 + maturin. The exposed types share Arc-handles with the Rust side — Runtime.bus() and Runtime.store() give you the same bus and store the pipeline writes to, so you can subscribe in Python and see Rust-emitted events in real time.
Top-level imports
from zymi import (
Event,
EventBus,
EventStore,
RunPipelineResult,
Runtime,
StepResult,
Subscription,
ToolRegistry,
)
from zymi import tool # @tool decorator for tools/*.py
@tool — Python tool decorator
Auto-discovered from any tools/<name>.py in a zymi project. Sync and async both supported.
from zymi import tool
@tool
def get_weather(city: str) -> str:
"""Return the current weather for a city."""
return f"sunny in {city}"
@tool(name="search_v2", description="Newer search.", intention="web_search", requires_approval=False)
async def search(query: str, limit: int = 10) -> str:
"""..."""
...
The decorator is a marker: it sets _zymi_tool = True (plus optional _zymi_tool_name, _zymi_tool_description, _zymi_tool_intention, _zymi_tool_requires_approval) and returns the function unchanged. The function is still callable directly in tests.
The Rust loader walks every tools/*.py at runtime startup, picks up callables with _zymi_tool truthy, and uses Python's inspect module to introspect signature + docstring into a JSON Schema for the LLM.
Decorator kwargs:
| Kwarg | Purpose |
|---|---|
name= | Override the registered name (default: func.__name__) |
description= | Override the description (default: docstring's first line) |
intention= | ESAA intention tag (default: CallCustomTool) |
requires_approval= | Force human-approval gating |
Runtime
Embed a zymi project in your Python app.
from zymi import Runtime
rt = Runtime.for_project("./my-zymi-project", approval="terminal")
result = rt.run_pipeline("chat", inputs={"message": "hello"})
print(result.success, result.final_output)
for step_id, sr in result.step_results().items():
print(step_id, sr.success, sr.iterations, sr.output)
Runtime.for_project(path, approval="terminal") — load project.yml and build a runtime.
path— project root containingproject.yml.approval—"terminal"(default, prompts on stdin for tools requiring approval) or"none"(such tool calls resolve to a denial). A pluggable Python callback is a future addition.
Runtime.run_pipeline(name, inputs=None) -> RunPipelineResult — one-shot pipeline run. inputs is a dict[str, str].
Runtime.bus() -> EventBus — shared Arc<EventBus> with the runtime. Subscribe to see live events.
Runtime.store() -> EventStore — shared Arc<EventStore> with the runtime. Read events directly.
Runtime.project_root() -> str — absolute path of the project root.
The runtime cleans up its approval channels on __del__.
RunPipelineResult and StepResult
result.pipeline_name # str
result.success # bool
result.final_output # Optional[str] — the `output:` step's text
result.step_results() # dict[str, StepResult]
result.step("respond") # Optional[StepResult]
sr.step_id # str
sr.agent_name # str — the agent that ran (None for tool steps)
sr.output # str
sr.iterations # int — ReAct loop count
sr.success # bool
EventBus, EventStore, Event, Subscription
For embedding scenarios where you want to inject your own events or observe live.
from zymi import EventStore, EventBus, Event
store = EventStore("./my-project/.zymi/events.db")
bus = EventBus(store)
# Publish.
event = Event(
stream_id="my-stream",
kind={"type": "UserMessageReceived", "data": {
"content": {"User": "hello"},
"connector": "embedded",
}},
source="my-app",
)
bus.publish(event)
# Subscribe.
sub = bus.subscribe()
ev = sub.try_recv() # Optional[Event] — non-blocking
if ev is not None:
print(ev.kind_tag, ev.stream_id)
# Subscribe to one correlation.
sub = bus.subscribe_correlation("approval-abc")
Event(stream_id, kind, source) — construct in Python. kind is a dict matching the EventKind JSON shape ({"type": "VariantName", "data": {...}}).
Event.kind_tag — string variant tag ("user_message_received" etc).
Subscription.try_recv() -> Optional[Event] — non-blocking poll.
EventStore(path) — opens or creates a SQLite event store at path. The Postgres backend is currently driven via the YAML store: postgres://… field; a dedicated Python constructor is a follow-up.
ToolRegistry
Programmatic tool registration — useful when you want to define tools in Python WITHOUT relying on the tools/*.py auto-discovery (e.g. dynamically generated tools, tools loaded from a non-standard directory).
from zymi import ToolRegistry
reg = ToolRegistry()
reg.register_from_callable(my_function) # picks up `@tool` markers if present
print(reg.names()) # list[str]
The auto-discovery path uses an internal registry equivalent — most users never need to touch ToolRegistry directly.
CLI entry point
The pip wheel installs zymi as a console script defined by [project.scripts]:
[project.scripts]
zymi = "zymi._cli:main"
zymi._cli.main(argv) is a thin shim that dispatches to the embedded Rust CLI. You can call it from Python if you want the CLI's behaviour without shelling out:
from zymi._cli import main
main(["run", "chat", "-i", "message=hello"])
Building from source
maturin develop --features python,cli # editable install into the active venv
# or
maturin build --features python,cli # produce a wheel
pip install dist/zymi_core-*.whl
The python feature pulls in pyo3; cli pulls in clap and the full CLI surface. macOS local cargo test --features python doesn't link cleanly because pyo3's extension-module setting is intended for runtime loading — use maturin + Python for local Python testing.
Gotchas
@tool'd functions are still callable directly — useful in tests but means "did the decorator apply" can't be observed at the call site. Checkfunc._zymi_toolto verify.- Don't
print()from@toolfunctions. Return a string.printgoes to stderr and is not captured by the runtime. Runtime.run_pipelineis blocking. It runs the async pipeline on a shared Tokio runtime viablock_on. If you call it from inside an existing event loop you'll deadlock — useasyncio.to_threador run it from a worker.- The pip wheel and the cargo build are different artefacts. Cargo
cargo build --features cli(nopython) produces azymibinary that won't loadtools/*.py. Use the pip-installedzymifor any project that uses Python tools.
See also
- Tools — declarative + Python + MCP + builtin overview
- Events and replay — what events look like
- ADR-0014 (declarative tools), ADR-0009 (event sourcing)