7.4 Blueprint D · Data Analyst Workbench
⚡ Runnable end-to-end
Outcome — analyst asks "show me Q3 sales by region" in plain language; agent runs DuckDB SQL, writes a one-off Python plotting script, saves a PNG into the session scratch dir — all inside a sandbox-exec profile. Stack — Python · DuckDB · Shell tool inside sandbox profile · custom "Analyst" skill · per-session working directory. Source — examples/data-workbench/Run — 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.
Who & why
- Product shape: internal web app; backend per-analyst workspace
- Users: non-engineer analysts who know SQL barely or not at all
- Pain: BI team is a bottleneck for ad-hoc questions; pre-built dashboards don't cover the long tail
Architecture
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)Key code
1 · Sandbox configuration
from agentao.sandbox import SandboxPolicy
policy = SandboxPolicy(project_root=workdir)
policy.set_enabled(True)
policy.set_default_profile("workspace-write-no-network")
agent = Agentao(working_directory=workdir, sandbox_policy=policy)The policy is built in code, per session. Agentao ignores a .agentao/sandbox.json inside the session directory, so the agent cannot loosen its own sandbox by writing one.
Why workspace-write-no-network:
- Analysts never need outbound network inside shell — block SSRF, data exfil
- Full read of the parquet mount; writes only inside workspace
- To let
duckdb/pythonskip the confirmation prompt, add allow rules to the injectedPermissionEngine(5.4); the sandbox does not decide prompts
2 · Skills
<!-- 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.3 · Chart-marker parser in the backend
# app.py (abridged)
from contextlib import aclosing
from agentao import Agentao
from agentao.host import TextDelta
from agentao.sandbox import SandboxPolicy
from pathlib import Path
import re
CHART_RE = re.compile(r"\[CHART\]\s+(\S+?\.png)") # ends at .png: joined deltas from two LLM calls have no separator
@app.post("/ask")
async def ask(req: dict, user=Depends(current_user)):
workdir = Path(f"/workspaces/{user.username}")
workdir.mkdir(exist_ok=True)
policy = SandboxPolicy(project_root=workdir)
policy.set_enabled(True)
policy.set_default_profile("workspace-write-no-network")
agent = Agentao(working_directory=workdir, sandbox_policy=policy)
agent.skill_manager.activate_skill(
"duckdb-analyst",
task_description=f"Answer: {req['question']}",
)
streamed: list[str] = []
try:
async with aclosing(agent.astream(req["question"])) as stream:
async for item in stream:
if isinstance(item, TextDelta):
streamed.append(item.text) # narration included
else:
outcome = item # TurnOutcome: the answer
finally:
await agent.aclose()
# A [CHART] line can come in narration before a tool call, so scan
# everything streamed, joined first (one line can span chunks).
charts = CHART_RE.findall("".join(streamed))
return {
"text": outcome.text,
"is_answer": outcome.is_answer,
"charts": [str(workdir / c) for c in charts],
}4 · Read-only mount of /data
# docker-compose.yml (abridged)
volumes:
- /srv/data:/data:ro # read-only parquet store
- ./workspaces:/workspaces # writable per-analystEnforced at the OS level — even if the sandbox profile is loosened, the mount is RO.
UX detail: show the SQL
Analysts trust answers only when they see the query. Parse the streamed TextDelta text 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.
⚠️ Pitfalls
Day-2 bugs from real data-workbench deployments
Each row below is a real production incident. Skim them before you ship — the fixes are cheap now and expensive later.
| 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 | Build the sandbox policy in code (a workspace sandbox.json is ignored) and keep ~/.agentao/ read-only to the agent; 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 |
Runnable code
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?"