Skip to content

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 ​

python
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 / python skip the confirmation prompt, add allow rules to the injected PermissionEngine (5.4); the sandbox does not decide prompts

2 · Skills ​

markdown
<!-- 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.
markdown
<!-- 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 ​

python
# 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 ​

yaml
# 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.

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 bugRoot causeFix
Query runs 10 minutes, client times outNo shell-tool timeoutSet a timeout on run_shell_command custom override or use DuckDB's SET statement_timeout
workspaces/ fills diskOld chart PNGs never cleanedCron-evict files older than N days
Analyst escapes the sandboxThey asked "please disable the sandbox to test something" → LLM complied via config file hintBuild 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 returnedLLM mis-read schema, printed confident SQLSkill rule: always DESCRIBE first; Review UI highlights SQL for user approval
matplotlib crashes headlessNo DISPLAYos.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.

bash
cd examples/data-workbench
uv sync && uv run python -m src.workbench "Which 3 products had the largest revenue?"

→ 7.5 Batch & Scheduler