Skip to content

panel-live-server

panel_live_server

Panel Live Server - Execute and visualize Python code snippets.

__all__ = ['__version__'] module-attribute

__version__ = importlib.metadata.version('panel-live-server') module-attribute

app

Panel server for code visualization.

This module implements a Panel web server that executes Python code and displays the results through various endpoints.

config = get_config() module-attribute

logger = logging.getLogger(__name__) module-attribute

main(address='localhost', port=5077, show=True)

Start the Panel server.

cli

CLI for Panel Live Server.

app = typer.Typer(name='pls', help='Panel Live Server - Execute and visualize Python code snippets.', add_completion=False) module-attribute

list_app = typer.Typer(help='List resources (packages, etc.).') module-attribute

logger = logging.getLogger(__name__) module-attribute

install_browser()

Download the Chromium browser the screenshot MCP tool needs.

Playwright ships its browser binary separately from the Python package, so a pip or uv install does not fetch it automatically. Run this once after installing (pixi users get it via pixi run postinstall). It lands in the same environment that runs pls.

list_packages(filter=typer.Argument('', help='Optional substring to filter package names (case-insensitive).', show_default=False))

List all Python packages installed in the current environment.

Optionally filter by a substring, e.g. pls list packages panel to show only packages whose name contains "panel".

main()

Entry point for the pls command.

main_callback(ctx, version=False)

Panel Live Server - Execute and visualize Python code snippets.

mcp(transport=typer.Option('stdio', '--transport', '-t', help='MCP transport: stdio, http, or sse.', envvar='PANEL_LIVE_SERVER_TRANSPORT'), host=typer.Option('127.0.0.1', '--host', help='Host for HTTP/SSE transport.', envvar='PANEL_LIVE_SERVER_MCP_HOST'), port=typer.Option(8001, '--port', '-p', help='Port for HTTP/SSE transport.', envvar='PANEL_LIVE_SERVER_MCP_PORT'), prompts=typer.Option('', '--prompts', help='Path to a JSON file overriding named prompt sections (e.g. library_selection). Sections you omit keep their built-in text.', envvar='PANEL_LIVE_SERVER_PROMPTS_FILE'), verbose=typer.Option(False, '--verbose', '-v', help='Enable verbose logging.'))

Start as an MCP server for AI assistants.

The MCP server exposes the show tool for executing and displaying Python visualizations. A Panel visualization server starts automatically on a per-environment port (override with PANEL_LIVE_SERVER_PORT) — run pls status to see the address, then visit its /feed in a browser to watch visualizations appear in real time.

Note: the --port flag here controls the MCP HTTP/SSE listener, NOT the Panel visualization server port. For stdio transport, --port is unused.

serve(port=typer.Option(None, '--port', '-p', help='Port to run the Panel server on. Defaults to a per-environment port derived from the interpreter.', envvar='PANEL_LIVE_SERVER_PORT'), host=typer.Option('localhost', '--host', '-H', help='Host address to bind to.', envvar='PANEL_LIVE_SERVER_HOST', show_default=True), db_path=typer.Option(None, '--db-path', help='Path to the SQLite database file.', envvar='PANEL_LIVE_SERVER_DB_PATH'), show=typer.Option(False, '--show', help='Open the server in a browser after starting.'), verbose=typer.Option(False, '--verbose', '-v', help='Enable verbose logging.'))

Start the Panel Live Server directly.

The server provides a web interface for executing Python code snippets and visualizing the results. Visit http://:/feed to see visualizations as they are created.

Note: pls serve and pls mcp launched from the same environment resolve to the same per-environment default port, so a browser opened here shows the visualizations the MCP server renders. Set PANEL_LIVE_SERVER_PORT (or --port) to override.

status(port=typer.Option(None, '--port', '-p', help='Port to check. Defaults to the per-environment port derived from the interpreter.', envvar='PANEL_LIVE_SERVER_PORT'), host=typer.Option('localhost', '--host', '-H', help='Host to check.', envvar='PANEL_LIVE_SERVER_HOST', show_default=True))

Check whether the Panel server is running.

Queries the health endpoint and reports the server status.

version_callback(value)

Print version and exit.

client

HTTP client for Display Server REST API.

This module provides a client for interacting with the Panel Display Server via its REST API. The client can be used with either a locally-managed subprocess or a remote server instance.

BROWSER_UNAVAILABLE_PREFIX = 'PlaywrightUnavailable: ' module-attribute

DraftScreenshotResult = tuple[screenshot.Capture | None, str | None, dict[str, str], str] module-attribute

ScreenshotResult = tuple[screenshot.Capture | None, str | None, dict[str, str]] module-attribute

logger = logging.getLogger(__name__) module-attribute

DisplayClient

HTTP client for Display Server REST API.

This client handles all HTTP communication with the Panel Display Server, including health checks and snippet creation. It uses a persistent session for connection pooling.

base_url = base_url.rstrip('/') instance-attribute
session = requests.Session() instance-attribute
timeout = timeout instance-attribute
close()

Close the HTTP session and cleanup resources.

create_snippet(code='', name='', description='', method='inline', validated=False, draft_id='')

Create a visualization snippet on the Display Server.

Sends Python code to the server for execution and rendering, or promotes a draft that has already been rendered.

Parameters:

Name Type Description Default
code str

Python code to execute. Ignored when draft_id is given.

''
name str

Name for the visualization

''
description str

Description of the visualization

''
method str

Execution method ("inline" or "server")

'inline'
validated bool

When True, signals that the caller already ran the static validation layers, so the server can skip repeating them. show currently passes False: its static pass is cached and cheap, but the server's storage-time execution is what populates error_message, and that is the gate that catches a runtime failure before the user's iframe loads.

False
draft_id str

Promote this draft instead of storing new code. The draft has already rendered under a real browser, so promotion neither validates nor executes — it just stops being a draft. This is the path that makes the final show of an iterated visualization free.

''

Returns:

Type Description
dict

Server response containing either: - Success: {"url": str, "id": str, ...} - Error: {"error": str, "message": str, "traceback": str}

Raises:

Type Description
RuntimeError

If HTTP request fails

edit_snippet(snippet_id, old_str, new_str)

Replace one occurrence of old_str with new_str in a stored snippet.

Lets an iteration send only what changed instead of the whole snippet again, which is where the output-token cost of a draft loop lives.

A draft is edited in place. A snippet the user has already been shown is forked into a new draft carrying the change, so the live version does not move under them; forked says which happened and id is the row to screenshot next.

Returns:

Type Description
dict

{"id": str, "chars": int, "forked": bool} on success, or {"error": ..., "message": ...} describing why the edit was refused.

evaluate(code)

Execute code on the server and return its text output.

Returns:

Type Description
dict

{"stdout": str, "result": str, "error": str, "traceback": str}, or {"error": ..., "message": ...} if the request itself failed.

get_screenshot(snippet_id, width=None, height=None, full_page=False, do=None)

Fetch a screenshot of a snippet's rendered /view page.

Returns:

Type Description
ScreenshotResult

(capture, None, diagnostics) on success, or (None, error_message, {}) on failure.

is_healthy()

Check if Display Server is healthy.

Returns:

Type Description
bool

True if server responds to health check, False otherwise

screenshot_code(code, name='', description='', method='inline', width=None, height=None, full_page=False, do=None)

Render code and return a screenshot of it without showing it to the user.

Backs the draft path of the MCP screenshot tool (issue #43): the server stores the code as a draft, kept out of the feed and out of search, so an agent can review it without anything reaching the user.

The draft is retained rather than discarded, and its id comes back, so show(draft_id=...) can promote it later without re-running it.

Returns:

Type Description
DraftScreenshotResult

(capture, None, diagnostics, draft_id) on success, or (None, error_message, {}, "") on failure.

config

Configuration for Panel Live Server.

logger = logging.getLogger('panel_live_server') module-attribute

Config

Bases: BaseModel

Panel Live Server configuration.

brief_error_max_len = Field(default=140, description='Max length of the one-line error shown on the render-failed strip') class-attribute instance-attribute
chars_per_token = Field(default=4, description='Characters per token for the payload size estimate reported by show()') class-attribute instance-attribute
db_path = Field(default_factory=(lambda: _default_user_dir() / 'snippets' / 'snippets.db'), description='Path to SQLite database for snippets') class-attribute instance-attribute
diagnostics_max_chars = Field(default=4000, description="Per-stream cap (chars) on the stdout and console output returned beside a screenshot. Large enough for a traceback or a run of console errors, small enough to stay well inside Tornado's header limit once base64-encoded.") class-attribute instance-attribute
diagnostics_max_console_lines = Field(default=200, description='Max browser console messages collected during a screenshot capture before the rest are dropped') class-attribute instance-attribute
draft_retention_hours = Field(default=24.0, description='How long a screenshot draft is kept before being swept up. Drafts are no longer deleted the moment their picture is taken, because show(draft_id=...) promotes one without re-running it — so they need an expiry instead.') class-attribute instance-attribute
external_url = Field(default='', description='Externally reachable base URL for the Panel server (port-inclusive). Auto-detected from JUPYTERHUB_HOST + JUPYTERHUB_SERVICE_PREFIX (JupyterHub) or CODESPACE_NAME (GitHub Codespaces) if not set explicitly via PANEL_LIVE_SERVER_EXTERNAL_URL.') class-attribute instance-attribute
host = Field(default='localhost', description='Host address for the Panel server') class-attribute instance-attribute
max_restarts = Field(default=3, description='Maximum number of restart attempts') class-attribute instance-attribute
port = Field(default=5077, description='Port for the Panel server') class-attribute instance-attribute
screenshot_height = Field(default=800, description='Viewport height (px) for screenshot capture') class-attribute instance-attribute
screenshot_max_actions = Field(default=20, description="Max steps a single 'do' script may contain, as a backstop against a runaway script") class-attribute instance-attribute
screenshot_max_tiles = Field(default=4, description='Max viewport-sized images one full_page capture returns; a count of screens is a limit that means something, a pixel height is not') class-attribute instance-attribute
screenshot_settle_ms = Field(default=1200, description='Delay (ms) after content mounts before capturing, to let Bokeh finish drawing') class-attribute instance-attribute
screenshot_timeout_ms = Field(default=30000, description='Max time (ms) to wait for the page to load before capturing') class-attribute instance-attribute
screenshot_width = Field(default=1200, description='Viewport width (px) for screenshot capture') class-attribute instance-attribute

default_panel_port()

Return the Panel server port for the active Python environment.

An explicit PANEL_LIVE_SERVER_PORT always wins. Otherwise the port is derived deterministically from the interpreter (sys.prefix) so that each environment gets its own server.

This is what keeps pls executing snippets against the packages the user expects: the Panel server subprocess runs in the same interpreter as pls itself, but a single fixed port would let an MCP client launched from one environment silently adopt a server already running in another. That server executes code against its installed packages, so an import the user just installed alongside pls shows up as missing. A per-environment port means different environments no longer collide on one server.

get_config()

Get or create the config instance.

reset_config()

Reset config (for testing).

database

Database models and operations for the display server.

This module handles SQLite database operations for storing and retrieving visualization requests.

logger = logging.getLogger(__name__) module-attribute

Snippet

Bases: BaseModel

Model for a code snippet stored in the database.

Represents a code snippet submitted to the Display System for visualization.

app = Field(..., description='Python code to execute') class-attribute instance-attribute
created_at = Field(default_factory=(lambda: datetime.now(timezone.utc))) class-attribute instance-attribute
description = Field(default='', description='Short description of the app') class-attribute instance-attribute
draft = Field(default=False, description='Held back from the feed and from search while an agent is still iterating on it') class-attribute instance-attribute
error_message = Field(default=None, description="Error details if status='error'") class-attribute instance-attribute
execution_time = Field(default=None, description='Execution time in seconds') class-attribute instance-attribute
extensions = Field(default_factory=list, description='Inferred Panel extensions') class-attribute instance-attribute
id = Field(default_factory=(lambda: str(uuid.uuid4()))) class-attribute instance-attribute
method = Field(..., description='Execution method') class-attribute instance-attribute
name = Field(default='', description='User-provided name') class-attribute instance-attribute
readme = Field(default='', description='Longer documentation describing the app') class-attribute instance-attribute
requirements = Field(default_factory=list, description='Inferred required packages') class-attribute instance-attribute
slug = Field(default='', description='URL-friendly slug for persistent links') class-attribute instance-attribute
status = Field(default='pending') class-attribute instance-attribute
tags = Field(default_factory=list, description='List of tags') class-attribute instance-attribute
updated_at = Field(default_factory=(lambda: datetime.now(timezone.utc))) class-attribute instance-attribute
user = Field(default='guest', description='User who created the snippet') class-attribute instance-attribute
validate_slug(v) classmethod

Validate that slug is either empty or a valid URL slug.

SnippetDatabase

SQLite database manager for code snippets.

Manages storage and retrieval of Snippet records (code snippets) submitted to the Display System.

db_path = db_path instance-attribute
create_snippet(snippet)

Create a new snippet record.

Parameters:

Name Type Description Default
snippet Snippet

Snippet record to create

required

Returns:

Type Description
Snippet

Created snippet record with ID

create_visualization(app, name='', description='', readme='', method='inline', run_static=True, format=True, execute=True, draft=False)

Create a visualization request.

This is the core business logic for creating visualizations, shared by both the HTTP API endpoint and the UI form.

Parameters:

Name Type Description Default
app str

Python code to execute

required
name str

Display name for the visualization

''
description str

Short description of the visualization

''
readme str

Longer documentation describing the app

''
method str

Execution method: "inline", "server", or "pyodide"

'inline'
run_static bool

Run the static layers: syntax, security, package availability, and — for method="server" — Panel extension availability. The MCP tools run these themselves before calling, behind a cache, so they can turn this off rather than pay for them twice. The web /add form leaves it on so untrusted input is still fully checked.

True
format bool

Autoformat with ruff format before storing. Off for anything an agent may later edit by string match — a reformat between what the author holds and what is stored is what makes old_str miss for reasons nobody can see. The web /add form leaves it on, since a human pasting code is not going to string-match against it later. Code is formatted when read for display, not on the way in.

True
execute bool

Run the snippet once here to populate status and error_message. This is what lets show catch a runtime failure before the user's iframe loads. The screenshot path turns it off: the Playwright render is itself an error detector, so executing here as well would run the code twice for one picture. When off, the row is stored pending for the render to settle.

True
draft bool

Store this as a draft: kept out of the feed, out of search, and swept up by age. The screenshot path sets it so an agent can iterate without anything reaching the user; show(draft_id=...) promotes the one it settles on.

False

Returns:

Type Description
Snippet

The snippet created for the visualization request.

Raises:

Type Description
ValueError

If app is empty or contains unsupported operations

SyntaxError

If app has syntax errors

Exception

If database operation or other errors occur

delete_snippet(snippet_id)

Delete a snippet record.

Parameters:

Name Type Description Default
snippet_id str

Snippet ID

required

Returns:

Type Description
bool

True if deleted, False if not found

delete_stale_drafts(older_than_hours)

Delete drafts last touched more than older_than_hours ago.

Drafts are no longer deleted the moment their screenshot is taken, so something has to clear them. Age is the simplest rule that works and matches how the validation cache is scoped: a draft is only interesting while the agent that made it is still working on it.

Parameters:

Name Type Description Default
older_than_hours float

Age past which a draft is discarded

required

Returns:

Type Description
int

Number of drafts deleted

get_snippet(snippet_id)

Get a snippet record by ID.

Parameters:

Name Type Description Default
snippet_id str

Snippet ID

required

Returns:

Type Description
Optional[Snippet]

Snippet record if found, None otherwise

get_snippet_by_slug(slug)

Get the most recent snippet record by slug.

Parameters:

Name Type Description Default
slug str

Snippet slug

required

Returns:

Type Description
Optional[Snippet]

Most recent snippet record with this slug if found, None otherwise

list_snippets(limit=100, offset=0, start=None, end=None, status=None, method=None, include_drafts=False)

List snippet records with filters.

Parameters:

Name Type Description Default
limit int

Maximum number of snippets to return

100
offset int

Number of snippets to skip

0
start Optional[datetime]

Filter snippets created after this time

None
end Optional[datetime]

Filter snippets created before this time

None
status Optional[str]

Filter by status

None
method Optional[str]

Filter by method

None
include_drafts bool

Include snippets an agent is still iterating on. Off by default so that every existing caller — the feed, the admin page — excludes them without having to know they exist.

False

Returns:

Type Description
list[Snippet]

List of snippet records

promote_draft(snippet_id, name=None, description=None)

Turn a draft into a snippet the user can see.

Promotion deliberately does not re-run the code. The draft already rendered under Playwright, which is a strictly stronger check than the storage-time execution it would otherwise repeat: a real page load, with the real preamble and session extensions, rather than an inline exec. Running it again here would reinstate the second execution this whole path exists to remove.

Nothing is reformatted. Stored code stays byte-identical to what the caller sent, so a later old_str edit matches what the author holds; formatting is applied when code is read for a human instead (the code panel and the feed).

Parameters:

Name Type Description Default
snippet_id str

Id of the draft to promote

required
name Optional[str]

Replacement display name. Left as-is when None.

None
description Optional[str]

Replacement description. Left as-is when None.

None

Returns:

Type Description
Snippet

The promoted snippet

Raises:

Type Description
ValueError

If no such snippet exists, it is not a draft, or its last render did not succeed

search_snippets(query, limit=100, include_drafts=False)

Search snippet records using full-text search.

Parameters:

Name Type Description Default
query str

Search query

required
limit int

Maximum number of results

100
include_drafts bool

Include snippets an agent is still iterating on. Off by default, or drafts leak to the user through search even while hidden from the feed.

False

Returns:

Type Description
list[Snippet]

Matching snippet records

update_snippet(snippet_id, status=None, error_message=None, execution_time=None, requirements=None, extensions=None, app=None, name=None, description=None, draft=None)

Update a snippet record.

Setting app, name or description reindexes the row for search, via the snippets_fts_update trigger. status and friends do not, which matters because every /view load writes them.

Parameters:

Name Type Description Default
snippet_id str

Snippet ID

required
status Optional[str]

New status

None
error_message Optional[str]

Error message

None
execution_time Optional[float]

Execution time

None
requirements Optional[list[str]]

Required packages

None
extensions Optional[list[str]]

Required extensions

None
app Optional[str]

Replacement code

None
name Optional[str]

Replacement display name

None
description Optional[str]

Replacement description

None
draft Optional[bool]

Whether the snippet is still a draft

None

Returns:

Type Description
bool

True if updated, False if not found

get_db(db_path=None)

Get or create the SnippetDatabase instance.

This function implements lazy initialization with a global cache. The database instance is created once and reused across the application.

Parameters:

Name Type Description Default
db_path Optional[Path]

Path to database file. If None, uses default from environment/config. Only used on first call; subsequent calls ignore this parameter.

None

Returns:

Type Description
SnippetDatabase

Shared database instance

reset_db()

Reset the database instance.

This is primarily for testing purposes to ensure a clean state.

diagnostics

Carry a snippet's output back out to the caller of screenshot.

The MCP screenshot tool returns a PNG. Everything else a render produced is otherwise discarded: what the snippet printed, and what the browser logged while drawing it. Both matter to an agent.

Losing stdout means an agent that wants to read a value has to render it into the image — a Markdown pane built solely to be screenshotted and then read back out of a picture. That is a whole extra round-trip for text the process already had in hand.

Losing the browser console is worse, because it hides a class of failure the image cannot explain. Bokeh reports layout and tile problems there (tile extent is not fully defined, could not set initial ranges), and a plot that fails for that reason screenshots as an empty frame — visually identical to every other cause, so the picture is the least informative evidence available at exactly the moment it is most tempting to keep taking pictures.

Snippet execution and the /api/screenshot handler run in the same process, so an in-memory store keyed by snippet id is sufficient. Nothing here needs to survive a restart, and entries are consumed once and dropped.

DRAFT_ID_HEADER = 'X-PLS-Draft-Id' module-attribute

HEADER = 'X-PLS-Diagnostics' module-attribute

MAX_ENTRIES = 64 module-attribute

logger = logging.getLogger(__name__) module-attribute

build(python_output, console_lines)

Assemble the payload for the response header. Empty dict when nothing ran.

collapse_repeats(lines)

Collapse consecutive identical lines into a single (xN) entry.

Browser consoles repeat: one failing tile prefetch can log the same message per tile. Without this, a single fault fills the whole budget and crowds out the messages that would identify it.

decode(raw)

Inverse of :func:encode. Returns {} rather than raising on junk.

encode(payload)

Base64-encode payload so it is safe to put in an HTTP header.

pop(snippet_id)

Return and forget the output recorded for snippet_id.

record(snippet_id, text)

Store text as the captured output of snippet_id.

Called with whatever the snippet wrote to stdout/stderr. Repeated calls for the same id append, so a partial write before an exception is not lost.

render(payload)

Format payload as the text block handed to the agent.

truncate(text, limit=None)

Clip text to limit, keeping the tail.

The end is the informative part — a traceback's final line, or the last thing printed before something went wrong.

limit defaults to config.diagnostics_max_chars, resolved at call time. It cannot be a default argument value: that would bind whatever the config held at import and ignore any later reset_config().

endpoints

REST API endpoints for the Display System.

This module implements Tornado RequestHandler classes that provide HTTP endpoints for creating visualizations and checking server health.

logger = logging.getLogger(__name__) module-attribute

EvaluateEndpoint

Bases: RequestHandler

Run code and return its text output — no rendering, no browser, no feed.

POST /api/evaluate with {"code": ...} executes the code and returns {"stdout": ..., "result": ..., "error": ..., "traceback": ...}.

This exists because the answer an agent wants is often a value, not a picture: does this option exist, what does this return, what are the columns, what range did Bokeh actually compute. Routing those through /screenshot means launching Chromium and rendering the text into an image purely so it can be read back out of one. This is the same environment — the packages that make the display server useful — reached without the browser.

Execution happens here, in the display-server process, exactly as /view does. The MCP process never execs snippet code.

Nothing is written to the database, so an evaluation cannot reach the feed.

post()

Execute the posted code and return its output as JSON.

HealthEndpoint

Bases: RequestHandler

Tornado RequestHandler for /api/health endpoint.

get()

Handle GET requests to check server health.

The payload reports the interpreter running this server (sys.prefix and sys.executable) so a manager can tell whether a server already listening on the port belongs to its own environment before adopting it.

It also carries the session's usage counters (issue #58), which is how the cost of a working session gets measured rather than estimated. They live here rather than in the show payload because this is a plain GET nobody pays context for.

ScreenshotEndpoint

Bases: RequestHandler

Render a snippet's /view page to a PNG.

Loads the live /view page in a headless browser (Playwright) and returns a PNG, giving LLMs a picture of the rendered output — layout, fonts, and margins as a user would see them. When no browser is installed/launchable this returns HTTP 503 with an install hint so the caller can surface a clear message instead of failing opaquely.

GET /api/screenshot?id=... captures a snippet that already exists.

POST /api/screenshot with a JSON body of {"code": ..., "name": ..., "description": ..., "method": ...} captures code that has never been shown (issue #43). The row is stored as a draft: kept out of the feed and out of search, so an agent can iterate without anything reaching the user. The id is returned in the X-PLS-Draft-Id header, and show(draft_id=...) later promotes the draft the agent settles on — which is what lets the final show cost no execution at all. Drafts are swept by age, not on the way out.

The draft is deliberately not executed before the capture. Loading /view runs it and stamps the row with a status and, on failure, a traceback — so the row is re-read afterwards and a failed draft comes back as its traceback rather than as a picture of one. One execution per draft, not two.

Query parameters (GET) / body fields (POST)

id : str Snippet id to render (GET only, required). width, height : int Viewport size in px (default from config). full_page : bool Capture the whole scrollable page as a run of viewport-sized tiles rather than the single visible screen. Defaults to false. do : str (GET, JSON-encoded) / list (POST) Steps to perform before capturing — see :func:screenshot.capture_pages. Unset means nothing is clicked; the page is captured as it loaded.

A single capture comes back as image/png. Anything that produced more than one image — several tiles of a tall page — comes back as JSON instead, one base64 PNG per image, because there is no honest way to put several images in one image body. Either way the X-PLS-Capture header reports what was not returned: the controls found on the page, and how many tiles the content actually needs.

get() async

Capture and return the snippet identified by ?id= as a PNG.

post() async

Store the posted code as a draft, capture it, and hand back the picture.

SnippetEditEndpoint

Bases: RequestHandler

Change part of a stored snippet without resending the whole thing.

POST /api/snippet/edit with {"snippet_id": ..., "old_str": ..., "new_str": ...} replaces one occurrence of old_str in the snippet's code.

A draft loop otherwise costs a full rewrite per turn: the model resends every line to change a colour. Substring editing makes the output proportional to the change rather than to the snippet.

This works only because drafts are stored verbatim (format=False): if the server reformatted on the way in, the text the model is matching against would not be the text on disk, and old_str would miss for reasons no one could see.

A draft is edited in place. Something the user has already been shown is forked instead: the edit lands on a new draft and the live row is left alone, so nothing changes under someone who is looking at it. Refusing these outright was the earlier design, and it cost a wasted call on the commonest shape there is — show, then "tweak that".

post()

Apply a single substring replacement to a stored snippet.

SnippetEndpoint

Bases: RequestHandler

Tornado RequestHandler for /api/snippet endpoint.

get()

Return a stored snippet's code and metadata for ?id=.

Exists so the show payload no longer has to carry the code itself. That echo was paid on every call, in the model's context, to populate a panel the user opens rarely — so the code is fetched here instead, when it is actually looked at.

The code is formatted on the way out rather than on the way in. Storage stays byte-identical to what the author sent, so old_str edits keep matching, while a human opening the panel still sees tidy code. This runs only when the panel is actually opened, so the cost is paid by the click.

post()

Handle POST requests to store snippets and create visualizations.

set_default_headers()

Allow the MCP App to read a snippet's code from its own origin.

show.html runs inside the host application, not on this server, so a cross-origin read needs this header. The data is already reachable by anything that can reach this port, which is the same machine, so the header grants nothing that was not already available.

manager

Panel server subprocess management.

This module manages the Panel server as a subprocess, including startup, health checks, and shutdown.

logger = logging.getLogger(__name__) module-attribute

PanelServerManager

Manages the Panel server subprocess.

db_path = db_path instance-attribute
host = host instance-attribute
max_restarts = max_restarts instance-attribute
port = port instance-attribute
process = None instance-attribute
restart_count = 0 instance-attribute
get_base_url()

Get the base URL for the Panel server.

Returns:

Type Description
str

Base URL

is_healthy()

Check if Panel server is healthy.

Returns:

Type Description
bool

True if server is healthy, False otherwise

restart()

Restart the Panel server.

Returns:

Type Description
bool

True if restarted successfully, False otherwise

start()

Start the Panel server subprocess.

Returns:

Type Description
bool

True if started successfully, False otherwise

stop(timeout=5)

Stop the Panel server subprocess.

Parameters:

Name Type Description Default
timeout int

Maximum time to wait for graceful shutdown

5

pages

Panel page functions for Panel Live Server.

add_page

Add page for creating new visualizations.

This module implements the /add page endpoint that provides a form for manually creating visualizations via the UI.

ABOUT = '\n## Add Visualization\n\nThis page allows you to create new visualizations by writing Python code.\n\n### How to Use\n\n1. **Write Code**: Enter your Python visualization code in the editor\n2. **Configure**: Set a name, description, and execution method in the sidebar\n3. **Submit**: Click the Submit button to create the visualization\n\n### Execution Methods\n\n- **jupyter**: The last expression in the code is displayed (like a Jupyter cell)\n- **panel**: Objects marked with `.servable()` are displayed as a Panel app\n\n### Learn More\n\nFor more information about this project, visit:\n[Panel Live Server](https://github.com/panel-extensions/panel-live-server).\n' module-attribute
DEFAULT_SNIPPET = "import pandas as pd\nimport hvplot.pandas\n\ndf = pd.DataFrame({\n 'Product': ['A', 'B', 'C', 'D'],\n 'Sales': [120, 95, 180, 150]\n})\n\ndf.hvplot.bar(x='Product', y='Sales', title='Sales by Product')" module-attribute
logger = logging.getLogger(__name__) module-attribute
add_page()

Create the /add page for manually creating visualizations.

Provides a UI form for entering code, name, description, and execution method.

admin_page

Admin page for managing snippets.

This module implements the /admin page endpoint that allows viewing and deleting snippets from the database.

ABOUT = '\n## Snippet Manager\n\nThis page provides an administrative interface for managing all visualizations\nstored in the database.\n\n### Features\n\n- **View All Snippets**: See all visualizations with their name, description, method, status, and creation date\n- **View Code**: Expand any row to see the full Python code for that visualization\n- **Delete Snippets**: Remove visualizations you no longer need\n- **Direct Links**: Click the link icon to view any visualization\n\n### Learn More\n\nFor more information about this project, visit:\n[Panel Live Server](https://github.com/panel-extensions/panel-live-server).\n' module-attribute
admin_page()

Create the /admin page.

Provides an administrative interface for managing all snippets in the database.

feed_page

Feed page showing a scrollable list of visualizations.

This module implements the /feed page endpoint that displays recent visualizations in a feed-style layout with live updates.

ABOUT = '\n## Visualization Feed\n\nThis page displays a live feed of recent visualizations created through the Panel Live Server display tool.\n\n### Features\n\n- **Live Updates**: The feed automatically refreshes every second to show new visualizations\n- **View / Code Tabs**: Each visualization shows both an interactive preview and the source code\n- **Actions**: Open visualizations in full screen, copy code to clipboard, or delete entries\n- **Limit Control**: Use the sidebar to control how many visualizations are displayed\n\n### How It Works\n\nWhen an AI assistant uses the `show` tool to display a visualization, it appears here in the feed.\nEach entry includes the visualization name, creation time, description, and an iframe preview.\n\n### Learn More\n\nFor more information about this project, including setup instructions and advanced configuration options,\nvisit: [Panel Live Server](https://github.com/panel-extensions/panel-live-server).\n' module-attribute
feed_page()

Create the /feed page.

Displays a feed of recent visualizations with automatic updates.

view_page

View page for displaying individual visualizations.

This module implements the /view page endpoint that executes and displays a single visualization by ID.

logger = logging.getLogger(__name__) module-attribute
create_view(snippet_id)

Create a view for a single visualization snippet.

Parameters:

Name Type Description Default
snippet_id str

ID of the snippet to display

required

Returns:

Type Description
Viewable

Panel component displaying the visualization

view_page()

Create the /view page.

Renders a single visualization by ID or slug from the query string parameter. Supports ?id=... or ?slug=... query parameters. If both are provided, id takes precedence.

prompts

Render the prompts sent to the model, with user overrides (issue #50).

Every word the server puts in front of the model used to be a hardcoded string in server.py: the instructions FastMCP sends at startup, and the reminders the screenshot tool returns alongside an image. Teams have real reasons to change that text — "we're a Plotly shop, stop recommending hvPlot", or a house style for how links are presented — and the only way to do it was to fork the repo.

The text now lives in templates/prompts/*.md.j2, carved into named {% block %} sections. A user points pls mcp --prompts at a JSON file naming the sections they want to change::

{"library_selection": "Always use ECharts. For sine waves use hvplot, in pink."}

A bare string is added in front of the shipped text, under a header marking it as authoritative. That is the safe default: replacing a section wholesale silently drops operational detail the model depends on (that Matplotlib and Plotly may not be installed, for instance), and most house rules are additions rather than deletions. To discard the default text instead, ask for it explicitly::

{"library_selection": {"replace": "Use plotly.express and nothing else."}}

Sections they do not mention keep rendering from the shipped template, so an upstream improvement to a section they never touched still reaches them on the next upgrade. That is the whole reason for blocks rather than "copy the prompt and edit it".

A broken override must never stop the MCP server from starting: the model losing a customization is a much smaller problem than the user losing the server. Every failure below is caught, reported on stderr, and falls back to the shipped text.

INSTRUCTIONS = 'instructions.md.j2' module-attribute

SCREENSHOT = 'screenshot.md.j2' module-attribute

logger = logging.getLogger(__name__) module-attribute

known_sections()

Return every section name a --prompts file may set.

render_instructions()

Return the MCP server instructions with any configured overrides applied.

render_prompt(template, block=None)

Return template (or one block of it) with configured overrides applied.

screenshot

Headless-browser screenshot capture for rendered Panel snippets.

Wraps Playwright (Chromium) to load a /view page and capture a PNG so the MCP screenshot tool can hand an LLM a picture of the rendered output — the actual layout, fonts, and margins as a user would see them, not just the source code.

A dashboard is often taller than the browser window, and much of what it can show is not showing yet. Both are invisible in a picture: a page cut in half looks exactly like a page that ends, and a chart you have not zoomed into is absent in the same silent way an empty chart is. So every capture also reports what it did not show — the controls it found on the page, and whether content continues past the fold (see :class:Capture). Both facts are read off the loaded page for the cost of two locator calls; neither clicks anything, and neither costs an extra image.

Acting on that report is the caller's choice, never this module's, because only the caller knows the question. "Is the top chart blue?" needs one picture; "review my dashboard" needs all of them; "do the points resolve when you zoom in?" needs a click and a drag first. So full_page defaults to the cheapest honest answer, and do — a short script of clicks, selections, and drags run before the shutter — is empty unless the caller asks. It carries the same name at every layer, MCP tool to browser, so there is no point where a reader has to learn that one thing is called two things.

This module deliberately recognises no widget at all. It used to look for .bk-tab and call what it found "pages", which meant a dashboard built from a Select, a Button, or a custom tab strip had no pages as far as the tool was concerned, and a plot you could only read by zooming could not be read at all. Elements are found by the name a user would use — visible text, a tooltip, an accessible label — or, for a canvas, by nothing but coordinates.

Playwright is a required dependency (included in the base install). Import / launch failures are surfaced as :class:PlaywrightUnavailableError with an install hint so callers can degrade gracefully instead of crashing.

META_HEADER = 'X-PLS-Capture' module-attribute

logger = logging.getLogger(__name__) module-attribute

ActionError

Bases: ValueError

Raised when a step in do cannot be carried out as written.

A malformed step, a name that matches nothing, or a name that matches several things. All three are the caller's to fix and all three are fixed from the message alone, so the message carries what is actually on the page rather than only saying no.

Distinct from every other ValueError the capture path can raise (a malformed width, say) so the HTTP layer can answer "your script was wrong" with a 400 without also blaming the caller for bugs of ours.

Capture dataclass

One browser visit: the images taken, and an honest account of the rest.

images holds (label, png) pairs. The label says which screen of a tall page this is — "" for the ordinary single-image case, which is most of them.

The remaining fields are the report. controls names what is on the page that could be acted on, which is both the vocabulary for a follow-up do and the answer to "what else is there". total_tiles is how many screens of content the page holds and captured_tiles how many came back; total_tiles > captured_tiles means the picture stops before the content does.

captured_tiles = 1 class-attribute instance-attribute
controls = field(default_factory=list) class-attribute instance-attribute
images = field(default_factory=list) class-attribute instance-attribute
png property

The first PNG, for callers that only ever wanted one image.

total_tiles = 1 class-attribute instance-attribute

PlaywrightUnavailableError

Bases: RuntimeError

Raised when Playwright or its browser is not installed/launchable.

apply_meta(capture, meta)

Fill capture's report fields in from a decoded :func:encode_meta payload.

capture_pages(url, *, width=1200, height=800, full_page=False, settle_ms=1200, timeout_ms=30000, do=None, max_tiles=4, max_actions=_MAX_ACTIONS, console_sink=None) async

Screenshot url using a shared headless browser.

Both full_page and do default to the cheapest honest answer — one image of what is on screen, nothing clicked — and the returned :class:Capture reports what that left out. Ask for more only when the question needs it.

Parameters:

Name Type Description Default
full_page bool

Capture the whole scrollable page as a run of viewport-sized tiles rather than the single visible screen.

False
do list[dict]

Steps to perform, in order, before capturing — {"click": "<name>"}, {"select": "<name>", "value": "<option>"}, {"fill": "<name>", "value": "<text>"}, {"key": "<KeyName>"}, {"drag": [x0, y0, x1, y1]} (viewport pixels), or {"wait": <ms>}. Names are matched against visible text, a tooltip, or an accessible label — whatever a person would call the element.

None
max_tiles int

Ceiling on how many tiles one full_page capture returns.

4
max_actions int

Ceiling on how many steps do may contain.

20
console_sink list[str]

If given, browser console messages and uncaught page errors observed during the capture are appended to it.

None

Returns:

Type Description
Capture

The images taken, plus the controls found and the tiles not captured.

Raises:

Type Description
PlaywrightUnavailableError

If Playwright or a launchable browser is not available.

ActionError

If do is malformed, or a step names something the page does not have (or has more than one of).

capture_png(url, **kwargs) async

Capture a single PNG of url. Thin wrapper over :func:capture_pages.

check_actions(do, limit=_MAX_ACTIONS)

Validate a do script before a browser is involved.

Every problem here is a typo in the caller's own message, so it is worth catching before the cost of a page load — and worth answering with the shape that would have worked rather than only with what did not.

Raises:

Type Description
ActionError

If the script is not a list of well-formed single-action steps, or is longer than limit.

decode_meta(raw)

Inverse of :func:encode_meta. Returns {} rather than raising on junk.

encode_meta(capture)

Base64-encode capture's report so it is safe to put in an HTTP header.

Control names are dropped from the end until the result fits _MAX_META_BYTES; the tile counts are fixed-size and always survive. A dashboard whose labels are paragraphs must cost the caller some names, never the image.

install_browser()

Download the headless Chromium browser the screenshot tool needs.

Playwright ships its browser binary separately from the Python package, so pip/uv installs do not fetch it automatically. This shells out to <this-interpreter> -m playwright install chromium so the browser always lands in the same environment that is running pls — avoiding the common trap where the binary is installed under a different interpreter.

Returns:

Type Description
int

The installer subprocess exit code (0 on success).

is_browser_installed()

Return True if the Chromium binary Playwright needs is present.

This is a cheap check — it does not launch a browser. It uses Playwright's sync API, so call it from a worker thread (e.g. asyncio.to_thread), not directly inside a running event loop.

tile_label(index, total)

Name one image: which screen of a tall page it is. "" when it is the only one.

server

Panel Live Server - MCP Server.

A standalone MCP server that provides the show tool for executing Python code and rendering visualizations via a Panel web server.

SHOW_RESOURCE_URI = 'ui://panel-live-server/show.html' module-attribute

SHOW_TEMPLATE_PATH = Path(__file__).parent / 'templates' / 'show.html' module-attribute

logger = logging.getLogger(__name__) module-attribute

mcp = FastMCP('Panel Live Server', instructions=(render_instructions()), lifespan=app_lifespan) module-attribute

app_lifespan(app) async

MCP server lifespan - eagerly start the Panel server.

edit(snippet_id, old_str, new_str='', ctx=None) async

Change part of a visualization without resending the whole snippet.

Use this instead of resending the full code when you are adjusting something small — a colour, a title, a width, one line of a layout. Rewriting a 200-line snippet to change one argument costs you the entire snippet in output tokens, every round.

old_str must appear EXACTLY ONCE, matching character for character including indentation. Code is stored exactly as you sent it, so what you wrote is what is stored. If the string appears more than once, include surrounding lines until it is unique.

Works on a draft and on something the user has already been shown:

· A DRAFT is edited in place and the same id comes back. · A SHOWN snippet is FORKED — a new draft is created carrying your change and its id comes back, while the version the user is looking at does not move. Showing the fork adds a new entry to their feed; the old one stays.

Either way the edited code is run before this returns, so the id you get back is ready to hand over: show(draft_id=...) directly. Screenshot it first only if you need to SEE the change — a layout you are unsure of, not a colour you named.

Typical loops: user tweak → show returns an id → edit(id, old, new)show(draft_id=<new id>) unsure → edit(id, old, new)screenshot(draft_id=<new id>)show(draft_id=<new id>)

For a small snippet, or a change touching most of the code, just resend it — that is fine and often simpler.

Parameters:

Name Type Description Default
snippet_id str

Id of the draft or shown visualization to change.

required
old_str str

Exact text to replace. Must occur exactly once.

required
new_str str

Replacement text. Omit to delete old_str.

''

Returns:

Type Description
str

Confirmation naming the id to show next, or why the edit was refused.

evaluate(code, ctx=None) async

Run Python and read its text output — no picture, no browser.

Use this when the answer you want is a VALUE, not an appearance: what a function returns, whether an option is accepted, what columns a DataFrame has, what range Bokeh actually computed. It executes in the same environment as show and screenshot, so the plotting packages are all importable, and returns whatever the code printed plus the repr of its last expression.

Prefer this over screenshot for anything textual. Rendering a value into a Markdown pane so it can be read back out of a PNG costs a browser launch and an image, and answers nothing the text would not have.

════════════════════════════════════════════════════════════════════════ DO NOT use this to answer questions about how something LOOKS ════════════════════════════════════════════════════════════════════════ Where a peak sits, which bar is tallest, what colour a series is, whether the legend overlaps — those must go through screenshot, because the rendered plot and the raw data frequently disagree (axes invert, categories sort, heatmap rows flip, values get binned). Recomputing an appearance from the data is the specific mistake screenshot exists to prevent. This tool is for facts about objects, not about pixels.

Nothing here reaches the user: no feed entry, no chat message, no stored snippet. It is yours to use as freely as you need.

Typical uses: · check an API → hv.opts.Points(autohide_toolbar=True) · inspect a rendered model → hv.render(plot).x_range.start · confirm data shape → df.dtypes, len(df), df.columns.tolist() · verify availability → import geoviews; geoviews.__version__

Parameters:

Name Type Description Default
code str

Python to execute. The last expression's value is returned as its repr, so a bare df.dtypes on the final line is enough — no print needed, though print output is returned too.

required

Returns:

Type Description
str

Captured stdout/stderr, the last expression's repr, and the traceback if it raised.

screenshot(snippet_id='', code='', draft_id='', name='', method='inline', width=1200, height=800, full_page=False, do=None, ctx=None) async

See a visualization as a PNG — returns the image to you (the LLM), not to the user.

════════════════════════════════════════════════════════════════════════ TWO WAYS TO CALL THIS — pick one: ════════════════════════════════════════════════════════════════════════

  1. screenshot(code=...) — CHECK YOUR OWN WORK BEFORE THE USER SEES IT. Renders the code and returns the picture to you alone. Nothing is added to the chat and nothing is added to the user's feed. Use it to look at a draft, fix what is wrong, and look again — as many rounds as you need. When it finally looks right, call show(draft_id=...) once, passing the draft id reported alongside the image, to hand that exact draft to the user. Do not paste the code into show again.

Do NOT call show just to get a snippet_id to screenshot. That puts every half-finished draft in front of the user, which is exactly what this parameter exists to prevent.

  1. screenshot(draft_id=...) — LOOK AT A DRAFT AGAIN AFTER EDITING IT. Re-renders a draft you already have, picking up any edit calls made since. Still yours alone; nothing reaches the user.

  2. screenshot(snippet_id=...) — LOOK AT SOMETHING THE USER ALREADY HAS. Pass the snippet_id that show returned. Use it to answer a follow-up question about how an already-shown visualization LOOKS. It does not create or modify anything.

Either way this is NOT a substitute for show — a screenshot is a still picture for you; only show gives the user the live, interactive page.

════════════════════════════════════════════════════════════════════════ CRITICAL RULE — answering questions ABOUT a visualization's appearance: ════════════════════════════════════════════════════════════════════════ When the user asks where something is, which element is biggest/smallest, what color/position/shape something has, or anything about how the chart LOOKS, you MUST call this tool and answer from the returned image.

You MUST NOT answer such questions by reading the code, recomputing from the raw data, or re-running the snippet in a Python tool. THAT IS CHEATING AND IS USUALLY WRONG, because the rendered plot is NOT the same as the raw data: - heatmaps flip/reverse the row order (Row 0 often renders at the BOTTOM) - axes get inverted, categories get sorted, histograms bin/group values - color mapping, stacking, and layout change what is visually "highest" The raw-data answer and the on-screen answer frequently DISAGREE. The image is the only ground truth for a question about appearance — so look at it.

Do not add np.random.seed(...) or otherwise make data deterministic just so you can recompute it; read the answer off the actual picture.

════════════════════════════════════════════════════════════════════════ IMAGE QUALITY — when the picture is not enough: ════════════════════════════════════════════════════════════════════════ After receiving the screenshot, check whether it is clear enough to answer: - Is the chart blurry or pixelated? - Is the relevant detail (a label, a tick value, a legend entry) too small to read confidently? - Is the area of interest clipped or off-screen?

If YES — the image is not reliable enough — do NOT guess from it. Instead, answer the question directly from the code and data (compute the value, read the label, inspect the structure). A code-derived answer is better than a wrong guess from a bad image.

If the image is fine, always prefer it over recomputing (see CRITICAL RULE above — rendered output and raw data frequently disagree).

════════════════════════════════════════════════════════════════════════ DRIVING THE APP BEFORE YOU LOOK: ════════════════════════════════════════════════════════════════════════ By default you get one screen exactly as it loaded: nothing clicked, nothing scrolled. A page taller than the window, and anything that only appears after an interaction, are missing from that picture the same silent way an empty chart is — so the reply TELLS you what is on the page and whether content continues past the fold.

Read that line and decide. You know the question; this tool does not. · full_page=True → the whole scrollable page, as a few readable screen-sized images rather than one shrunken strip · do=[...] → a short script run before the picture is taken:

do=[{"click": "Reports"}]                                 # open a tab
do=[{"select": "Region", "value": "West"}]                # pick a dropdown option
do=[{"fill": "Search", "value": "acme"}, {"key": "Enter"}] # type, then submit
do=[{"click": "Box Zoom"}, {"drag": [300, 200, 500, 350]}] # zoom into a plot

Each step names what to act on — the text on it, its tooltip, or its label — not a CSS selector, and not a widget type. This reaches anything: a Tabs strip, a Button, a Select, a RadioButtonGroup, or a Bokeh toolbar icon (icons carry no visible text, only a tooltip — "click": "Box Zoom" finds one by that tooltip). Steps run in order, each settling before the next.

{"drag": [x0, y0, x1, y1]} is viewport pixels, not a name — the one step for a canvas or a plot region that has nothing to click by name. A crowded scatter plot rendered with datashader often needs exactly this: click the plot's "Box Zoom" tool, then drag a box over the crowded area, to see whether the individual points resolve once zoomed. The tool is not active by default — clicking it first is part of the script, not optional.

A step that matches nothing, or matches more than one thing, is refused before any picture is taken, and the message names what IS on the page — read it and try again with a more specific name.

Nothing is clicked unless you name it. A Select wired to filter data and a Select wired to switch pages look identical until you act on one; this tool never decides that for you.

WHEN TO USE — a follow-up question about an already-shown visualization that can only be answered by seeing it (random/dynamic data, or visual position): · wave/line chart → "where does it peak?", "where is the lowest dip?" · bar chart → "which bar is the tallest?", "which category leads?" · scatter plot → "where are the outliers?", "how spread out are the points?" · heatmap → "which cell has the highest value?" · pie/donut chart → "which slice is the largest?" · histogram → "where is the distribution centered?" · any chart → "what color is X?", "what does the legend say?"

Typical loops: · building something → screenshot(code=...) → revise → screenshot(code=...)show(draft_id=...) · small adjustment → edit(id, old, new)show(draft_id=<new id>) · visual follow-up → show (returns id) → screenshot(snippet_id=id)

Parameters:

Name Type Description Default
snippet_id str

Id of an already-shown visualization, as returned by show.

''
code str

Python code for a draft the user has not seen. Rendered and captured without ever reaching the chat or the feed, and kept as a draft so show(draft_id=...) can hand it over unchanged. Takes precedence if several of these are given.

''
draft_id str

Id of an existing draft to re-render, typically after edit.

''
name str

Short display name for the draft. Only used with code.

''
method (inline, server)

How to render code, matching the same parameter on show. Use "server" for Panel apps built with .servable().

"inline"
width int

Browser viewport width in pixels.

1200
height int

Browser viewport height in pixels.

800
full_page bool

Capture the whole scrollable page instead of the visible screen. Comes back as several screen-sized images, each readable at native scale.

False
do list[dict]

Steps to perform before capturing, in order — click, select, fill, press a key, drag, or wait. See "DRIVING THE APP BEFORE YOU LOOK" above for the shape of each step. Nothing is clicked unless listed here.

None

Returns:

Type Description
Image

PNG screenshot of the rendered visualization — one per screen when full_page is set — plus a note naming what else is on the page and whether content continues past the fold.

show(code='', name='', description='', method='inline', zoom=75, draft_id='', ctx=None) async

Display Python code as a live, interactive visualization.

Runs static validation (syntax, security, packages, extensions) in ~50 ms, stores the snippet, and returns the visualization URL. The iframe loads immediately via Panel's WebSocket — the user sees a loading indicator then the rendered visualization, with no prior validate() call needed.

Always call this tool when the user asks to show, display, plot, or visualize anything.

IMPORTANT — this tool is for FINISHED work; calling it puts the visualization in front of the user. To check your own output first, call screenshot(code=...) instead — that renders the code and returns the picture to you alone, with nothing appearing in the chat or the user's feed. Iterate there as long as you need, then finish with show.

IMPORTANT — if you reached the final version through screenshot(code=...), call show(draft_id=...) with the draft id that screenshot reported, NOT show(code=...) with the code pasted again. The draft has already been rendered and checked, so promoting it costs nothing and cannot introduce a difference between what you looked at and what the user gets.

IMPORTANT — pass the Python code DIRECTLY as the code argument. Do NOT write it to a file in the user's project first, do NOT create scripts, notebooks, or examples/ files, and do NOT run it in a separate shell. This tool executes the code itself; creating files is unwanted side-effect clutter in the user's repository.

IMPORTANT — always provide a short name (e.g. "Temperature chart") so the visualization is easy to find in the feed.

IMPORTANT — after calling this tool, always present the returned url to the user as a clickable Markdown link: [Show Visualization](url)

Parameters:

Name Type Description Default
code str

Python code to execute. Omit when promoting with draft_id. For "inline" method: the last expression is displayed. It must be fully dedented (no leading whitespace on top-level statements). For "server" method: call .servable() on objects to display.

''
name str

Short display name shown in the feed (e.g. "Sales chart 2024"). Always provide this — unnamed visualizations are hard to track.

''
description str

One-sentence description of what the visualization shows.

''
method (inline, server)

Execution mode:

  • "inline": displays the last expression's result. Use for standard plots, DataFrames, and objects that do NOT import Panel directly.
  • "server": displays objects marked .servable(). Use when the code imports and uses Panel to build dashboards or complex layouts.
"inline"
zoom (100, 75, 50, 25)

Initial zoom level for the preview pane. 75 fits most charts and dashboards. Use 50 for full-page templates, 25 for very wide apps.

100
draft_id str

Id of a draft from screenshot(code=...) to hand to the user as-is. The draft has already rendered successfully in a real browser, so this neither re-validates nor re-executes it — use it instead of resending the code whenever you have been iterating with screenshot.

''

Returns:

Type Description
str

JSON payload for MCP App rendering, including the visualization URL.

show_view()

Return the HTML resource used by the show MCP App.

ui

Shared UI components for the Display Server.

banner()

Create a banner indicating alpha/experimental software.

usage

Count how much code each tool actually receives, so issue #58 can be settled with numbers.

The argument for the draft/promote/edit rework is a cost argument: that a review loop was sending the same snippet several times over, and executing it twice per picture. That is measurable, and until it is measured it is only a claim.

Counting happens here, in the display-server process, rather than in the MCP process, for two reasons. Everything arrives here anyway — every tool ends up posting its code to an endpoint — and this process outlives any single tool call, so the totals accumulate across a whole session the way _validation_cache does. The MCP process could count its own arguments, but it has no obvious place to report them from.

Deliberately NOT reported in the show payload. That payload is a message the model reads in full, and dropping the code echo from it was the point of the previous step; adding a telemetry block would spend context to measure context. /api/health is a plain GET nobody pays for.

Nothing here is persisted. A restart is a new session and the numbers start over, which is the intended granularity: the question is what one working session costs, not what the tool has cost forever. Note that an adopted server — one already listening when a new MCP session starts — keeps its existing counts, so read since rather than assuming the totals began with the current session.

logger = logging.getLogger(__name__) module-attribute

record(tool, chars)

Note that tool received chars characters of code.

A call with chars=0 still counts as a call. That case is the point of the measurement rather than a degenerate one: a promotion moves a finished visualization to the user while sending no code at all, and it is only visible as a saving if those calls are counted alongside the ones that do carry a payload.

Parameters:

Name Type Description Default
tool str

Label for the call site, e.g. "show" or "screenshot".

required
chars int

Characters of code carried by this call.

required

reset()

Clear the counters. For tests, and for starting a fresh measurement.

snapshot()

Return the current counts, safe to serialize into a response.

Returns:

Type Description
dict

{"since": iso8601, "total_chars": int, "total_calls": int, "by_tool": {tool: {"chars": int, "calls": int}}}

utils

Utilities for inferring required packages and Panel extensions from code.

logger = logging.getLogger(__name__) module-attribute

ExtensionError

Bases: Exception

Custom exception for missing Panel extensions.

execute_in_module(code, module_name, *, cleanup=True)

Execute Python code in a proper module namespace.

Creates a types.ModuleType following Bokeh's pattern, registers it in sys.modules, executes code, and optionally cleans up. This ensures Panel decorators (@pn.cache, @pn.depends) and function references work properly by using module.dict as a single namespace for both globals and locals.

Parameters:

Name Type Description Default
code str

Python code to execute

required
module_name str

Unique name for the module (should be a valid Python identifier)

required
cleanup bool

Whether to remove module from sys.modules after execution. Set to False if you need to keep the module registered (e.g., for later eval calls), but remember to clean up manually.

True

Returns:

Type Description
dict[str, Any]

The module's namespace (module.dict) after execution

Raises:

Type Description
Exception

Any exception raised during code execution

Notes

This pattern is critical for Panel decorators and code that uses function introspection or cross-references. It follows Bokeh's CodeRunner pattern.

Examples:

>>> namespace = execute_in_module(
...     "x = 1\ny = 2\nz = x + y",
...     "my_module"
... )
>>> namespace['z']
3

extract_last_expression(code)

Extract the last expression from code for jupyter method.

Parameters:

Name Type Description Default
code str

Python code

required

Returns:

Type Description
tuple[str, str]

(statements_code, last_expression_code)

find_extensions(code, namespace=None)

Infer Panel extensions required for code execution.

Maps common packages/types to their Panel extensions: - pandas DataFrame/Series -> "tabulator" - plotly figures -> "plotly" - bokeh models -> (none, default) - matplotlib figures -> (none, uses pngpane) - altair charts -> "vega" - deck.gl -> "deckgl"

Parameters:

Name Type Description Default
code str

Python code to analyze

required
namespace dict[str, Any] | None

Namespace from code execution (optional)

None

Returns:

Type Description
list[str]

List of required Panel extension names

find_requirements(code)

Find package requirements from code.

Uses Panel's built-in find_requirements function to detect package dependencies.

Parameters:

Name Type Description Default
code str

Python code to analyze

required

Returns:

Type Description
list[str]

List of required package names

get_relative_view_url(id)

Generate a relative URL for viewing a visualization by ID.

Parameters:

Name Type Description Default
id str

Visualization ID

required

Returns:

Type Description
str

Relative URL to view the visualization

prepend_env_dll_paths(env)

Prepend conda/pixi environment DLL directories to env["PATH"].

On Windows, compiled extensions such as numpy require <env>/Library/bin and <env>/DLLs to be on PATH so Windows can locate the native DLLs at import time. These entries are added automatically when a shell activates the environment (e.g. via conda activate or pixi run), but external launchers such as MCP clients typically do not activate the environment.

This function is idempotent: if the entries are already present in env["PATH"] they are not duplicated.

It operates on any str → str mapping so it can patch both os.environ (for the current process) and a freshly copied dict (for a subprocess env= argument).

Parameters:

Name Type Description Default
env dict[str, str]

Environment mapping to update in-place. Normally os.environ or a copy of it.

required

Returns:

Type Description
dict[str, str]

The same env mapping, updated in-place (returned for convenience).

validate_code(code)

Execute code in a thread with a timeout to catch runtime errors.

Runs code in a separate thread so the caller is not blocked indefinitely by long-running or hanging code. The thread is a daemon — on timeout it continues running until the process exits (threads cannot be forcibly killed).

Parameters:

Name Type Description Default
code str

Python code to validate as a string.

required

Returns:

Type Description
str

Empty string if the code runs without error, otherwise the traceback or a timeout message.

validate_extension_availability(code)

Validate that required Panel Javascript extensions are loaded in the code.

Parameters:

Name Type Description Default
code str

Python code to analyze

required

Raises:

Type Description
ExtensionError

If a required extension is not loaded.

Example
This code will raise an ExtensionError because 'tabulator' extension is not available:

code = ''' import pandas as pd import panel as pn pn.extension()

df = pd.DataFrame({'A': [1, 2], 'B': [3, 4]}) pn.widgets.Tabulator(df).servable() '''

validate_extension_availability(code)

This code will pass as 'tabulator' extension is included:

code = ''' import pandas as pd import panel as pn pn.extension('tabulator') df = pd.DataFrame({'A': [1, 2], 'B': [3, 4]}) pn.widgets.Tabulator(df).servable() '''

validate_extension_availability(code)

Note

pn.extension("tabulator", "plotly")

and

pn.extension("tabulator") pn.extension("plotly")

will both work correctly.

validation

Code validation pipeline for panel-live-server.

Provides four static validation layers that run before code is stored:

  • Layer 1 ast_check — syntax via ast.parse()
  • Layer 2 ruff_check — security rules via ruff (raises SecurityError)
  • Layer 3 check_packages — all imports are installed
  • Formatting ruff_format — autoformat via ruff format

Runtime execution (Layer 5) lives in utils.validate_code.

BLOCKED_IMPORTS = frozenset({'pickle', 'marshal', 'shelve', 'subprocess', 'multiprocessing', 'threading', 'socket', 'ctypes', 'ftplib', 'smtplib', 'telnetlib', 'webbrowser', 'xmlrpc'}) module-attribute

IMPORT_TO_PACKAGE = {'PIL': 'Pillow', 'sklearn': 'scikit-learn', 'cv2': 'opencv-python', 'skimage': 'scikit-image', 'bs4': 'beautifulsoup4', 'yaml': 'PyYAML', 'dateutil': 'python-dateutil', 'dotenv': 'python-dotenv', 'gi': 'PyGObject', 'wx': 'wxPython', 'Crypto': 'pycryptodome', 'OpenSSL': 'pyOpenSSL', 'usb': 'pyusb', 'serial': 'pyserial', 'magic': 'python-magic', 'attr': 'attrs'} module-attribute

logger = logging.getLogger(__name__) module-attribute

SecurityError

Bases: ToolError

Raised by show() when code contains a security violation.

Given a special class (separate from ValidationError) to signal seriousness — security violations are never auto-fixable and should not be retried without a substantive code rewrite. Particularly relevant in enterprise contexts where security policy enforcement is audited.

ValidationError

Bases: ToolError

Raised by show() when code fails a non-security validation check.

Covers syntax errors, missing packages, and missing Panel extension declarations. The message always begins with the layer name in brackets, e.g. [syntax] invalid syntax so the LLM can identify the failing check at a glance.

ast_check(code)

Return an error string if code has a syntax error, else None.

Parameters:

Name Type Description Default
code str

Python source to check.

required

Returns:

Type Description
str | None

Human-readable error with line/col info, or None if syntax is valid.

Examples:

>>> ast_check("x = 1")
>>> ast_check("if True")
'expected \':\' (line 1, col 8)'

check_packages(code)

Check that all packages imported by code are installed.

Parses imports via AST and calls importlib.util.find_spec() for each top-level module name. Stdlib modules are skipped. Returns an error string for the first missing package, or None if everything is available.

Parameters:

Name Type Description Default
code str

Python source to analyse.

required

Returns:

Type Description
str | None

Error string with install hint, or None if all packages are available.

Examples:

>>> check_packages("import os\\nimport json") is None
True
>>> check_packages("import numpy") is None
True
>>> "HoloViz" in (check_packages("import _totally_fake_pkg_xyz") or "")
True

ruff_check(code)

Run import blocklist and ruff security checks on code.

First performs a fast AST-based blocked-import scan (does not depend on ruff being installed), then runs ruff for deeper static analysis.

Raises SecurityError if any violations are found. Returns None silently if the code is clean or ruff is not installed.

Parameters:

Name Type Description Default
code str

Python source to check.

required

Raises:

Type Description
SecurityError

If a blocked import is found or ruff reports any violations.

Examples:

>>> ruff_check("x = 1")
>>> ruff_check("import pickle")
SecurityError: ...

ruff_format(code)

Autoformat code via ruff format and return the result.

Returns code unchanged if ruff is not installed or formatting fails.

Parameters:

Name Type Description Default
code str

Python source to format.

required

Returns:

Type Description
str

Formatted source, or original source on failure.

Examples:

>>> ruff_format("x=1+2") == "x = 1 + 2\\n"
True