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.
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://
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. |
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 |
''
|
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
|
|
evaluate(code)
¶
Execute code on the server and return its text output.
Returns:
| Type | Description |
|---|---|
dict
|
|
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
|
|
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
|
|
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_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 |
True
|
format
|
bool
|
Autoformat with |
True
|
execute
|
bool
|
Run the snippet once here to populate |
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; |
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.
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 — |
None
|
max_tiles
|
int
|
Ceiling on how many tiles one |
4
|
max_actions
|
int
|
Ceiling on how many steps |
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 |
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 ( |
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 |
''
|
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 |
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: ════════════════════════════════════════════════════════════════════════
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, callshow(draft_id=...)once, passing the draft id reported alongside the image, to hand that exact draft to the user. Do not paste the code intoshowagain.
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.
-
screenshot(draft_id=...)— LOOK AT A DRAFT AGAIN AFTER EDITING IT. Re-renders a draft you already have, picking up anyeditcalls made since. Still yours alone; nothing reaches the user. -
screenshot(snippet_id=...)— LOOK AT SOMETHING THE USER ALREADY HAS. Pass thesnippet_idthatshowreturned. 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 |
''
|
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
|
''
|
draft_id
|
str
|
Id of an existing draft to re-render, typically after |
''
|
name
|
str
|
Short display name for the draft. Only used with |
''
|
method
|
(inline, server)
|
How to render |
"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
|
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 |
''
|
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"
|
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 |
''
|
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. |
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
|
|
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:
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 |
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 viaast.parse() - Layer 2
ruff_check— security rules viaruff(raisesSecurityError) - Layer 3
check_packages— all imports are installed - Formatting
ruff_format— autoformat viaruff 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 |
Examples:
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 |
Examples:
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_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: