diff --git a/.gitignore b/.gitignore
index 5d61dde..eee8e22 100644
--- a/.gitignore
+++ b/.gitignore
@@ -208,3 +208,4 @@ marimo/_lsp/
__marimo__/
docs/site/*
+.claude/settings.local.json
diff --git a/pywry/docs/docs/components/chat/index.md b/pywry/docs/docs/components/chat/index.md
index 7055983..5070b3a 100644
--- a/pywry/docs/docs/components/chat/index.md
+++ b/pywry/docs/docs/components/chat/index.md
@@ -1,22 +1,72 @@
# Chat
-PyWry includes a first-class chat UI that can run in native windows, notebook widgets, and browser-rendered deployments. The chat stack has two layers:
+PyWry ships a complete chat UI component that works in native desktop windows, Jupyter notebooks, and browser tabs. It handles the entire conversation lifecycle — rendering messages, streaming responses token-by-token, managing multiple conversation threads, and displaying rich content like code blocks, charts, and data tables inline.
-- `build_chat_html()` for low-level rendering of the chat shell.
-- `ChatManager` for the production path: thread management, event wiring, streaming, stop-generation, slash commands, settings, and input requests.
+The chat system is built on the **Agent Client Protocol (ACP)**, an open standard that defines how AI coding agents communicate with client applications. You do not need to know anything about ACP to use PyWry chat — the protocol details are handled internally. What it means in practice is that the same chat component can talk to any ACP-compatible agent (like Claude Code or Gemini CLI) as easily as it talks to the OpenAI or Anthropic APIs.
-If you are building an interactive assistant, use `ChatManager` unless you explicitly need to assemble the raw chat HTML yourself.
+## Architecture Overview
-For the complete API surface, see the [Chat API](../../reference/chat.md), [ChatManager API](../../reference/chat-manager.md), and [Chat Providers API](../../integrations/chat/chat-providers.md).
+The chat system has two layers:
-## Minimal ChatManager Setup
+1. **`ChatManager`** — the high-level orchestrator that most developers should use. It handles thread management, event wiring, streaming, cancellation, slash commands, settings menus, and all the plumbing between your AI backend and the chat UI.
+
+2. **`build_chat_html()`** — the low-level HTML builder that produces the raw chat DOM structure. Use this only if you are assembling a completely custom chat experience and want to handle all events yourself.
+
+## How It Works
+
+When a user types a message in the chat input and presses send:
+
+1. The frontend emits a `chat:user-message` event with the text.
+2. `ChatManager` receives the event, stores the message in the thread history, and starts a background thread.
+3. The background thread calls your **handler function** (or **provider**) with the conversation history.
+4. Your handler returns or yields response chunks — plain strings for text, or typed objects for rich content.
+5. `ChatManager` dispatches each chunk to the frontend as it arrives, which renders it in real time.
+6. When the handler finishes, the assistant message is finalized and stored in thread history.
+
+The user can click **Stop** at any time to cancel generation. Your handler receives this signal through `ctx.cancel_event`.
+
+## Getting Started
+
+### Install
+
+```bash
+pip install pywry
+```
+
+For AI provider support, install the optional extras:
+
+```bash
+pip install 'pywry[openai]' # OpenAI
+pip install 'pywry[anthropic]' # Anthropic
+pip install 'pywry[magentic]' # Magentic (100+ providers)
+pip install 'pywry[acp]' # External ACP agents
+pip install 'pywry[all]' # Everything
+```
+
+### Minimal Example
+
+This creates a chat window with a simple echo handler:
```python
-from pywry import Div, HtmlContent, PyWry, Toolbar
-from pywry.chat_manager import ChatManager
+from pywry import HtmlContent, PyWry
+from pywry.chat.manager import ChatManager
def handler(messages, ctx):
+ """Called every time the user sends a message.
+
+ Parameters
+ ----------
+ messages : list[dict]
+ The full conversation history for the active thread.
+ Each dict has 'role' ('user' or 'assistant') and 'text'.
+ ctx : ChatContext
+ Context object with thread_id, settings, cancel_event, etc.
+
+ Returns or yields
+ -----------------
+ str or SessionUpdate objects — see below.
+ """
user_text = messages[-1]["text"]
return f"You said: {user_text}"
@@ -24,12 +74,11 @@ def handler(messages, ctx):
app = PyWry(title="Chat Demo")
chat = ChatManager(
handler=handler,
- welcome_message="Welcome to **PyWry Chat**.",
- system_prompt="You are a concise assistant.",
+ welcome_message="Hello! Type a message to get started.",
)
widget = app.show(
- HtmlContent(html="
Assistant
Ask something in the chat panel.
"),
+ HtmlContent(html="
My App
"),
toolbars=[chat.toolbar(position="right")],
callbacks=chat.callbacks(),
)
@@ -38,245 +87,307 @@ chat.bind(widget)
app.block()
```
-`ChatManager` expects three pieces to be connected together:
+Three things must be wired together:
-1. `chat.toolbar()` to render the chat panel.
-2. `chat.callbacks()` to wire the `chat:*` frontend events.
-3. `chat.bind(widget)` after `app.show(...)` so the manager can emit updates back to the active widget.
+1. **`chat.toolbar()`** — returns a collapsible sidebar panel containing the chat UI. Pass it to `app.show(toolbars=[...])`.
+2. **`chat.callbacks()`** — returns a dict mapping `chat:*` event names to handler methods. Pass it to `app.show(callbacks=...)`.
+3. **`chat.bind(widget)`** — tells the manager which widget to send events back to. Call this after `app.show()` returns.
-## Handler Shapes
+## Writing Handlers
-The handler passed to `ChatManager` is the core integration point. PyWry supports all of these forms:
+The handler function is where your AI logic lives. It receives the conversation history and a context object, and produces the assistant's response.
-- Sync function returning `str`
-- Async function returning `str`
-- Sync generator yielding `str` chunks
-- Async generator yielding `str` chunks
-- Sync or async generator yielding rich `ChatResponse` objects
+### Return a String
-### One-shot response
+The simplest handler returns a complete string. The entire response appears at once.
```python
def handler(messages, ctx):
- question = messages[-1]["text"]
- return f"Answering: {question}"
+ return "Here is my answer."
```
-### Streaming response
+### Yield Strings (Streaming)
+
+For a streaming experience where text appears word-by-word, yield string chunks from a generator:
```python
import time
def handler(messages, ctx):
- text = "Streaming responses work token by token in the chat UI."
- for word in text.split():
+ words = "This streams one word at a time.".split()
+ for word in words:
if ctx.cancel_event.is_set():
- return
+ return # User clicked Stop
yield word + " "
- time.sleep(0.03)
+ time.sleep(0.05)
```
-### Rich response objects
+Always check `ctx.cancel_event.is_set()` between chunks. This is how the Stop button works — it sets the event, and your handler should exit promptly.
+
+### Yield Rich Objects
+
+Beyond plain text, handlers can yield typed objects that render as structured UI elements:
```python
-from pywry import StatusResponse, ThinkingResponse, TodoItem, TodoUpdateResponse
+from pywry.chat.updates import PlanUpdate, StatusUpdate, ThinkingUpdate
+from pywry.chat.session import PlanEntry
def handler(messages, ctx):
- yield StatusResponse(text="Searching project files...")
- yield TodoUpdateResponse(
- items=[
- TodoItem(id=1, title="Analyze request", status="completed"),
- TodoItem(id=2, title="Generate answer", status="in-progress"),
- ]
- )
- yield ThinkingResponse(text="Comparing the available implementation paths...\n")
- yield "Here is the final answer."
+ # Show a transient status message (disappears when next content arrives)
+ yield StatusUpdate(text="Searching documentation...")
+
+ # Show collapsible thinking/reasoning (not stored in history)
+ yield ThinkingUpdate(text="Evaluating three possible approaches...\n")
+
+ # Show a task plan with progress tracking
+ yield PlanUpdate(entries=[
+ PlanEntry(content="Search docs", priority="high", status="completed"),
+ PlanEntry(content="Synthesize answer", priority="high", status="in_progress"),
+ ])
+
+ # Stream the actual answer
+ yield "Based on the documentation, the answer is..."
```
-## Conversation State
+These objects are called **session updates** and follow the ACP specification. The available types are:
+
+| Type | What It Does |
+|------|-------------|
+| `StatusUpdate` | Shows a transient inline status (e.g. "Searching...") |
+| `ThinkingUpdate` | Shows collapsible reasoning text (not saved to history) |
+| `PlanUpdate` | Shows a task list with priority and status for each entry |
+| `ToolCallUpdate` | Shows a tool invocation with name, kind, and lifecycle status |
+| `CitationUpdate` | Shows a source reference link |
+| `ArtifactUpdate` | Shows a rich content block (code, chart, table — see Artifacts below) |
+| `PermissionRequestUpdate` | Shows an inline approval card for tool execution |
+| `CommandsUpdate` | Dynamically registers slash commands |
+| `ConfigOptionUpdate` | Pushes settings options from the agent |
+| `ModeUpdate` | Switches the agent's operational mode |
-`ChatManager` handles thread state internally:
+You can mix these freely with plain text strings in any order.
-- Creates a default thread on startup
-- Tracks active thread selection
-- Supports thread create, switch, rename, and delete events
-- Keeps message history per thread
-- Exposes `active_thread_id`, `threads`, and `settings` as read-only views
+### Async Handlers
-Use `send_message()` when you need to push a programmatic assistant message into the active thread or a specific thread.
+All handler shapes work as `async` functions or async generators too:
```python
-chat.send_message("Background task completed.")
+async def handler(messages, ctx):
+ async for chunk in my_async_llm_stream(messages):
+ if ctx.cancel_event.is_set():
+ return
+ yield chunk
```
-## Slash Commands
+## Using a Provider Instead of a Handler
-Slash commands appear in the command palette inside the chat input. Register custom commands with `SlashCommandDef` and optionally handle them through `on_slash_command`.
+If you want to connect to an actual LLM API, you can pass a **provider** instead of writing a handler function. Providers implement the ACP session interface and handle message formatting, streaming, and cancellation internally.
```python
-from pywry import SlashCommandDef
+from pywry.chat.manager import ChatManager
+from pywry.chat.providers.openai import OpenAIProvider
+provider = OpenAIProvider(api_key="sk-...")
+chat = ChatManager(
+ provider=provider,
+ system_prompt="You are a helpful coding assistant.",
+)
+```
-def on_slash(command, args, thread_id):
- if command == "/time":
- chat.send_message("Current time: **12:34:56**", thread_id)
+Available providers:
+
+| Provider | Backend | Install |
+|----------|---------|---------|
+| `OpenAIProvider` | OpenAI API | `pip install 'pywry[openai]'` |
+| `AnthropicProvider` | Anthropic API | `pip install 'pywry[anthropic]'` |
+| `MagenticProvider` | Any magentic-supported LLM | `pip install 'pywry[magentic]'` |
+| `CallbackProvider` | Your own Python callable | (included) |
+| `StdioProvider` | External ACP agent via subprocess | `pip install 'pywry[acp]'` |
+
+The `StdioProvider` is special — it spawns an external program (like `claude` or `gemini`) as a subprocess and communicates over stdin/stdout using JSON-RPC. This means you can connect PyWry's chat UI to any ACP-compatible agent without writing any adapter code.
+
+See [Chat Artifacts And Providers](../../integrations/chat/index.md) for detailed provider documentation.
+
+## Conversation Threads
+
+`ChatManager` supports multiple conversation threads. The UI includes a thread picker dropdown in the header bar where users can create, switch between, rename, and delete threads.
+
+Each thread has its own independent message history. The manager tracks:
+
+- The active thread ID
+- Thread titles
+- Per-thread message lists
+
+You can access these programmatically:
+
+```python
+chat.active_thread_id # Currently selected thread
+chat.threads # Dict of thread_id → message list
+chat.settings # Current settings values
+chat.send_message("Hi!") # Inject a message into the active thread
+```
+
+## Slash Commands
+
+Slash commands appear in a palette when the user types `/` in the input bar. Register them at construction time:
+
+```python
+from pywry.chat.models import ACPCommand
chat = ChatManager(
handler=handler,
slash_commands=[
- SlashCommandDef(name="/time", description="Show the current time"),
- SlashCommandDef(name="/clearcache", description="Clear cached results"),
+ ACPCommand(name="/time", description="Show the current time"),
+ ACPCommand(name="/clear", description="Clear the conversation"),
],
- on_slash_command=on_slash,
+ on_slash_command=my_slash_handler,
)
+
+
+def my_slash_handler(command, args, thread_id):
+ if command == "/time":
+ import time
+ chat.send_message(f"It is {time.strftime('%H:%M:%S')}", thread_id)
```
-PyWry also ships built-in commands at the lower-level `ChatConfig` layer, including `/clear`, `/export`, `/model`, and `/system`.
+The `/clear` command is always available by default — it clears the current thread's history.
## Settings Menu
-Use `SettingsItem` to populate the gear-menu dropdown. These values are stored by the manager and emitted back through `on_settings_change`.
+The gear icon in the chat header opens a settings dropdown. Populate it with `SettingsItem` entries:
```python
-from pywry import SettingsItem
+from pywry.chat.manager import SettingsItem
def on_settings_change(key, value):
- print(f"{key} changed to {value}")
+ if key == "model":
+ chat.send_message(f"Switched to **{value}**")
+ elif key == "temp":
+ chat.send_message(f"Temperature set to **{value}**")
chat = ChatManager(
handler=handler,
settings=[
- SettingsItem(
- id="model",
- label="Model",
- type="select",
- value="gpt-4o-mini",
- options=["gpt-4o-mini", "gpt-4.1", "claude-sonnet-4"],
- ),
- SettingsItem(
- id="temperature",
- label="Temperature",
- type="range",
- value=0.7,
- min=0,
- max=2,
- step=0.1,
- ),
+ SettingsItem(id="model", label="Model", type="select",
+ value="gpt-4", options=["gpt-4", "gpt-4o", "claude-sonnet"]),
+ SettingsItem(id="temp", label="Temperature", type="range",
+ value=0.7, min=0, max=2, step=0.1),
+ SettingsItem(id="stream", label="Streaming", type="toggle", value=True),
],
on_settings_change=on_settings_change,
)
```
-## Cooperative Cancellation
-
-The stop button triggers `chat:stop-generation`, and `ChatManager` exposes that to your handler through `ctx.cancel_event`.
-
-Your handler should check `ctx.cancel_event.is_set()` while streaming so long generations terminate quickly and cleanly.
+Setting values are available in your handler via `ctx.settings`:
```python
def handler(messages, ctx):
- for chunk in very_long_generation():
- if ctx.cancel_event.is_set():
- return
- yield chunk
+ model = ctx.settings.get("model", "gpt-4")
+ temp = ctx.settings.get("temp", 0.7)
+ # Use these to configure your LLM call
```
-At the lower level, `GenerationHandle` tracks the active task and provides cancellation state for provider-backed streaming flows.
+## File Attachments And Context Mentions
-## Input Requests
+The chat input supports two ways to include extra context:
-Chat flows can pause and ask the user for confirmation or structured input by yielding `InputRequiredResponse`. The handler can then continue by calling `ctx.wait_for_input()`.
+**File attachments** — users drag-and-drop or click the paperclip button to attach files:
```python
-from pywry import InputRequiredResponse
-
-
-def handler(messages, ctx):
- yield "I need confirmation before I continue."
- yield InputRequiredResponse(
- prompt="Proceed with deployment?",
- input_type="buttons",
- )
- answer = ctx.wait_for_input()
- if not answer or answer.lower().startswith("n"):
- yield "Deployment cancelled."
- return
- yield "Deployment approved. Continuing now."
+chat = ChatManager(
+ handler=handler,
+ enable_file_attach=True,
+ file_accept_types=[".csv", ".json", ".py"], # Required
+)
```
-Supported flows include:
-
-- Button confirmation dialogs
-- Radio/select choices
-- Free-text or filename input
-
-See the chat demo example for a complete input-request flow.
-
-## Context Mentions And File Attachments
-
-`ChatManager` can expose extra context sources to the user:
-
-- `enable_context=True` enables `@` mentions for registered live widget sources.
-- `register_context_source(component_id, name)` makes a widget target selectable.
-- `enable_file_attach=True` enables file uploads.
-- `file_accept_types` is required when file attachment is enabled.
-- `context_allowed_roots` restricts attachment reads to specific directories.
+**Widget mentions** — users type `@` to reference live dashboard components:
```python
chat = ChatManager(
handler=handler,
enable_context=True,
- enable_file_attach=True,
- file_accept_types=[".csv", ".json", ".xlsx"],
- context_allowed_roots=["./data", "./reports"],
)
-
chat.register_context_source("sales-grid", "Sales Data")
```
-When attachments are present, `ChatManager.CONTEXT_TOOL` can be passed into an LLM tool schema so the model can request the full contents of an attached item on demand.
+When attachments are present, your handler receives them in `ctx.attachments`:
-## Eager Versus Lazy Assets
+```python
+def handler(messages, ctx):
+ if ctx.attachments:
+ yield StatusUpdate(text=f"Processing {len(ctx.attachments)} attachments...")
+ for att in ctx.attachments:
+ content = ctx.get_attachment(att.name)
+ yield f"**{att.name}** ({att.type}): {len(content)} chars\n\n"
+ yield "Here is my analysis of the attached data."
+```
-The chat UI can render AG Grid and Plotly artifacts inline. You can choose between:
+## Artifacts
-- Eager asset loading with `include_plotly=True` and `include_aggrid=True`
-- Lazy asset injection when the first matching artifact is emitted
+Artifacts are rich content blocks that render inline in the chat transcript. Unlike streamed text, they appear as standalone visual elements — code editors, charts, tables, etc.
-Eager loading is simpler for predictable assistant workflows. Lazy loading reduces initial page weight.
+To emit an artifact, yield it from your handler wrapped in an `ArtifactUpdate`, or yield it directly (the manager auto-wraps `_ArtifactBase` subclasses):
-## Lower-Level HTML Assembly
+```python
+from pywry.chat.artifacts import CodeArtifact, PlotlyArtifact, TableArtifact, TradingViewArtifact
-If you need to embed the raw chat shell yourself, use `build_chat_html()`.
+# Code with syntax highlighting
+yield CodeArtifact(
+ title="fibonacci.py",
+ language="python",
+ content="def fib(n):\n if n <= 1:\n return n\n return fib(n - 1) + fib(n - 2)",
+)
-```python
-from pywry import build_chat_html
+# Interactive Plotly chart
+yield PlotlyArtifact(title="Revenue", figure={"data": [{"type": "bar", "x": [1,2], "y": [3,4]}]})
-html = build_chat_html(
- show_sidebar=True,
- show_settings=True,
- enable_context=True,
- enable_file_attach=True,
- file_accept_types=[".md", ".py", ".json"],
- container_id="assistant-chat",
+# AG Grid table
+yield TableArtifact(title="Users", data=[{"name": "Alice", "age": 30}])
+
+# TradingView financial chart
+from pywry.chat.artifacts import TradingViewSeries
+yield TradingViewArtifact(
+ title="AAPL",
+ series=[TradingViewSeries(type="candlestick", data=[
+ {"time": "2024-01-02", "open": 185, "high": 186, "low": 184, "close": 185.5},
+ ])],
)
```
-This returns only the chat HTML structure. You are then responsible for wiring the matching frontend and backend event flow.
+Available artifact types: `CodeArtifact`, `MarkdownArtifact`, `HtmlArtifact`, `TableArtifact`, `PlotlyArtifact`, `ImageArtifact`, `JsonArtifact`, `TradingViewArtifact`.
+
+The frontend libraries for `TableArtifact` (AG Grid), `PlotlyArtifact` (Plotly.js), and `TradingViewArtifact` (lightweight-charts) are loaded automatically the first time an artifact of that type is emitted. You can also preload them by passing `include_plotly=True` or `include_aggrid=True` to the `ChatManager` constructor.
+
+## Notebook Mode
+
+When running inside a Jupyter notebook with `anywidget` installed (`pip install 'pywry[notebook]'`), the chat automatically renders as a native notebook widget — no HTTP server, no IFrame. The `PyWryChatWidget` bundles the chat JavaScript in its ESM module and loads artifact libraries (Plotly, AG Grid, TradingView) through traitlet synchronization when needed.
+
+This happens automatically. The same code works in native windows, notebooks, and browser deployments with no changes.
+
+## RBAC
+
+When PyWry's authentication system is enabled (deploy mode), all chat operations are gated by role-based access control:
+
+- **Viewers** can read but cannot send messages
+- **Editors** can send messages and interact normally
+- **Admins** can additionally approve file write operations from ACP agents
+
+See [Chat Artifacts And Providers](../../integrations/chat/index.md) for the full RBAC permission mapping.
## Examples
-- `pywry/examples/pywry_demo_chat.py` demonstrates `ChatManager`, slash commands, settings, todo updates, thinking output, and `InputRequiredResponse`.
-- `pywry/examples/pywry_demo_chat_artifacts.py` demonstrates all supported artifact types.
+Working examples in the `examples/` directory:
+
+- **`pywry_demo_chat.py`** — ChatManager with slash commands, settings, plan updates, thinking output, and streaming
+- **`pywry_demo_chat_artifacts.py`** — all artifact types including TradingView charts
+- **`pywry_demo_chat_magentic.py`** — magentic provider integration with tool calls
## Next Steps
-- [Chat Artifacts And Providers](../../integrations/chat/index.md)
-- [Chat API](../../reference/chat.md)
-- [ChatManager API](../../reference/chat-manager.md)
-- [Chat Providers API](../../integrations/chat/chat-providers.md)
+- [Chat Artifacts And Providers](../../integrations/chat/index.md) — detailed artifact and provider documentation
+- [Chat Providers API](../../integrations/chat/chat-providers.md) — API reference for all providers
diff --git a/pywry/docs/docs/components/modal/index.md b/pywry/docs/docs/components/modal/index.md
index 9a38244..adc576a 100644
--- a/pywry/docs/docs/components/modal/index.md
+++ b/pywry/docs/docs/components/modal/index.md
@@ -131,7 +131,7 @@ modal = Modal(
)
def on_dismissed(data, event_type, label):
- print("User dismissed the confirmation dialog")
+ app.emit("pywry:set-content", {"id": "status", "text": "Action cancelled"}, label)
app.show(content, modals=[modal], callbacks={"app:confirm-dismissed": on_dismissed})
```
diff --git a/pywry/docs/docs/features.md b/pywry/docs/docs/features.md
index 94b31c0..175f2a0 100644
--- a/pywry/docs/docs/features.md
+++ b/pywry/docs/docs/features.md
@@ -30,7 +30,7 @@ One API, three output targets — PyWry automatically selects the right one:
| **[Configuration](guides/configuration.md)** | TOML files, env vars, layered precedence |
| **[Hot Reload](guides/hot-reload.md)** | Live CSS/JS updates during development |
| **[Deploy Mode](guides/deploy-mode.md)** | Redis backend for horizontal scaling |
-| **[Tauri Plugins](integrations/tauri-plugins.md)** | 19 bundled plugins — clipboard, notifications, HTTP, and more |
+| **[Tauri Plugins](integrations/pytauri/tauri-plugins.md)** | 19 bundled plugins — clipboard, notifications, HTTP, and more |
## Platform Support
diff --git a/pywry/docs/docs/getting-started/quickstart.md b/pywry/docs/docs/getting-started/quickstart.md
index 77e0551..7831ec2 100644
--- a/pywry/docs/docs/getting-started/quickstart.md
+++ b/pywry/docs/docs/getting-started/quickstart.md
@@ -89,8 +89,6 @@ app = PyWry()
def on_button_click(data, event_type, label):
"""Called when the button is clicked."""
- print(f"Button clicked! Data: {data}")
- # Update the page content
app.emit("pywry:set-content", {"id": "greeting", "text": "Button was clicked!"}, label)
html = """
diff --git a/pywry/docs/docs/getting-started/why-pywry.md b/pywry/docs/docs/getting-started/why-pywry.md
index 9813d83..2051f78 100644
--- a/pywry/docs/docs/getting-started/why-pywry.md
+++ b/pywry/docs/docs/getting-started/why-pywry.md
@@ -1,81 +1,106 @@
# Why PyWry
-PyWry is an open-source rendering engine for building lightweight, cross-platform interfaces using Python. It solves a specific problem: **how to build beautiful, modern data applications in Python without being forced into an opinionated web framework or a heavy native GUI toolkit.**
+PyWry is an open-source rendering engine for building cross-platform interfaces using Python. It solves a specific problem: **how to build modern data applications in Python without being forced into an opinionated web framework or a heavy native GUI toolkit.**
-PyWry renders standard HTML, CSS, and JavaScript inside battle-tested OS webviews (WebView2 on Windows, WebKit on macOS/Linux). Your team can use web skills they already have — no proprietary widget toolkit to learn. If it works in a browser, it works in PyWry.
+PyWry renders standard HTML, CSS, and JavaScript inside OS-native webviews (WebView2 on Windows, WebKit on macOS/Linux) via [PyTauri](https://pytauri.github.io/pytauri/). Your team can use web skills they already have — no proprietary widget toolkit to learn. If it works in a browser, it works in PyWry.
-There are many ways to render web content from Python — Electron, Dash, Streamlit, NiceGUI, Gradio, Flet, or plain FastAPI. So why choose PyWry?
+## Write Once, Render Anywhere
-### The "Goldilocks" Framework
+PyWry's defining feature is that the same code renders in three environments without modification:
-Python developers often find themselves choosing between uncomfortable extremes:
+| Environment | Transport | How It Renders |
+|---|---|---|
+| Desktop terminal | PyTauri subprocess | Native OS webview window |
+| Jupyter / VS Code / Colab | Anywidget traitlets | Notebook cell widget (no server) |
+| Headless / SSH / Deploy | FastAPI + WebSocket | Browser tab via IFrame |
-- **Native GUI Toolkits (PyQt/Tkinter)**: Steep learning curves, custom styling systems, and they don't look modern without massive effort.
-- **Web-to-Desktop (Electron)**: Forces Python developers into the JavaScript/Node.js ecosystem and ships with hundreds of megabytes of Chromium bloat.
-- **Data Dashboards (Streamlit/Gradio)**: Excellent for rapid deployment in a browser, but highly opinionated, difficult to deeply customize, and hard to package as a true desktop executable.
+A Plotly chart, an AG Grid table, a TradingView financial chart, or a full chat interface — all render identically across these three paths. The same `on()`/`emit()` event protocol works in every environment, so components you build are portable by default.
-PyWry targets the sweet spot: **Write your logic in Python, build your UI with modern web technologies, and deploy it anywhere**—including as a native, lightweight executable.
+This pipeline is designed for data teams: prototype in a Jupyter notebook, share as a browser-based FastAPI application, and package as a standalone desktop executable with `pywry[freeze]` — all from the same Python code.
-### The Jupyter → Web → Desktop Pipeline
+## Built-In Integrations
-PyWry's most potent feature is its **"Build Once, Render Anywhere"** pipeline. Most frameworks support Web + Desktop, but PyWry is uniquely optimized for data science and full-stack environments.
+PyWry ships with production-ready integrations that implement industry-standard interfaces where they exist, so your code stays portable and your skills transfer.
-You can instantly render a Plotly chart or AgGrid table directly inside a **Jupyter Notebook** cell. When you're ready to share your work, you use the exact same code to deploy a browser-based FastAPI application. When you want to hand an internal tool to a business user, you use `pywry[freeze]` to compile that *same code* into a standalone `.exe` or `.app`—dropping the notebook or server entirely.
+### Plotly Charts
-### Lightweight Native Windows
+Interactive charts with automatic dark/light theming, pre-wired click/hover/selection/zoom events, programmatic updates, custom mode bar buttons that fire Python callbacks, and per-theme template overrides. Accepts standard Plotly `Figure` objects and figure dicts — the same data format used across the Plotly ecosystem.
-PyWry uses the **OS-native webview** (WebView2, WebKit) via [PyTauri](https://github.com/pytauri/pytauri) instead of bundling a full browser engine like Electron. This results in apps that add only a few megabytes of overhead and open in under a second. There's no server to spin up and no browser to launch.
+### AG Grid Tables
-### One API, three targets
+Sortable, filterable, editable data tables with automatic DataFrame conversion, cell editing callbacks, row selection events, and pagination. Configures through standard AG Grid `ColDef` and `GridOptions` structures — the same column definitions and grid options documented in the [AG Grid docs](https://www.ag-grid.com/javascript-data-grid/) work directly in PyWry.
-Write your interface once. PyWry automatically renders it in the right place without changing your code:
+### TradingView Financial Charts
-| Environment | Rendering Path |
-|---|---|
-| Desktop terminal | Native OS window via PyTauri |
-| Jupyter / VS Code / Colab | anywidget or inline IFrame |
-| Headless / SSH / Deploy | Browser tab via FastAPI + WebSocket |
+Full [TradingView Lightweight Charts](https://tradingview.github.io/lightweight-charts/) integration supporting three data modes:
+- **Static** — pass a DataFrame or list of OHLCV dicts for one-shot rendering
+- **Datafeed** — implement the `DatafeedProvider` interface for async on-demand bar loading, symbol resolution, and real-time subscriptions (follows TradingView's [Datafeed API](https://www.tradingview.com/charting-library-docs/latest/connecting_data/Datafeed-API/) contract)
+- **UDF** — `UDFAdapter` wraps any `DatafeedProvider` as a [Universal Data Feed](https://www.tradingview.com/charting-library-docs/latest/connecting_data/UDF/) HTTP endpoint compatible with TradingView's server-side data protocol
-### Built for data workflows
+Also includes drawing tools, technical indicators, persistent chart layouts (file or Redis storage), and session/timezone management.
-PyWry comes with built-in integrations tailored for data workflows:
+### AI Chat (ACP)
-- **Plotly charts** with pre-wired event callbacks (click, select, hover, zoom).
-- **AG Grid tables** with automatic DataFrame conversion and grid events.
-- **Toolbar system** with 18 declarative Pydantic input components across 7 layout positions to easily add headers, sidebars, and overlays.
-- **Two-way events** between Python and JavaScript, with no boilerplate.
+The chat system implements the [Agent Client Protocol (ACP)](https://agentclientprotocol.com) — an open standard for AI agent communication using JSON-RPC 2.0. This means:
+- **Provider interface** follows ACP's session lifecycle: `initialize` → `new_session` → `prompt` → `cancel`
+- **Session updates** use ACP's typed notification system: `agent_message`, `tool_call`, `plan`, `available_commands`, `config_option`, `current_mode`
+- **Content blocks** use ACP's content model: `text`, `image`, `audio`, `resource`, `resource_link`
+- **StdioProvider** connects to any ACP-compatible agent (Claude Code, Gemini CLI) over stdio JSON-RPC without writing adapter code
-### Production-ready
+Built-in providers for OpenAI, Anthropic, Magentic (100+ backends), [Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) (LangChain's agent harness with filesystem tools, planning, and subagents), and user-supplied callables adapt their respective APIs to the ACP session interface. Rich inline artifacts (code, markdown, tables, Plotly charts, TradingView charts, images, JSON trees) render directly in the chat transcript.
-PyWry scales from prototyping to multi-user deployments:
+### MCP Server
-- **Deploy Mode** with an optional Redis backend for horizontal scaling.
-- **OAuth2** authentication system for both native and deploy modes with enterprise RBAC.
-- **Security built-in**: Token authentication, CSRF protection, and CSP headers out of the box.
+A [Model Context Protocol](https://modelcontextprotocol.io/) server built on [FastMCP](https://github.com/jlowin/fastmcp) with 25+ tools that lets AI coding agents create and control PyWry widgets, send chat messages, manage chart data, and build interactive dashboards programmatically. MCP is the standard protocol used by Claude Code, Cursor, Windsurf, and other AI coding tools for tool integration.
-### Cross-platform
+### Toolbar System
-PyWry runs on Windows, macOS, and Linux. The same code produces native windows on all three platforms, notebook widgets in any Jupyter environment, and browser-based interfaces anywhere Python runs.
+18 declarative Pydantic input components (buttons, selects, toggles, sliders, text inputs, date pickers, search bars, secret inputs, radio groups, tab groups, marquees, and more) across 7 layout positions, all with automatic event wiring.
+## Lightweight Native Windows
-## Why not something else
+PyWry uses the OS-native webview via PyTauri instead of bundling a full browser engine like Electron. Apps add only a few megabytes of overhead and open in under a second. The PyTauri subprocess provides access to 19 Tauri plugins for native OS capabilities — clipboard, file dialogs, notifications, global shortcuts, system tray icons, and more.
-| Alternative | Trade-off |
-|---|---|
-| **NiceGUI** | Server + browser required natively; highly capable but lacks the single-codebase Jupyter → Desktop executable pipeline of PyWry. |
-| **Electron** | 150 MB+ runtime per app, requires Node.js/JavaScript context, difficult integration for native Python execution. |
-| **Dash / Streamlit / Gradio** | Opinionated UIs, browser-only deployment, not easily packagable into offline standalone executables. |
-| **Flet (Flutter/Python)** | Cannot use standard web libraries (React, Tailwind, AG Grid) as it relies entirely on Flutter's custom canvas rendering. |
-| **PyQt / Tkinter / wxPython** | Proprietary widget toolkits, requires learning custom desktop layout engines, lacks web interactivity features. |
-| **Plain FastAPI + HTML** | No native OS windows, no notebook support, requires manual WebSocket and event wiring. |
+## Unified Event Protocol
+
+All three rendering transports implement the same bidirectional event protocol:
+
+- **Python → JavaScript**: `widget.emit("app:update", {"count": 42})` updates the UI
+- **JavaScript → Python**: `pywry.emit("app:click", {x: 100})` fires a Python callback
+
+This means every component — whether it's a Plotly chart, an AG Grid table, a TradingView chart, a chat panel, or a custom HTML element — uses the same `on()`/`emit()` pattern. Build a component once and it works in native windows, notebooks, and browser tabs.
+
+## Production Ready
-PyWry sits in a unique position: native-quality lightweight desktop rendering, interactive Jupyter notebook support, and browser deployment, all from one Python API.
+PyWry scales from a single-user notebook to multi-user deployments:
+
+- **Three state backends**: in-memory (ephemeral), SQLite with encryption at rest (local persistent), and Redis (multi-worker distributed) — the same interfaces, queries, and RBAC work on all three
+- **SQLite audit trail**: tool call traces, generated artifacts, token usage stats, resource references, and skill activations persisted to an encrypted local database
+- **Deploy Mode** with a Redis backend for horizontal scaling across multiple Uvicorn workers
+- **OAuth2 authentication** with pluggable providers (Google, GitHub, Microsoft, generic OIDC) for both native and deploy modes
+- **Role-based access control** with viewer/editor/admin roles enforced across all ACP chat operations, file system access, and terminal control
+- **Security built-in**: per-widget token authentication, origin validation, CSP headers, secret input values never rendered in HTML, and SQLite databases encrypted at rest via SQLCipher
+
+## Cross Platform
+
+PyWry runs on Windows, macOS, and Linux. The same code produces native windows on all three platforms, notebook widgets in any Jupyter environment, and browser-based interfaces anywhere Python runs. The PyTauri binary ships as a vendored wheel — no Rust toolchain or system dependencies required.
+
+## Why Not Something Else
+
+| Alternative | What PyWry Adds |
+|---|---|
+| **Electron** | 150MB+ runtime, requires Node.js. PyWry uses the OS webview — a few MB, pure Python. |
+| **Dash / Streamlit / Gradio** | Browser-only, opinionated layouts, no desktop executables. PyWry renders in notebooks, browsers, and native windows from one codebase. |
+| **NiceGUI** | Server + browser required for native rendering. PyWry renders directly in the OS webview with no server for desktop mode. |
+| **Flet** | Flutter canvas rendering — cannot use standard web libraries (Plotly, AG Grid, TradingView). PyWry renders any HTML/CSS/JS. |
+| **PyQt / Tkinter** | Proprietary widget toolkits with custom layout engines. PyWry uses standard web technologies. |
+| **Plain FastAPI** | No native windows, no notebook rendering, no event system, no component library. PyWry provides all of these. |
-## Next steps
+None of these alternatives offer the combination of native desktop rendering, Jupyter notebook widgets, browser deployment, integrated AI chat with ACP protocol support, TradingView financial charting, and MCP agent tooling — all from one Python API with one event protocol.
-Ready to try it?
+## Next Steps
- [**Installation**](installation.md) — Install PyWry and platform dependencies
- [**Quick Start**](quickstart.md) — Build your first interface in 5 minutes
diff --git a/pywry/docs/docs/guides/app-show.md b/pywry/docs/docs/guides/app-show.md
index 90c1b6f..ae6f379 100644
--- a/pywry/docs/docs/guides/app-show.md
+++ b/pywry/docs/docs/guides/app-show.md
@@ -86,10 +86,11 @@ A dictionary mapping event names to Python callback functions. These are registe
```python
def on_click(data, event_type, label):
- print(f"Clicked: {data}")
+ selected_point = data.get("points", [{}])[0]
+ app.emit("pywry:set-content", {"id": "info", "text": f"x={selected_point.get('x')}"}, label)
def on_save(data, event_type, label):
- print("Saving...")
+ app.emit("pywry:download", {"filename": "data.json", "content": "{}"}, label)
app.show(html, callbacks={
"plotly:click": on_click,
diff --git a/pywry/docs/docs/guides/browser-mode.md b/pywry/docs/docs/guides/browser-mode.md
index c567a63..44514e2 100644
--- a/pywry/docs/docs/guides/browser-mode.md
+++ b/pywry/docs/docs/guides/browser-mode.md
@@ -107,8 +107,8 @@ h2 = app.show("
Table
", label="table")
# Two browser tabs open:
# http://127.0.0.1:8765/widget/chart
# http://127.0.0.1:8765/widget/table
-print(h1.url) # Full URL for the chart widget
-print(h2.url) # Full URL for the table widget
+chart_url = h1.url # e.g. http://127.0.0.1:8765/widget/chart
+table_url = h2.url # e.g. http://127.0.0.1:8765/widget/table
app.block()
```
diff --git a/pywry/docs/docs/guides/builder-options.md b/pywry/docs/docs/guides/builder-options.md
index cc69047..dcd1f45 100644
--- a/pywry/docs/docs/guides/builder-options.md
+++ b/pywry/docs/docs/guides/builder-options.md
@@ -157,12 +157,12 @@ The `builder_kwargs()` method returns a dict of only the non-default builder fie
from pywry.models import WindowConfig
config = WindowConfig(transparent=True, user_agent="test/1.0")
-print(config.builder_kwargs())
-# {'transparent': True, 'user_agent': 'test/1.0'}
+kwargs = config.builder_kwargs()
+# kwargs == {'transparent': True, 'user_agent': 'test/1.0'}
config2 = WindowConfig() # all defaults
-print(config2.builder_kwargs())
-# {}
+kwargs2 = config2.builder_kwargs()
+# kwargs2 == {} — only non-default values are included
```
This is used internally by the runtime to avoid sending unnecessary data over IPC.
diff --git a/pywry/docs/docs/guides/configuration.md b/pywry/docs/docs/guides/configuration.md
index 29b3060..e5c54ac 100644
--- a/pywry/docs/docs/guides/configuration.md
+++ b/pywry/docs/docs/guides/configuration.md
@@ -66,7 +66,8 @@ auto_start = true
websocket_require_token = true
[deploy]
-state_backend = "memory" # or "redis"
+state_backend = "memory" # "memory", "sqlite", or "redis"
+sqlite_path = "~/.config/pywry/pywry.db"
redis_url = "redis://localhost:6379/0"
```
@@ -194,6 +195,6 @@ pywry init
## Next Steps
- **[Configuration Reference](../reference/config.md)** — Complete `PyWrySettings` API
-- **[Tauri Plugins](../integrations/tauri-plugins.md)** — Enable clipboard, notifications, HTTP & more
+- **[Tauri Plugins](../integrations/pytauri/tauri-plugins.md)** — Enable clipboard, notifications, HTTP & more
- **[Deploy Mode](deploy-mode.md)** — Production server configuration
- **[Browser Mode](browser-mode.md)** — Server settings for browser mode
diff --git a/pywry/docs/docs/guides/deploy-mode.md b/pywry/docs/docs/guides/deploy-mode.md
index e1c3d02..7cd9304 100644
--- a/pywry/docs/docs/guides/deploy-mode.md
+++ b/pywry/docs/docs/guides/deploy-mode.md
@@ -128,7 +128,8 @@ Deploy mode is configured through environment variables (prefix `PYWRY_SERVER__`
| Setting | Default | Environment variable | Description |
|:---|:---|:---|:---|
-| State backend | `memory` | `PYWRY_DEPLOY__STATE_BACKEND` | `memory` or `redis` |
+| State backend | `memory` | `PYWRY_DEPLOY__STATE_BACKEND` | `memory`, `sqlite`, or `redis` |
+| SQLite path | `~/.config/pywry/pywry.db` | `PYWRY_DEPLOY__SQLITE_PATH` | Database file path (when backend is `sqlite`) |
| Redis URL | `redis://localhost:6379/0` | `PYWRY_DEPLOY__REDIS_URL` | Redis connection string |
| Redis prefix | `pywry` | `PYWRY_DEPLOY__REDIS_PREFIX` | Key namespace in Redis |
| Redis pool size | `10` | `PYWRY_DEPLOY__REDIS_POOL_SIZE` | Connection pool size (1–100) |
@@ -174,9 +175,9 @@ Redis key structure: `{prefix}:widget:{widget_id}` (hash), `{prefix}:widgets:act
```python
from pywry.state import is_deploy_mode, get_state_backend, get_worker_id
-print(f"Deploy mode: {is_deploy_mode()}")
-print(f"Backend: {get_state_backend().value}") # "memory" or "redis"
-print(f"Worker: {get_worker_id()}")
+deploy_active = is_deploy_mode() # True when PYWRY_DEPLOY__ENABLED=true
+backend = get_state_backend().value # "memory", "redis", or "sqlite"
+worker_id = get_worker_id() # Unique per-process identifier
```
Deploy mode is active when any of these are true:
diff --git a/pywry/docs/docs/guides/javascript-bridge.md b/pywry/docs/docs/guides/javascript-bridge.md
index 7ae4bc5..2b86f8d 100644
--- a/pywry/docs/docs/guides/javascript-bridge.md
+++ b/pywry/docs/docs/guides/javascript-bridge.md
@@ -33,7 +33,8 @@ In Python, register a callback for the event:
```python
def on_save(data, event_type, label):
- print(f"Saving ID {data['id']} from window {label}")
+ record_id = data["id"]
+ app.emit("pywry:set-content", {"id": "status", "text": f"Saved {record_id}"}, label)
handle = app.show(html, callbacks={"app:save": on_save})
```
diff --git a/pywry/docs/docs/guides/menus.md b/pywry/docs/docs/guides/menus.md
index 7abe64e..92f2617 100644
--- a/pywry/docs/docs/guides/menus.md
+++ b/pywry/docs/docs/guides/menus.md
@@ -35,11 +35,11 @@ app = PyWry()
# ── Define handlers FIRST ────────────────────────────────────────
def on_new(data, event_type, label):
- print("Creating new file…")
+ app.show(HtmlContent(html="
Untitled
"), title="New File")
def on_open(data, event_type, label):
- print("Opening file…")
+ app.emit("pywry:alert", {"message": "Open file dialog triggered"}, label)
def on_quit(data, event_type, label):
@@ -89,7 +89,7 @@ A normal clickable menu item. **`handler` is required.**
from pywry import MenuItemConfig
def on_save(data, event_type, label):
- print("Saving…")
+ app.emit("app:save", {"path": "current.json"}, label)
item = MenuItemConfig(
id="save", # Unique ID — sent in menu:click events
@@ -121,7 +121,7 @@ A toggle item with a check mark. **`handler` is required.**
from pywry import CheckMenuItemConfig
def on_bold(data, event_type, label):
- print("Bold toggled")
+ app.emit("editor:toggle-bold", {"checked": data.get("checked", False)}, label)
item = CheckMenuItemConfig(
id="bold",
@@ -149,7 +149,7 @@ A menu item with an icon (RGBA bytes or native OS icon). **`handler` is required
from pywry import IconMenuItemConfig
def on_doc(data, event_type, label):
- print("Document clicked")
+ app.emit("editor:format", {"style": data.get("id", "plain")}, label)
# With RGBA bytes
item = IconMenuItemConfig(
@@ -223,10 +223,10 @@ A nested container that holds other menu items.
from pywry import SubmenuConfig, MenuItemConfig
def on_zoom_in(data, event_type, label):
- print("Zoom in")
+ app.emit("view:zoom", {"direction": "in"}, label)
def on_zoom_out(data, event_type, label):
- print("Zoom out")
+ app.emit("view:zoom", {"direction": "out"}, label)
view_menu = SubmenuConfig(
id="view",
@@ -297,13 +297,13 @@ All mutations happen live — the native menu updates immediately.
```python
def on_export(data, event_type, label):
- print("Exporting…")
+ app.emit("app:export", {"format": "csv"}, label)
def on_import(data, event_type, label):
- print("Importing…")
+ app.emit("app:import", {"format": "csv"}, label)
def on_save_as(data, event_type, label):
- print("Save as…")
+ app.emit("app:save-as", {"path": ""}, label)
# Add items (handler required on new items)
menu.append(MenuItemConfig(id="export", text="Export", handler=on_export))
@@ -428,15 +428,15 @@ app = PyWry()
# ── Handlers ──────────────────────────────────────────────────────
def on_new(data, event_type, label):
- print("Creating new file…")
+ app.show(HtmlContent(html="
Untitled
"), title="New File")
def on_open(data, event_type, label):
- print("Opening file…")
+ app.emit("pywry:alert", {"message": "Open file dialog triggered"}, label)
def on_save(data, event_type, label):
- print("Saving…")
+ app.emit("app:save", {"path": "current.json"}, label)
def on_quit(data, event_type, label):
@@ -444,23 +444,23 @@ def on_quit(data, event_type, label):
def on_sidebar(data, event_type, label):
- print("Sidebar toggled")
+ app.emit("view:toggle-sidebar", {}, label)
def on_statusbar(data, event_type, label):
- print("Status bar toggled")
+ app.emit("view:toggle-statusbar", {}, label)
def on_zoom_in(data, event_type, label):
- print("Zoom in")
+ app.emit("view:zoom", {"direction": "in"}, label)
def on_zoom_out(data, event_type, label):
- print("Zoom out")
+ app.emit("view:zoom", {"direction": "out"}, label)
def on_zoom_reset(data, event_type, label):
- print("Zoom reset")
+ app.emit("view:zoom", {"direction": "reset"}, label)
# ── Menu structure ────────────────────────────────────────────────
diff --git a/pywry/docs/docs/integrations/multi-widget.md b/pywry/docs/docs/guides/multi-widget.md
similarity index 96%
rename from pywry/docs/docs/integrations/multi-widget.md
rename to pywry/docs/docs/guides/multi-widget.md
index f3ea996..80a12c1 100644
--- a/pywry/docs/docs/integrations/multi-widget.md
+++ b/pywry/docs/docs/guides/multi-widget.md
@@ -142,7 +142,7 @@ def on_export(_data, _event_type, _label):
widget.emit("pywry:download", {"content": df.to_csv(index=False), "filename": "data.csv", "mimeType": "text/csv"})
```
-See the [Event System guide](../guides/events.md) for the full list of system events (`pywry:set-content`, `pywry:download`, `plotly:update-figure`, `grid:update-data`, etc.).
+See the [Event System guide](events.md) for the full list of system events (`pywry:set-content`, `pywry:download`, `plotly:update-figure`, `grid:update-data`, etc.).
---
@@ -156,7 +156,7 @@ See [`examples/pywry_demo_multi_widget.py`](https://github.com/deeleeramone/PyWr
- [Toolbar System](../components/toolbar/index.md) — all toolbar component types and their APIs
- [Modals](../components/modal/index.md) — modal overlay components
-- [Event System](../guides/events.md) — event registration and dispatch
+- [Event System](events.md) — event registration and dispatch
- [Theming & CSS](../components/theming.md) — `--pywry-*` variables and theme switching
- [HtmlContent](../components/htmlcontent/index.md) — CSS files, script files, inline CSS, JSON data
- [Content Assembly](../components/htmlcontent/content-assembly.md) — what PyWry injects into the document
diff --git a/pywry/docs/docs/guides/oauth2.md b/pywry/docs/docs/guides/oauth2.md
index c69d2ad..9cc836c 100644
--- a/pywry/docs/docs/guides/oauth2.md
+++ b/pywry/docs/docs/guides/oauth2.md
@@ -176,12 +176,14 @@ app = PyWry()
try:
result = app.login() # blocks — see "User experience" below
+ access_token = result.access_token
+ # Proceed with authenticated app.show(...) using access_token
except AuthFlowTimeout:
- print("User took too long to authenticate")
+ app.show("
")
```
After a successful login:
diff --git a/pywry/docs/docs/guides/state-and-auth.md b/pywry/docs/docs/guides/state-and-auth.md
index c081503..53a07c2 100644
--- a/pywry/docs/docs/guides/state-and-auth.md
+++ b/pywry/docs/docs/guides/state-and-auth.md
@@ -14,7 +14,7 @@ The state layer is made of four pluggable stores and a callback registry:
| **SessionStore** | User sessions, roles, and permissions |
| **CallbackRegistry** | Python callback dispatch (always local) |
-Each store has two implementations — `Memory*` for single-process use and `Redis*` for multi-worker deployments. A factory layer auto-selects the right one based on configuration.
+Each store has three implementations — `Memory*` for ephemeral single-process use, `Sqlite*` for persistent local storage with encryption, and `Redis*` for multi-worker deployments. A factory layer auto-selects the right one based on configuration.
## State Backends
@@ -29,6 +29,27 @@ store = get_widget_store() # MemoryWidgetStore
sessions = get_session_store() # MemorySessionStore
```
+### SQLite (local persistent)
+
+Persists all state to an encrypted SQLite database file. Data survives process restarts without requiring an external server. The database is encrypted at rest using SQLCipher when available, with keys managed through the OS keyring.
+
+```bash
+export PYWRY_DEPLOY__STATE_BACKEND=sqlite
+export PYWRY_DEPLOY__SQLITE_PATH=~/.config/pywry/pywry.db
+```
+
+The SQLite backend includes a `ChatStore` with audit trail extensions not available in the Memory or Redis backends:
+
+- **Tool call logging** — every tool invocation with arguments, result, timing, and error status
+- **Artifact logging** — generated code blocks, charts, tables, and other artifacts
+- **Token usage tracking** — prompt tokens, completion tokens, total tokens, and cost per message
+- **Resource references** — URIs, MIME types, and sizes of files the agent read or produced
+- **Skill activations** — which skills were loaded during a conversation
+- **Full-text search** — search across all message content with `search_messages()`
+- **Cost aggregation** — `get_usage_stats()` and `get_total_cost()` across threads or widgets
+
+On first initialization, the SQLite backend auto-creates a default admin session (`session_id="local"`, `user_id="admin"`, `roles=["admin"]`) and seeds the standard role permission table. This means RBAC works identically to deploy mode — the same `check_permission()` calls, the same role hierarchy — with one permanent admin user.
+
### Redis (production)
Enables horizontal scaling across multiple workers/processes. Widgets registered by one worker are visible to all others. Events published on one worker are received by subscribers on every worker.
@@ -56,7 +77,8 @@ All settings are controlled via `DeploySettings` and read from environment varia
| Variable | Default | Description |
|:---|:---|:---|
-| `STATE_BACKEND` | `memory` | `memory` or `redis` |
+| `STATE_BACKEND` | `memory` | `memory`, `sqlite`, or `redis` |
+| `SQLITE_PATH` | `~/.config/pywry/pywry.db` | Path to SQLite database file |
| `REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL |
| `REDIS_PREFIX` | `pywry` | Key namespace prefix |
| `REDIS_POOL_SIZE` | `10` | Connection pool size (1–100) |
diff --git a/pywry/docs/docs/guides/window-management.md b/pywry/docs/docs/guides/window-management.md
index 0939988..c3a2c3d 100644
--- a/pywry/docs/docs/guides/window-management.md
+++ b/pywry/docs/docs/guides/window-management.md
@@ -108,7 +108,7 @@ handle = app.show(content, label="dashboard")
# Auto-generated (UUID)
handle = app.show(content)
-print(handle.label) # e.g., "a3f1c2d4-..."
+window_label = handle.label # e.g., "a3f1c2d4-..." — used to route events to this window
```
Labels are used to:
diff --git a/pywry/docs/docs/integrations/aggrid/index.md b/pywry/docs/docs/integrations/aggrid/index.md
index 22f7df6..466d857 100644
--- a/pywry/docs/docs/integrations/aggrid/index.md
+++ b/pywry/docs/docs/integrations/aggrid/index.md
@@ -1,10 +1,22 @@
-# AG Grid Tables
+# AG Grid
-PyWry provides first-class AG Grid support — pass a Pandas DataFrame to `show_dataframe()` and get sortable, filterable, editable data tables with pre-wired events.
+PyWry integrates [AG Grid](https://www.ag-grid.com/) — a high-performance JavaScript data grid — to render interactive tables with sorting, filtering, column resizing, row selection, cell editing, and pagination. The integration handles all data serialization, event bridging, and theme synchronization automatically.
-For the complete column and grid configuration API, see the [Grid Reference](grid.md). For all grid events and payloads, see the [Event Reference](../../reference/events/grid.md).
+AG Grid runs entirely in the browser. PyWry's role is to serialize your Python data (DataFrames, dicts, lists) into AG Grid's JSON format, inject the AG Grid library into the page, wire up events so user interactions flow back to Python, and keep the grid's theme in sync with PyWry's dark/light mode.
-## Basic Usage
+## How It Works
+
+1. You pass a DataFrame (or list of dicts) to `show_dataframe()` or `TableArtifact`
+2. PyWry calls `normalize_data()` which converts the data to `{rowData, columns, columnTypes}` — the format AG Grid expects
+3. The AG Grid JavaScript library (~200KB gzipped) is injected into the page
+4. `aggrid-defaults.js` registers event listeners on the grid instance that call `pywry.emit()` when the user clicks, selects, or edits cells
+5. Your Python callbacks receive these events through the same `on()`/`emit()` protocol used by all PyWry components
+
+The grid renders in all three environments — native windows, notebooks (anywidget or IFrame), and browser tabs — using the same code.
+
+## Displaying a Grid
+
+### From a DataFrame
```python
import pandas as pd
@@ -13,72 +25,173 @@ from pywry import PyWry
app = PyWry()
df = pd.DataFrame({
- "Name": ["Alice", "Bob", "Charlie"],
- "Age": [25, 30, 35],
- "City": ["NYC", "LA", "Chicago"],
+ "Symbol": ["AAPL", "MSFT", "GOOGL", "AMZN"],
+ "Price": [189.84, 425.22, 176.49, 185.07],
+ "Change": [1.23, -0.45, 0.89, -2.10],
+ "Volume": [52_340_000, 18_920_000, 21_150_000, 45_670_000],
})
-# Display the grid
handle = app.show_dataframe(df)
```
+### From a List of Dicts
+
+```python
+data = [
+ {"name": "Alice", "role": "Engineer", "level": 3},
+ {"name": "Bob", "role": "Designer", "level": 2},
+]
+
+handle = app.show_dataframe(data)
+```
+
+### Inside a Chat Response
+
+```python
+from pywry.chat.artifacts import TableArtifact
+
+def handler(messages, ctx):
+ yield TableArtifact(
+ title="Portfolio",
+ data=portfolio_df,
+ height="320px",
+ )
+```
+
+The AG Grid library is loaded lazily — it's only injected when the first grid is rendered.
+
+## Data Normalization
+
+`normalize_data()` accepts several input formats and converts them all to AG Grid's expected structure:
+
+| Input | Example | Result |
+|-------|---------|--------|
+| pandas DataFrame | `pd.DataFrame({"a": [1, 2]})` | Columns from DataFrame columns, types auto-detected |
+| List of dicts | `[{"a": 1}, {"a": 2}]` | Columns from dict keys, types inferred from values |
+| Dict of lists | `{"a": [1, 2], "b": [3, 4]}` | Columns from dict keys |
+| Single dict | `{"a": 1, "b": 2}` | Rendered as a two-column key/value table |
+
+The normalizer also detects column types (`number`, `text`, `date`, `boolean`) and applies appropriate formatting defaults.
+
## Column Configuration
-Use `ColDef` for detailed column configuration:
+`ColDef` controls how individual columns render and behave:
```python
from pywry.grid import ColDef
columns = [
- ColDef(field="name", header_name="Full Name", sortable=True, filter=True),
- ColDef(field="age", header_name="Age", width=100, cell_data_type="number"),
- ColDef(field="salary", value_formatter="'$' + value.toLocaleString()"),
- ColDef(field="active", editable=True, cell_renderer="agCheckboxCellRenderer"),
+ ColDef(
+ field="symbol",
+ header_name="Ticker",
+ sortable=True,
+ filter=True,
+ pinned="left",
+ width=100,
+ ),
+ ColDef(
+ field="price",
+ header_name="Price",
+ cell_data_type="number",
+ value_formatter="'$' + value.toFixed(2)",
+ ),
+ ColDef(
+ field="change",
+ header_name="Change",
+ cell_data_type="number",
+ cell_style={"color": "params.value >= 0 ? '#a6e3a1' : '#f38ba8'"},
+ ),
+ ColDef(
+ field="volume",
+ header_name="Volume",
+ value_formatter="value.toLocaleString()",
+ ),
+ ColDef(
+ field="active",
+ header_name="Active",
+ editable=True,
+ cell_renderer="agCheckboxCellRenderer",
+ ),
]
handle = app.show_dataframe(df, column_defs=columns)
```
-For the full list of `ColDef` properties, see the [Grid Reference](grid.md).
+Key `ColDef` fields:
+
+| Field | Type | Effect |
+|-------|------|--------|
+| `field` | `str` | Column key in the data |
+| `header_name` | `str` | Display name in the header |
+| `sortable` | `bool` | Allow clicking header to sort |
+| `filter` | `bool` or `str` | Enable column filter (`True` for auto, or `"agTextColumnFilter"`, `"agNumberColumnFilter"`, etc.) |
+| `editable` | `bool` | Allow inline cell editing |
+| `width` | `int` | Fixed column width in pixels |
+| `pinned` | `str` | Pin column to `"left"` or `"right"` |
+| `cell_data_type` | `str` | `"number"`, `"text"`, `"date"`, `"boolean"` |
+| `value_formatter` | `str` | JavaScript expression for display formatting |
+| `cell_style` | `dict` | Conditional CSS styles |
+| `cell_renderer` | `str` | AG Grid cell renderer component name |
+
+For the complete list, see the [Grid Reference](grid.md).
## Grid Options
-Use `GridOptions` for global grid configuration:
+`GridOptions` controls grid-level behavior:
```python
-from pywry.grid import GridOptions, RowSelection
+from pywry.grid import GridOptions
options = GridOptions(
pagination=True,
pagination_page_size=25,
row_selection={"mode": "multiRow", "enableClickSelection": True},
animate_rows=True,
+ suppress_column_virtualisation=True,
)
handle = app.show_dataframe(df, grid_options=options)
```
-For the full list of `GridOptions` properties, see the [Grid Reference](grid.md).
+Key `GridOptions` fields:
+
+| Field | Type | Effect |
+|-------|------|--------|
+| `pagination` | `bool` | Enable pagination |
+| `pagination_page_size` | `int` | Rows per page |
+| `row_selection` | `dict` | Selection mode configuration |
+| `animate_rows` | `bool` | Animate row additions/removals |
+| `default_col_def` | `dict` | Default properties for all columns |
+| `suppress_column_virtualisation` | `bool` | Render all columns (not just visible ones) |
## Grid Events
-AgGrid emits events for user interactions:
+AG Grid interactions produce events that your Python callbacks receive through the standard `on()`/`emit()` protocol:
```python
def on_row_selected(data, event_type, label):
- rows = data.get("rows", [])
- app.emit("pywry:alert", {"message": f"Selected {len(rows)} rows"}, label)
+ selected_rows = data.get("rows", [])
+ symbols = [r["Symbol"] for r in selected_rows]
+ app.emit("pywry:set-content", {
+ "id": "selection",
+ "text": f"Selected: {', '.join(symbols)}",
+ }, label)
def on_cell_click(data, event_type, label):
+ col = data["colId"]
+ value = data["value"]
+ row_index = data["rowIndex"]
app.emit("pywry:set-content", {
- "id": "status",
- "text": f"{data['colId']} = {data['value']}"
+ "id": "detail",
+ "text": f"Row {row_index}: {col} = {value}",
}, label)
def on_cell_edit(data, event_type, label):
- app.emit("pywry:alert", {
- "message": f"Edited {data['colId']}: {data['oldValue']} → {data['newValue']}"
- }, label)
+ col = data["colId"]
+ old_val = data["oldValue"]
+ new_val = data["newValue"]
+ row_data = data["data"]
+ save_edit_to_database(row_data, col, new_val)
handle = app.show_dataframe(
df,
@@ -90,28 +203,55 @@ handle = app.show_dataframe(
)
```
-For the complete list of grid events and payload structures, see the [Event Reference](../../reference/events/grid.md).
+Available grid events:
+
+| Event | Payload Fields | When It Fires |
+|-------|---------------|---------------|
+| `grid:cell-click` | `colId`, `value`, `rowIndex`, `data` | User clicks a cell |
+| `grid:cell-double-click` | `colId`, `value`, `rowIndex`, `data` | User double-clicks a cell |
+| `grid:cell-edit` | `colId`, `oldValue`, `newValue`, `data` | User finishes editing a cell |
+| `grid:row-selected` | `rows` (list of selected row dicts) | Row selection changes |
+| `grid:sort-changed` | `columns` (list of sort state dicts) | User changes sort order |
+| `grid:filter-changed` | `filterModel` (AG Grid filter model dict) | User changes column filters |
+
+For complete payload structures, see the [Event Reference](../../reference/events/grid.md).
## Updating Grid Data
-### Replace All Data
+After the grid is displayed, update its data from Python:
```python
-new_df = pd.DataFrame({...})
-handle.emit("grid:update-data", {"data": new_df.to_dict("records")})
+new_data = fetch_latest_prices()
+handle.emit("grid:update-data", {"data": new_data})
```
+The grid re-renders with the new data while preserving sort, filter, and selection state.
+
## Themes
-Available AG Grid themes:
+AG Grid themes match PyWry's dark/light mode automatically:
```python
-handle = app.show_dataframe(df, aggrid_theme="alpine") # default
+handle = app.show_dataframe(df, aggrid_theme="alpine") # default
handle = app.show_dataframe(df, aggrid_theme="balham")
+handle = app.show_dataframe(df, aggrid_theme="quartz")
handle = app.show_dataframe(df, aggrid_theme="material")
```
-Themes automatically adapt to PyWry's light/dark mode.
+When the user switches PyWry's theme (via `pywry:update-theme`), the grid's CSS class is updated automatically — `ag-theme-alpine-dark` ↔ `ag-theme-alpine`.
+
+## Embedding in Multi-Widget Pages
+
+To place a grid alongside other components (charts, toolbars, etc.), generate the grid HTML directly:
+
+```python
+from pywry.grid import build_grid_config, build_grid_html
+
+config = build_grid_config(df, grid_id="portfolio-grid", row_selection=True)
+grid_html = build_grid_html(config)
+```
+
+Then compose it with `Div` and other components. The `grid_id` parameter lets you target the specific grid with events when multiple grids share a page. See [Multi-Widget Composition](../../guides/multi-widget.md) for the full pattern.
## With Toolbars
@@ -121,18 +261,19 @@ from pywry import Toolbar, Button, TextInput
toolbar = Toolbar(
position="top",
items=[
- TextInput(event="grid:search", label="Search", placeholder="Filter..."),
+ TextInput(event="grid:search", label="Search", placeholder="Filter rows..."),
Button(event="grid:export", label="Export CSV"),
],
)
def on_search(data, event_type, label):
- query = data.get("value", "")
- # Filter logic here
+ query = data.get("value", "").lower()
+ filtered = df[df.apply(lambda r: query in str(r.values).lower(), axis=1)]
+ handle.emit("grid:update-data", {"data": filtered.to_dict("records")})
def on_export(data, event_type, label):
handle.emit("pywry:download", {
- "filename": "data.csv",
+ "filename": "portfolio.csv",
"content": df.to_csv(index=False),
"mimeType": "text/csv",
})
@@ -149,7 +290,7 @@ handle = app.show_dataframe(
## Next Steps
-- **[Grid Reference](grid.md)** — Full `ColDef`, `GridOptions` API
+- **[Grid Reference](grid.md)** — Complete `ColDef`, `ColGroupDef`, `DefaultColDef`, `GridOptions` API
- **[Event Reference](../../reference/events/grid.md)** — All grid event payloads
-- **[Toolbar System](../../components/toolbar/index.md)** — Building interactive controls
-- **[Theming & CSS](../../components/theming.md)** — Styling the grid
+- **[Multi-Widget Composition](../../guides/multi-widget.md)** — Embedding grids in dashboards
+- **[Theming & CSS](../../components/theming.md)** — Styling and theme variables
diff --git a/pywry/docs/docs/integrations/anywidget.md b/pywry/docs/docs/integrations/anywidget.md
index 8533463..ba675d9 100644
--- a/pywry/docs/docs/integrations/anywidget.md
+++ b/pywry/docs/docs/integrations/anywidget.md
@@ -1,159 +1,269 @@
-# Anywidget & Widget Protocol
+# Anywidget Transport
-PyWry supports three rendering paths — native desktop windows, anywidget-based Jupyter widgets, and IFrame + FastAPI server. All three implement the same `BaseWidget` protocol, so your application code works identically regardless of environment.
+PyWry's event system uses a unified protocol — `on()`, `emit()`, `update()`, `display()` — that works identically across native windows, IFrame+WebSocket, and anywidget. This page explains how that protocol is implemented over the anywidget transport, so you can build reusable components or introduce new integrations that work seamlessly in all three environments.
-For the protocol and widget API reference, see [`BaseWidget`](../reference/widget-protocol.md), [`PyWryWidget`](../reference/widget.md), and [`InlineWidget`](../reference/inline-widget.md).
+For the IFrame+WebSocket transport, see [IFrame + WebSocket Transport](../inline-widget/index.md).
-## Rendering Path Auto-Detection
+## The Unified Protocol
-`PyWry.show()` automatically selects the best rendering path:
+Every PyWry widget — regardless of rendering path — implements `BaseWidget`:
-```
-Script / Terminal ──→ Native OS window (PyTauri subprocess)
-Notebook + anywidget ──→ PyWryWidget (traitlet sync, no server)
-Notebook + Plotly/Grid ──→ InlineWidget (FastAPI + IFrame)
-Notebook without anywidget ──→ InlineWidget (FastAPI fallback)
-Browser / SSH / headless ──→ InlineWidget (opens system browser)
+```python
+class BaseWidget(Protocol):
+ def on(self, event_type: str, callback: Callable[[dict, str, str], Any]) -> BaseWidget: ...
+ def emit(self, event_type: str, data: dict[str, Any]) -> None: ...
+ def update(self, html: str) -> None: ...
+ def display(self) -> None: ...
```
-No configuration needed — the right path is chosen at `show()` time.
+A reusable component only calls these four methods. It never knows whether it's running in a native window, a notebook widget, or a browser tab. The transport handles everything else.
-## The BaseWidget Protocol
+## How Anywidget Implements the Protocol
-Every rendering backend implements this protocol:
+In anywidget mode, `PyWryWidget` extends `anywidget.AnyWidget` and implements `BaseWidget` by mapping each method to traitlet synchronization:
-```python
-from pywry.widget_protocol import BaseWidget
+| BaseWidget Method | Anywidget Implementation |
+|-------------------|--------------------------|
+| `emit(type, data)` | Serialize `{type, data, ts}` to JSON → set `_py_event` traitlet → `send_state()` |
+| `on(type, callback)` | Store callback in `_handlers[type]` dict → `_handle_js_event` observer dispatches |
+| `update(html)` | Set `content` traitlet → JS `model.on('change:content')` re-renders |
+| `display()` | Call `IPython.display.display(self)` |
-def use_widget(widget: BaseWidget):
- # Register a JS → Python event handler
- widget.on("app:click", lambda data, event_type, label: print(data))
+### Traitlets
- # Send a Python → JS event
- widget.emit("app:update", {"key": "value"})
+Six traitlets carry all state between Python and JavaScript:
- # Replace the widget HTML
- widget.update("
New Content
")
+| Traitlet | Direction | Purpose |
+|----------|-----------|---------|
+| `content` | Python → JS | HTML markup to render |
+| `theme` | Bidirectional | `"dark"` or `"light"` |
+| `width` | Python → JS | CSS width |
+| `height` | Python → JS | CSS height |
+| `_js_event` | JS → Python | Serialized event from browser |
+| `_py_event` | Python → JS | Serialized event from Python |
- # Show the widget in the current context
- widget.display()
-```
+### Event Wire Format
-| Method | Description |
-|--------|-------------|
-| `on(event_type, callback)` | Register callback for JS → Python events. Callback receives `(data, event_type, label)`. Returns self for chaining. |
-| `emit(event_type, data)` | Send Python → JS event with a JSON-serializable payload. |
-| `update(html)` | Replace the widget's HTML content. |
-| `display()` | Show the widget (native window, notebook cell, or browser tab). |
+Both `_js_event` and `_py_event` carry JSON strings:
-## Level 1: PyWryWidget (anywidget)
+```json
+{"type": "namespace:event-name", "data": {"key": "value"}, "ts": "unique-id"}
+```
-The best notebook experience. Uses anywidget's traitlet sync — no server needed, instant bidirectional communication through the Jupyter kernel.
+The `ts` field ensures every event is a unique traitlet value. Jupyter only syncs on change — identical consecutive events would be dropped without unique timestamps.
-**Requirements:** `pip install anywidget traitlets`
+### JS → Python Path
-```python
-from pywry import PyWry
+```
+pywry.emit("form:submit", {name: "x"})
+ → JSON.stringify({type: "form:submit", data: {name: "x"}, ts: Date.now()})
+ → model.set("_js_event", json_string)
+ → model.save_changes()
+ → Jupyter kernel syncs traitlet
+ → Python observer _handle_js_event fires
+ → json.loads(change["new"])
+ → callback(data, "form:submit", widget_label)
+```
-app = PyWry()
-widget = app.show("
Hello from anywidget!
")
+### Python → JS Path
-# Events work identically
-widget.on("app:ready", lambda d, e, l: print("Widget ready"))
-widget.emit("app:update", {"count": 42})
+```
+widget.emit("pywry:set-content", {"id": "status", "text": "Done"})
+ → json.dumps({"type": ..., "data": ..., "ts": uuid.hex})
+ → self._py_event = json_string
+ → self.send_state("_py_event")
+ → Jupyter kernel syncs traitlet
+ → JS model.on("change:_py_event") fires
+ → JSON.parse(model.get("_py_event"))
+ → pywry._fire(type, data)
+ → registered on() listeners execute
```
-**How it works:**
-
-1. Python creates a `PyWryWidget` (extends `anywidget.AnyWidget`)
-2. An ESM module is bundled as the widget frontend
-3. Traitlets (`content`, `theme`, `_js_event`, `_py_event`) sync bidirectionally via Jupyter comms
-4. `widget.emit()` → sets `_py_event` traitlet → JS receives change → dispatches to JS listeners
-5. JS `pywry.emit()` → sets `_js_event` traitlet → Python `_handle_js_event()` → dispatches to callbacks
+## The ESM Render Function
+
+The widget frontend is an ESM module with a `render({model, el})` function. This function must:
+
+1. Create a `.pywry-widget` container div inside `el`
+2. Render `model.get("content")` as innerHTML
+3. Create a local `pywry` bridge object with `emit()`, `on()`, and `_fire()`
+4. Also set `window.pywry` for HTML `onclick` handlers to access
+5. Listen for `change:_py_event` and dispatch to `pywry._fire()`
+6. Listen for `change:content` and re-render
+7. Listen for `change:theme` and update CSS classes
+
+The `pywry` bridge in the ESM implements the JavaScript side of the protocol:
+
+```javascript
+const pywry = {
+ _handlers: {},
+ emit: function(type, data) {
+ // Write to _js_event traitlet → triggers Python observer
+ model.set('_js_event', JSON.stringify({type, data: data || {}, ts: Date.now()}));
+ model.save_changes();
+ // Also dispatch locally so JS listeners fire immediately
+ this._fire(type, data || {});
+ },
+ on: function(type, callback) {
+ if (!this._handlers[type]) this._handlers[type] = [];
+ this._handlers[type].push(callback);
+ },
+ _fire: function(type, data) {
+ (this._handlers[type] || []).forEach(function(h) { h(data); });
+ }
+};
+```
-**When it's used:** Notebook environment + anywidget installed + no Plotly/AG Grid/TradingView content.
+## Building a Reusable Component
-## Level 2: InlineWidget (IFrame + FastAPI)
+A reusable component is a Python class that takes a `BaseWidget` and registers event handlers. Because it only calls `on()` and `emit()`, it works on all three rendering paths without modification.
-Used for Plotly, AG Grid, and TradingView content in notebooks, or when anywidget isn't installed. Starts a local FastAPI server and renders via an IFrame.
+### Python Side: State Mixin Pattern
-**Requirements:** `pip install fastapi uvicorn`
+PyWry's built-in components (`GridStateMixin`, `PlotlyStateMixin`, `ChatStateMixin`, `ToolbarStateMixin`) all follow the same pattern — they inherit from `EmittingWidget` and call `self.emit()`:
```python
-from pywry import PyWry
+from pywry.state_mixins import EmittingWidget
-app = PyWry()
-# Plotly/Grid/TradingView automatically use InlineWidget
-handle = app.show_plotly(fig)
-handle = app.show_dataframe(df)
-handle = app.show_tvchart(ohlcv_data)
-```
+class CounterMixin(EmittingWidget):
+ """Adds a counter widget that syncs between Python and JavaScript."""
-**How it works:**
+ def increment(self, amount: int = 1):
+ self.emit("counter:increment", {"amount": amount})
-1. A singleton FastAPI server starts in a background thread (one per kernel)
-2. Each widget gets a URL (`/widget/{widget_id}`) and a WebSocket (`/ws/{widget_id}`)
-3. An IFrame in the notebook cell points to the widget URL
-4. `widget.emit()` → enqueues event → WebSocket send loop pushes to browser
-5. JS `pywry.emit()` → sends over WebSocket → FastAPI handler dispatches to Python callbacks
+ def reset(self):
+ self.emit("counter:reset", {})
-**Multiple widgets share one server** — efficient for dashboards with many components.
+ def set_value(self, value: int):
+ self.emit("counter:set", {"value": value})
+```
-**Browser-only mode:**
+Any widget class that mixes this in and provides `emit()` gets counter functionality:
```python
-widget = app.show("
Dashboard
")
-widget.open_in_browser() # Opens system browser instead of notebook
+class MyWidget(PyWryWidget, CounterMixin):
+ pass
+
+widget = MyWidget(content=counter_html)
+widget.increment(5) # Works in notebooks (anywidget traitlets)
+widget.reset() # Works in browser (WebSocket)
+ # Works in native windows (Tauri IPC)
```
-## Level 3: NativeWindowHandle (Desktop)
+### JavaScript Side: Event Handlers
-Used in scripts and terminals. The PyTauri subprocess manages native OS webview windows.
+The JavaScript side registers listeners through `pywry.on()` — this works identically in all rendering paths because every transport creates the same `pywry` bridge object:
-```python
-from pywry import PyWry
+```javascript
+// This code works in ESM (anywidget), ws-bridge.js (IFrame), and bridge.js (native)
+pywry.on('counter:increment', function(data) {
+ var el = document.getElementById('counter-value');
+ var current = parseInt(el.textContent) || 0;
+ el.textContent = current + data.amount;
+});
+
+pywry.on('counter:reset', function() {
+ document.getElementById('counter-value').textContent = '0';
+});
+
+pywry.on('counter:set', function(data) {
+ document.getElementById('counter-value').textContent = data.value;
+});
+
+// User clicks emit events back to Python — same pywry.emit() everywhere
+document.getElementById('inc-btn').onclick = function() {
+ pywry.emit('counter:clicked', {action: 'increment'});
+};
+```
+
+### Wiring It Together
-app = PyWry(title="My App", width=800, height=600)
-handle = app.show("
Native Window
")
+To use the component with `ChatManager`, `app.show()`, or any other entry point:
+
+```python
+from pywry import HtmlContent, PyWry
-# Same API as notebook widgets
-handle.on("app:click", lambda d, e, l: print("Clicked!", d))
-handle.emit("app:update", {"status": "ready"})
+app = PyWry()
-# Additional native-only features
-handle.close()
-handle.hide()
-handle.eval_js("document.title = 'Updated'")
-print(handle.label) # Window label
+counter_html = """
+
+
0
+
+
+
+
+"""
+
+def on_counter_click(data, event_type, label):
+ if data["action"] == "increment":
+ app.emit("counter:increment", {"amount": 1}, label)
+ elif data["action"] == "reset":
+ app.emit("counter:reset", {}, label)
+
+widget = app.show(
+ HtmlContent(html=counter_html),
+ callbacks={"counter:clicked": on_counter_click},
+)
```
-**How it works:** JSON-over-stdin/stdout IPC to the PyTauri Rust subprocess. The subprocess manages the OS webview (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux).
+This works in native windows, notebooks with anywidget, notebooks with IFrame fallback, and browser mode — the same HTML, the same callbacks, the same `pywry.emit()`/`pywry.on()` contract.
+
+## Specialized Widget Subclasses
+
+When a component needs its own bundled JavaScript library (like Plotly, AG Grid, or TradingView), it defines a widget subclass with a custom `_esm`:
+
+| Subclass | Mixin | Bundled Library | Extra Traitlets |
+|----------|-------|-----------------|-----------------|
+| `PyWryWidget` | `EmittingWidget` | Base bridge only | — |
+| `PyWryPlotlyWidget` | `PlotlyStateMixin` | Plotly.js | `figure_json`, `chart_id` |
+| `PyWryAgGridWidget` | `GridStateMixin` | AG Grid | `grid_config`, `grid_id`, `aggrid_theme` |
+| `PyWryChatWidget` | `ChatStateMixin` | Chat handlers | `_asset_js`, `_asset_css` |
+| `PyWryTVChartWidget` | `TVChartStateMixin` | Lightweight-charts | `chart_config`, `chart_id` |
-## Writing Portable Code
+Each subclass overrides `_esm` with an ESM module that includes both the library code and the domain-specific event handlers. The extra traitlets carry domain state (chart data, grid config, etc.) alongside the standard `content`/`theme`/`_js_event`/`_py_event` protocol.
-Since all three backends share the `BaseWidget` protocol, write code against the protocol:
+### Lazy Asset Loading
+
+`PyWryChatWidget` uses two additional traitlets — `_asset_js` and `_asset_css` — for on-demand library loading. When `ChatManager` first encounters a `PlotlyArtifact`, it pushes the Plotly library source through `_asset_js`:
```python
-def setup_dashboard(widget):
- """Works with any widget type."""
- widget.on("app:ready", lambda d, e, l: print("Ready in", l))
- widget.on("app:click", handle_click)
- widget.emit("app:config", {"theme": "dark"})
-
-# Works everywhere
-handle = app.show(my_html)
-setup_dashboard(handle)
+# ChatManager detects anywidget and uses trait instead of HTTP
+self._widget.set_trait("_asset_js", plotly_source_code)
+```
+
+The ESM listens for the trait change and injects the code:
+
+```javascript
+model.on("change:_asset_js", function() {
+ var js = model.get("_asset_js");
+ if (js) {
+ var script = document.createElement("script");
+ script.textContent = js;
+ document.head.appendChild(script);
+ }
+});
```
-## Fallback Behavior
+This replaces the `chat:load-assets` HTTP-based injection used in the IFrame transport, keeping the protocol uniform while adapting to the transport's capabilities.
+
+## Transport Comparison
-If `anywidget` is not installed, `PyWryWidget` becomes a stub that shows an error message with install instructions. The `InlineWidget` fallback handles all notebook rendering in that case.
+| Aspect | Anywidget | IFrame+WebSocket | Native Window |
+|--------|-----------|------------------|---------------|
+| `pywry.emit()` | Traitlet `_js_event` | WebSocket send | Tauri IPC `pyInvoke` |
+| `pywry.on()` | Local handler dict | Local handler dict | Local handler dict |
+| Python `emit()` | Traitlet `_py_event` | Async queue → WS send | Tauri event emit |
+| Python `on()` | Traitlet observer | Callback dict lookup | Callback dict lookup |
+| Asset loading | Bundled in `_esm` or `_asset_js` trait | HTTP `
+
+ {plotly.js, ag-grid.js, etc. if needed}
+ {toolbar handler scripts if toolbars present}
+
+
+