Run this example:
examples/data-workbench/—uv run python -m src.workbench "your question"
Scenario: internal analysts have a Jupyter-like workbench. You want to add “ask in English / Chinese, get back a chart and the SQL” — Agentao runs duckdb, writes a one-off Python script, and saves a PNG into the session’s scratch directory. Since shell is involved, the sandbox is non-negotiable.
Web UI (chart viewer + transcript)
│
▼
Backend (FastAPI) ──┐
│ │
▼ │
Agentao instance │
├─ working_directory = /workspaces/alice/
├─ Tools:
│ - run_shell_command (sandbox: workspace-write-no-network)
│ - read_file / write_file
│ - glob / grep
├─ Skills: "duckdb-analyst" + "matplotlib-charts"
└─ Read-only mount of /data (parquet dumps)
// .agentao/sandbox.json
{
"shell": {
"enabled": true,
"default_profile": "workspace-write-no-network",
"allow_network": false,
"allowed_commands_without_confirm": [
"duckdb", "python", "python3", "uv", "head", "wc", "ls", "cat"
]
}
}
Why workspace-write-no-network:
duckdb / python skip the confirmation prompt for interactivity<!-- skills/duckdb-analyst/SKILL.md -->
---
name: duckdb-analyst
description: Use for any analytical question over /data/*.parquet. Prefer DuckDB, always show the SQL.
---
# DuckDB Analyst
## Conventions
- Data lives in `/data/*.parquet` (read-only). Never write there.
- Use DuckDB (`duckdb` CLI or `import duckdb` in Python).
- Always print the SQL you ran; cap to 1000 rows by default for speed.
- Save intermediate results as `workspace/cache-<slug>.parquet`.
## Workflow
1. `ls /data` to discover files
2. `duckdb -c "DESCRIBE SELECT * FROM read_parquet('/data/X.parquet') LIMIT 0"` to learn schema
3. Write the query; run with `LIMIT 1000`
4. If the user wants a chart, activate `matplotlib-charts`
## Guardrails
- If a query would scan > 10 GB, warn and ask first.
- Never `DELETE`, `UPDATE`, `DROP` — DuckDB on parquet can't anyway, but the LLM must still not suggest it.
<!-- skills/matplotlib-charts/SKILL.md -->
---
name: matplotlib-charts
description: Produce PNG charts with matplotlib. Save to workspace/chart-<ts>.png.
---
# Matplotlib Charts
## Format
- 1 chart per question. No subplots unless asked.
- Dark-mode friendly palette: `matplotlib.style.use("default")`; set a readable size `figsize=(10, 6)`.
- Save with `plt.savefig(path, dpi=120, bbox_inches="tight")` — `plt.show()` does nothing in a headless env.
## Return contract
After saving the chart, print exactly:
`[CHART] workspace/chart-<ts>.png`
The UI parses this marker to render the image.
# app.py (abridged)
from agentao import Agentao
from agentao.transport import SdkTransport
from agentao.transport.events import EventType
from pathlib import Path
import re, asyncio
CHART_RE = re.compile(r"\[CHART\]\s+(\S+)")
@app.post("/ask")
async def ask(req: dict, user=Depends(current_user)):
workdir = Path(f"/workspaces/{user.username}")
workdir.mkdir(exist_ok=True)
charts: list[str] = []
def on_event(ev):
if ev.type is EventType.LLM_TEXT:
for m in CHART_RE.finditer(ev.data["chunk"]):
charts.append(m.group(1))
transport = SdkTransport(on_event=on_event)
agent = Agentao(working_directory=workdir, transport=transport)
agent.skill_manager.activate_skill(
"duckdb-analyst",
task_description=f"Answer: {req['question']}",
)
reply = await asyncio.to_thread(agent.chat, req["question"])
agent.close()
return {
"text": reply,
"charts": [str(workdir / c) for c in charts],
}
/data# docker-compose.yml (abridged)
volumes:
- /srv/data:/data:ro # read-only parquet store
- ./workspaces:/workspaces # writable per-analyst
Enforced at the OS level — even if the sandbox profile is loosened, the mount is RO.
Analysts trust answers only when they see the query. Parse LLM_TEXT chunks for fenced ```sql blocks client-side and render them as copy-able code. The duckdb-analyst skill’s “always print the SQL” rule makes this reliable.
| Day-2 bug | Root cause | Fix |
|---|---|---|
| Query runs 10 minutes, client times out | No shell-tool timeout | Set a timeout on run_shell_command custom override or use DuckDB’s SET statement_timeout |
workspaces/ fills disk |
Old chart PNGs never cleaned | Cron-evict files older than N days |
| Analyst escapes the sandbox | They asked “please disable the sandbox to test something” → LLM complied via config file hint | Keep sandbox config outside the writable workspace; prompt injection guardrails (6.5) |
| Wrong data returned | LLM mis-read schema, printed confident SQL | Skill rule: always DESCRIBE first; Review UI highlights SQL for user approval |
matplotlib crashes headless |
No DISPLAY | os.environ["MPLBACKEND"] = "Agg" in the entrypoint |
The full project lives in-repo at examples/data-workbench/ — see the top-of-page “Run this example” link.
cd examples/data-workbench
uv sync && uv run python -m src.workbench "Which 3 products had the largest revenue?"