diff --git a/docs/examples/finance-chart-sandbox/README.mdx b/docs/examples/finance-chart-sandbox/README.mdx
index 62ee87c..47bf972 100644
--- a/docs/examples/finance-chart-sandbox/README.mdx
+++ b/docs/examples/finance-chart-sandbox/README.mdx
@@ -1,42 +1,43 @@
---
title: Finance Chart (Sandbox)
-description: Chart a stock's closing-price history with the Perplexity Agent API sandbox tool — fetched and built inside an isolated container as a background task, then rendered locally.
+description: Chart a stock's closing-price history with the Perplexity Agent API sandbox tool — the sandbox fetches the prices and renders the chart inside an isolated container, returning the CSV and PNG as downloadable files.
sidebar_position: 8
-keywords: [agent-api, sandbox, code-execution, stock-chart, matplotlib, csv, background]
+keywords: [agent-api, sandbox, code-execution, file-creation, stock-chart, matplotlib, csv, png, background]
---
# Finance Chart (Sandbox)
A command-line tool that charts a stock's closing-price history using Perplexity's [Agent API](https://docs.perplexity.ai/docs/agent-api/quickstart) [`sandbox`](https://docs.perplexity.ai/docs/agent-api/tools/sandbox) tool.
-The whole agent loop runs inside **one background Agent API request**:
+Everything runs inside **one background Agent API request**:
-1. The model is given the `sandbox` tool — a full agentic Python environment that includes the Perplexity SDK (web search + URL fetch).
-2. Inside the sandbox it finds and fetches the ticker's historical daily closing prices, parses them, and prints a clean `date,close` CSV to stdout between sentinel fences.
+1. The model is given the `sandbox` tool — a Python environment with `urllib`/`pandas`/`matplotlib` and a writable working directory.
+2. Inside the sandbox it fetches the ticker's daily closing prices from a **pinned data source** (Yahoo Finance's v8 chart JSON endpoint), **writes `prices.csv`**, and **renders `chart.png`**. The sandbox exposes both files as downloadable **artifacts**.
-The script polls the request to completion, pulls the CSV out of the sandbox's stdout, saves it, and renders the line chart **locally** with matplotlib.
+The script polls the request to completion and downloads both files — no local rendering, so it has no third-party dependencies.
-
+
## How it differs from the docs (important)
This recipe was built and verified against the live Agent API. A few realities shape the design:
- **The `sandbox` tool requires a background task.** On the synchronous/streaming path the request is rejected with `streaming failed: ... unknown tool "sandbox"`. You must submit with `background: true` and poll the response by id. This script always does that.
-- **`stdout` is nested.** The execution output lives at `sandbox_results.results[].stdout`, not at the top level of the `sandbox_results` item.
-- **`finance_search` has no history (current deployment).** The top-level `finance_search` tool returns only the latest quote, so the price *series* is gathered from inside the sandbox using its in-container Perplexity SDK.
-- **Sandbox data fetching is best-effort.** Because the sandbox pulls from third-party web sources, requests can be rate-limited. The script retries the whole call a few times (`--attempts`) until it gets a usable CSV.
-- **No PNG comes back.** `sandbox_results` carries only text, so the chart is rendered client-side — and you keep a reusable `.csv`.
+- **The sandbox returns files.** Anything the sandbox writes to its working directory comes back as a `share_file` output item carrying a `file_id`, `filename`, and a ready `/v1/responses/{id}/files/{file_id}/content` URL — you can also list them with `GET /v1/responses/{id}/files`. The script downloads the `prices.csv` and `chart.png` artifacts directly, instead of scraping anything out of `stdout`.
+- **Pin the data source to cut latency.** The slow part of an unconstrained sandbox run is the model *hunting* for a price source (public pages `429` or gate behind captchas — easily 3–7 sandbox calls). Telling it to hit Yahoo's v8 chart JSON endpoint directly turns that into a **single** fetch, and leaves token budget to render the chart in the same session. A typical run is now **one sandbox invocation**.
+- **Give the sandbox output-token headroom.** The sandbox spends `max_output_tokens` *writing the code* that fetches the data and renders the chart. A tight cap can starve the file-writing step (the data gets fetched but the files are never written). This recipe uses `8192`.
+- **`finance_search` has no history (current deployment).** The top-level `finance_search` tool returns only the *latest quote* — a single row — so it can't produce a price *series*. The history is fetched inside the sandbox instead.
+- **Sandbox data fetching is best-effort.** Even a pinned source can rate-limit; the prompt falls back to a second source (Stooq), and the script retries the whole call a few times (`--attempts`).
The Agent API is called over **raw HTTP** (stdlib `urllib`, no SDK) so the exact request body is visible and the endpoint is configurable.
## Features
- One background request orchestrates the sandbox; the script polls it to completion (resilient to transient 5xx)
-- Sandbox fetches the price history itself and emits a fenced `date,close` CSV; the script extracts it from `sandbox_results.results[].stdout` (with message-text fallbacks)
-- Automatic retries until a usable CSV is parsed
-- Renders a clean closing-price line chart with matplotlib (headless `Agg` backend)
-- Saves both a reusable CSV and a PNG
+- The sandbox **fetches the prices and renders the chart itself**, writing a `date,close` CSV and a PNG; the script reads the `share_file` artifacts off the response and downloads them (falling back to the `/files` endpoint)
+- Pinned data source (Yahoo v8 chart JSON, Stooq fallback) keeps it to ~1 sandbox call
+- Automatic retries until both files come back and the CSV parses
+- No third-party dependencies — the chart is rendered server-side, in the sandbox
- Configurable `--base-url` / `PERPLEXITY_BASE_URL`; reports sandbox invocation count and request cost
## Prerequisites
@@ -48,7 +49,8 @@ The Agent API is called over **raw HTTP** (stdlib `urllib`, no SDK) so the exact
```bash
cd docs/examples/finance-chart-sandbox
-pip install -r requirements.txt # matplotlib only — the API is called over raw HTTP
+# No dependencies to install — the API is called over raw HTTP and the chart is
+# rendered inside the sandbox. (requirements.txt is intentionally empty.)
chmod +x finance_chart_sandbox.py
```
@@ -74,7 +76,7 @@ This writes `AAPL_6mo.csv` and `AAPL_6mo.png` to the current directory.
```bash
./finance_chart_sandbox.py TICKER [--period 6mo] [--start YYYY-MM-DD --end YYYY-MM-DD] \
- [--model MODEL] [--attempts 3] [--max-steps 25] [--poll-timeout 300] \
+ [--model MODEL] [--attempts 3] [--max-steps 15] [--poll-timeout 300] \
[--out-dir DIR] [--base-url URL] [--api-key KEY] [--keep-json]
```
@@ -99,22 +101,22 @@ PERPLEXITY_BASE_URL=https://api.perplexity.ai ./finance_chart_sandbox.py NVDA
## Example Output
```
-[attempt 1/3] Asking the sandbox to fetch AAPL closing prices over the past 1 month...
+[attempt 1/3] Asking the sandbox to fetch AAPL closing prices over the past 1 month and plot them...
-Data points: 21 (2026-04-30 → 2026-05-29)
+Data points: 21 (2026-05-08 → 2026-06-08)
CSV: AAPL_1mo.csv
-Chart: AAPL_1mo.png
-Sandbox invocations: 8
-Cost: 0.3509 USD
+Chart: AAPL_1mo.png (fetched and rendered in the sandbox)
+Sandbox invocations: 1
+Cost: 0.1003 USD
```
The CSV (`AAPL_1mo.csv`):
```csv
date,close
-2026-04-30,271.35
-2026-05-01,280.14
-2026-05-04,276.83
+2026-05-08,293.32
+2026-05-11,292.68
+2026-05-12,294.80
...
```
@@ -124,17 +126,19 @@ date,close
A small web app in [`webapp/`](webapp/) puts a **natural-language** front door
on the agent loop: ask *"What was Apple's stock price over the last 6 months?"*
-and the model resolves the ticker and period itself, fetches the prices in the
-sandbox, and the page charts the result.
+and the model resolves the ticker and period itself, fetches the prices and
+renders the chart in the sandbox, and the page shows the result.
Unlike the CLI (which calls the API over raw HTTP), the web backend uses the
-**Perplexity Python SDK** and reuses the CLI module's CSV-extraction and parsing
+**Perplexity Python SDK** and reuses the CLI module's parsing and shared-file
helpers. It runs a **two-phase** flow, because the `sandbox` tool only runs as a
(non-streamable) background task:
1. **Data** — a background call (`client.responses.create(..., background=True)`
then `client.responses.retrieve(id)`) where the sandbox resolves the ticker +
- period, fetches the prices, and prints a `META`/`CSV` block.
+ period, fetches the prices, **writes `prices.csv` and renders `chart.png`**
+ (both downloaded by the backend), and prints a tiny `META` block for routing
+ (ticker + label).
2. **Answer** — a separate **streaming** call (`stream=True`) that writes a short
natural-language analysis of the series, token by token.
@@ -142,16 +146,17 @@ helpers. It runs a **two-phase** flow, because the `sandbox` tool only runs as a
| --- | --- |
| `POST /api/charts` | Submit a question (`{query, attempts?}`) → returns a `job_id` |
| `GET /api/charts/{job_id}/events` | **Server-Sent Events**: `progress` → `chart` → streamed `token`s → `done` |
+| `GET /api/charts/{job_id}/chart.png` | The chart PNG the sandbox rendered |
| `GET /api/charts/{job_id}/response.json` | The **raw Agent API response** from phase 1 (sandbox code, stdout, usage) |
| `GET /api/charts/{job_id}/csv` | Download the `date,close` CSV |
The job runs in a worker thread and writes incremental state onto the job; the
SSE endpoint merely *tails* that state, so reconnects never re-run the work. The
-frontend (vanilla JS + [Chart.js](https://www.chartjs.org/) from a CDN — no
-build step) renders the chart on the `chart` event, appends the streamed
-analysis live, and links to the raw JSON for inspection.
+frontend is vanilla JS with **no build step and no charting library** — on the
+`chart` event it simply points an `` at the sandbox-rendered PNG, appends
+the streamed analysis live, and links to the raw JSON and CSV.
-
+
### Run it
@@ -164,7 +169,7 @@ python app.py # serves http://127.0.0.1:8000
```
Open the page, type a question (or click an example), and hit **Ask**. The
-status line updates per attempt while the background sandbox runs (~30–60s).
+status line updates per attempt while the background sandbox runs (~20–40s).
## Code Walkthrough
@@ -173,15 +178,18 @@ status line updates per attempt while the background sandbox runs (~30–60s).
```python
payload = {
"model": "openai/gpt-5.5",
- "instructions": SYSTEM_PROMPT, # "print the CSV between fences"
- "input": "Produce the daily closing-price CSV for AAPL over the past 6 months. ...",
+ "instructions": SYSTEM_PROMPT, # "fetch from Yahoo v8, write CSV + render PNG"
+ "input": "Fetch this exact URL ... Write prices.csv and render chart.png for AAPL ...",
"tools": [{"type": "sandbox"}],
"background": True, # required for the sandbox tool
- "max_steps": 25,
+ "max_output_tokens": 8192, # headroom for the in-sandbox code
+ "max_steps": 15,
}
# POST https://api.perplexity.ai/v1/responses (Authorization: Bearer )
```
+The prompt pins the data source (`https://query1.finance.yahoo.com/v8/finance/chart/?range=&interval=1d`, Stooq as fallback) so the sandbox fetches in one shot instead of hunting across rate-limited pages.
+
**2. Poll the response by id until it completes.**
```python
@@ -191,38 +199,40 @@ while body["status"] in ("queued", "in_progress"):
body = get(f"/v1/responses/{body['id']}") # tolerate transient 5xx
```
-**3. Pull the CSV out of the nested sandbox stdout.**
+**3. Find the files the sandbox shared and download them.**
```python
for item in body["output"]:
- if item["type"] == "sandbox_results":
- for res in item["results"]:
- stdout = res["stdout"] # contains the fenced CSV
+ if item["type"] == "share_file":
+ url = item["url"] # /v1/responses/{id}/files/{file_id}/content
+ # item["filename"] is "prices.csv" or "chart.png"
+data = get_raw(url) # Authorization: Bearer
```
-The script searches each `sandbox_results.results[].stdout` for text between the `===CSV_START===` / `===CSV_END===` fences (falling back to the message text and a ```` ```csv ```` block), validates it parses into ≥2 `date,close` rows, and retries the whole call if not.
+The script reads the `share_file` items off the response (falling back to `GET /v1/responses/{id}/files` if none are inlined), downloads both the `.csv` and the `.png`, validates the CSV parses into ≥2 `date,close` rows, and retries the whole call if not.
-**4. Render the chart locally.** The CSV is parsed with the stdlib `csv` module and plotted with matplotlib's headless `Agg` backend. Because the sandbox returns only text, rendering lives on the client side and you keep a tidy `.csv`.
+**4. Keep the files.** The CSV and the sandbox-rendered PNG are written to disk — there's nothing to render client-side, so the CLI has no third-party dependencies. (The CSV is parsed only to report the series length.)
## Prompting Guidance
-- **Fence the machine-readable output.** Asking the sandbox to wrap the CSV in unique sentinel lines makes extraction robust even when the model adds commentary or debug prints.
-- **Tell it to retry sources.** Public price endpoints rate-limit (e.g. Yahoo `429`) or gate behind captchas; instructing the model to try another source on failure improves the hit rate.
+- **Make the files the deliverable.** State plainly that the task is complete only once `prices.csv` and `chart.png` exist in the working directory — otherwise the model may answer with the prices in prose and never write the files.
+- **Pin the data source.** Handing the sandbox the exact fetch URL (Yahoo's v8 chart JSON) collapses a multi-call source hunt into a single fetch — the biggest latency win — and frees up budget to render the chart in the same session.
+- **Give output-token headroom.** The sandbox spends `max_output_tokens` writing the code that fetches the data and renders the chart; with too small a cap it runs out before the write step. `8192` is comfortable.
+- **Name a fallback source.** Even a pinned endpoint can `429`; telling the model to fall back to a second source (Stooq) improves the hit rate.
- **Forbid fabrication.** The system prompt instructs the model to use only prices it actually retrieved — never to interpolate or estimate.
## Pricing
- **`sandbox`**: `$0.03` per container session
-- **In-sandbox SDK search queries**: `$0.005` per request (the sandbox issues these to gather the data)
- **Model tokens**: billed separately per Agent API token pricing
-Sandbox invocations are counted under `usage.tool_calls_details.sandbox.invocation`. A typical run here is a few sandbox calls plus a handful of in-sandbox searches. See [Perplexity Pricing](https://docs.perplexity.ai/docs/getting-started/pricing) for current rates.
+Sandbox invocations are counted under `usage.tool_calls_details.sandbox.invocation`, and file sharing under `usage.tool_calls_details.share_file.invocation`. With a pinned data source a typical run is **one** sandbox invocation (no in-container web searches), which keeps cost low (~`$0.10` in our runs). See [Perplexity Pricing](https://docs.perplexity.ai/docs/getting-started/pricing) for current rates.
## Limitations
- `sandbox` is in **preview** and must be run as a background task
-- Price history is fetched from third-party web sources inside the sandbox, so **data accuracy and availability depend on those sources** — values should be sanity-checked, and obscure/non-US tickers may fail
-- Fetching is **best-effort**: rate limits can cause an attempt to return no CSV; the script retries, but a run may still fail (raise `--attempts`)
+- Price history comes from third-party sources (Yahoo v8 chart JSON, Stooq fallback) fetched inside the sandbox, so **data accuracy and availability depend on those sources** — values should be sanity-checked, and obscure/non-US tickers may fail (Stooq expects a `.us` suffix)
+- Fetching is **best-effort**: rate limits can cause an attempt to return no files; the script retries, but a run may still fail (raise `--attempts`)
- Each attempt is a separate billed sandbox session
- This is not investment advice
diff --git a/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py b/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py
index f3687e4..5d27a30 100644
--- a/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py
+++ b/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py
@@ -1,31 +1,40 @@
#!/usr/bin/env python3
"""
Finance Chart (Sandbox) - Plot a stock's closing-price history using the
-Perplexity Agent API ``sandbox`` tool, then render the chart locally.
+Perplexity Agent API ``sandbox`` tool. The sandbox fetches the prices AND
+renders the chart, returning both as files — no local rendering.
-The agent loop runs entirely inside one **background** Agent API request:
+Everything runs inside one **background** Agent API request:
- 1. The model is given the ``sandbox`` tool — a full agentic Python
- environment that includes the Perplexity SDK (web search + URL fetch).
- 2. Inside the sandbox it searches for / fetches the ticker's historical
- daily closing prices, parses them, and prints a clean ``date,close`` CSV
- to stdout between sentinel fences.
+ 1. The model is given the ``sandbox`` tool — a Python environment with
+ ``urllib``/``pandas``/``matplotlib`` and a writable working directory.
+ 2. It fetches the ticker's daily closing prices from a **pinned** data source
+ (Yahoo Finance's v8 chart JSON endpoint), writes them to ``prices.csv``,
+ and renders a line chart to ``chart.png``.
+ 3. Both files are saved to the sandbox workspace, which the Agent API exposes
+ as downloadable **artifacts** (``share_file`` output items).
-We poll the request until it completes, pull the CSV out of the sandbox's
-stdout (``sandbox_results.results[].stdout``), save it, and render the line
-chart locally with matplotlib.
+We poll the request to completion, read the shared files off the response, and
+download the CSV and PNG. This script has **no third-party dependencies** — it
+only speaks raw HTTP.
Why this shape?
- The ``sandbox`` tool is rejected on the synchronous/streaming path
("streaming failed: ... unknown tool"); it must run with ``background: true``
and be polled by id. This script always does that.
-- ``sandbox_results`` carries only text (code/stdout/stderr) — there is no
- binary artifact channel — so the chart is rendered on this side, and you
- also keep a reusable ``.csv``.
-- Top-level ``finance_search`` returns only the latest quote (no history) on
- the current deployment, so the price *series* is gathered from inside the
- sandbox. Because that relies on third-party web data, it is best-effort:
- the script retries the whole call a few times until it gets a usable CSV.
+- The sandbox now creates files. Anything written to the workspace comes back
+ as a ``share_file`` output item (``file_id`` + a ``/v1/responses/{id}/files/
+ {file_id}/content`` url); you can also list them via
+ ``GET /v1/responses/{id}/files``. So both the CSV and the chart PNG are
+ downloaded directly.
+- **Latency: pin the data source.** The slow part of an unconstrained sandbox
+ run is the model *discovering* a working price source (public pages 429 or
+ gate behind captchas). Telling it to hit Yahoo's v8 chart JSON endpoint
+ directly turns a multi-call hunt into a single fetch, which also leaves token
+ budget for rendering the chart in the same session.
+- **``finance_search`` has no history.** The top-level ``finance_search`` tool
+ returns only the *latest quote* (a single row) on the current deployment — it
+ cannot produce a price *series* — so the history is fetched in the sandbox.
The Agent API is called over **raw HTTP** (no SDK) so the request body — and
the sandbox tool in it — is fully visible, and the endpoint is configurable
@@ -41,12 +50,11 @@
import csv
import json
import os
-import re
import sys
import time
import urllib.error
import urllib.request
-from datetime import datetime
+from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
@@ -54,12 +62,17 @@
DEFAULT_BASE_URL = "https://api.perplexity.ai"
RESPONSES_PATH = "/v1/responses"
-CSV_START = "===CSV_START==="
-CSV_END = "===CSV_END==="
+# Filenames the sandbox is told to produce, matched on the way back by suffix.
+CSV_NAME = "prices.csv"
+PNG_NAME = "chart.png"
+# Yahoo's v8 chart JSON understands these range tokens directly; anything else
+# is expressed as an explicit period1/period2 window instead.
+YAHOO_RANGES = {"1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"}
-# Friendly --period values mapped to a natural-language phrase the model can
-# act on. Anything not in this map is passed through verbatim.
+
+# Friendly --period values mapped to a natural-language phrase (used in logs /
+# the chart title). Anything not in this map is passed through verbatim.
PERIOD_PHRASES: Dict[str, str] = {
"1mo": "the past 1 month",
"3mo": "the past 3 months",
@@ -70,30 +83,40 @@
}
-SYSTEM_PROMPT = f"""You run inside a Python sandbox that includes the
-`perplexity` SDK (web search and URL fetch) plus pandas and the standard
-library. Your job is to produce a CSV of a stock's daily closing prices.
-
-Approach:
-- Use the perplexity SDK to obtain the daily closing prices: search the web
- and/or fetch a historical-price page that exposes a clean date/close table.
-- If a source fails or is rate-limited, try a different one. Do not give up
- after a single failure.
-- Print ONLY the final CSV to stdout, wrapped exactly between these fences:
- {CSV_START}
-
- {CSV_END}
- Header must be `date,close`; one row per trading day; sorted ascending by
- date; dates as YYYY-MM-DD; close as a plain number. Put no logs, debug
- output, or commentary inside the fences.
-
-Never fabricate or interpolate prices — use only values you actually
-retrieved."""
+SYSTEM_PROMPT = f"""You run inside a Python sandbox with a writable working
+directory that includes `urllib`/`requests`, `pandas`, `matplotlib`, and the
+standard library. Your job is to produce two files: a CSV of a stock's daily
+closing prices and a line chart of them.
+
+The two files ARE the deliverable. The task is complete only once `{CSV_NAME}`
+and `{PNG_NAME}` exist in the working directory — they are returned to the
+caller as downloadable artifacts. Do not end your turn with a text answer in
+place of the files.
+
+Steps:
+1. Fetch the daily closing prices from the EXACT URL you are given (Yahoo
+ Finance's v8 chart JSON), sending a browser `User-Agent` header such as
+ `Mozilla/5.0`. Parse `result.timestamp` (epoch seconds) together with
+ `result.indicators.quote[0].close`; drop any null closes. If that request
+ fails or is rate-limited, fall back to the Stooq daily CSV
+ (`https://stooq.com/q/d/l/?s=.us&i=d`) and filter to the window.
+ Never fabricate or interpolate prices.
+2. Write the data to `{CSV_NAME}`: header `date,close`; one row per trading
+ day; sorted ascending by date; dates YYYY-MM-DD; close as a plain number.
+3. Render a closing-price line chart with matplotlib (headless `Agg` backend)
+ and save it to `{PNG_NAME}`:
+ - figure ~10x5 inches at 150 dpi
+ - a single line in color #1f77b4, ~1.6pt wide, with a light fill below it
+ - dashed gridlines, x-axis label "Date", y-axis label "Close (USD)"
+ - the exact title you are given
+ - concise, auto-spaced date ticks on the x-axis
+4. Verify both files exist, then print only a one-line confirmation."""
PROMPT_TEMPLATE = (
- "Produce the daily closing-price CSV for {ticker} over {period_phrase}. "
- "Print it to stdout between the {start} / {end} fences."
+ "Fetch this exact URL for the daily closing prices: {url}\n"
+ "Write {csv} and render {png} for {ticker} over {period_phrase}. "
+ 'Title the chart exactly "{ticker} closing price — {period_label}".'
)
@@ -141,7 +164,7 @@ def _request(
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
- "User-Agent": "api-cookbook-finance-chart-sandbox/1.0",
+ "User-Agent": "api-cookbook-finance-chart-sandbox/3.0",
},
method=method,
)
@@ -155,6 +178,21 @@ def _request(
return err.code, {"error": {"message": err.reason}}
+def _download(base_url: str, key: str, url_or_path: str, timeout: int = 120) -> bytes:
+ """GET a file's raw bytes from an absolute URL or a base-relative path."""
+ url = url_or_path if url_or_path.startswith("http") else base_url + url_or_path
+ req = urllib.request.Request(
+ url,
+ headers={
+ "Authorization": f"Bearer {key}",
+ "User-Agent": "api-cookbook-finance-chart-sandbox/3.0",
+ },
+ method="GET",
+ )
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return resp.read()
+
+
def _poll(base_url: str, key: str, response_id: str, deadline: float) -> dict:
"""Poll a background response until terminal status (resilient to 5xx)."""
url = f"{base_url}{RESPONSES_PATH}/{response_id}"
@@ -170,11 +208,41 @@ def _poll(base_url: str, key: str, response_id: str, deadline: float) -> dict:
return body
+def yahoo_chart_url(
+ ticker: str, period: str, start: Optional[str], end: Optional[str]
+) -> str:
+ """Build the Yahoo v8 chart JSON URL for the ticker over the window.
+
+ Uses a ``range`` token for the standard lookback periods; an explicit
+ ``period1``/``period2`` epoch window for date ranges or non-standard
+ periods.
+ """
+ base = f"https://query1.finance.yahoo.com/v8/finance/chart/{ticker.upper()}"
+ if not start and not end and period in YAHOO_RANGES:
+ return f"{base}?range={period}&interval=1d"
+
+ def _epoch(date_str: str) -> int:
+ return int(
+ datetime.strptime(date_str, "%Y-%m-%d")
+ .replace(tzinfo=timezone.utc)
+ .timestamp()
+ )
+
+ p1 = _epoch(start) if start else 0
+ # +1 day so the end date itself is included.
+ p2 = _epoch(end) + 86400 if end else int(time.time())
+ return f"{base}?period1={p1}&period2={p2}&interval=1d"
+
+
def run_sandbox_request(
base_url: str,
key: str,
ticker: str,
+ period: str,
+ period_label: str,
period_phrase: str,
+ start: Optional[str],
+ end: Optional[str],
model: str,
max_steps: int,
poll_timeout: int,
@@ -184,14 +252,19 @@ def run_sandbox_request(
"model": model,
"instructions": SYSTEM_PROMPT,
"input": PROMPT_TEMPLATE.format(
+ url=yahoo_chart_url(ticker, period, start, end),
ticker=ticker.upper(),
+ period_label=period_label,
period_phrase=period_phrase,
- start=CSV_START,
- end=CSV_END,
+ csv=CSV_NAME,
+ png=PNG_NAME,
),
"tools": [{"type": "sandbox"}],
"background": True,
- "max_output_tokens": 4096,
+ # Headroom: the sandbox spends output tokens writing the code that
+ # fetches the data and renders the chart; a tight cap can starve the
+ # file-writing step.
+ "max_output_tokens": 8192,
"max_steps": max_steps,
}
status, body = _request(
@@ -205,60 +278,53 @@ def run_sandbox_request(
# ---------------------------------------------------------------------------
-# Response parsing
+# Files the sandbox produced
# ---------------------------------------------------------------------------
-def _sandbox_stdout(response: dict) -> str:
- """Concatenate stdout from every sandbox execution result."""
- chunks: List[str] = []
- for item in response.get("output", []) or []:
- if item.get("type") != "sandbox_results":
- continue
- # Real shape nests executions under `results`; tolerate a flat shape.
- results = item.get("results")
- if results:
- for res in results:
- if res.get("stdout"):
- chunks.append(res["stdout"])
- elif item.get("stdout"):
- chunks.append(item["stdout"])
- return "\n".join(chunks)
-
-
-def _message_text(response: dict) -> str:
- """Concatenate assistant ``output_text`` blocks."""
- chunks: List[str] = []
- for item in response.get("output", []) or []:
- if item.get("type") != "message":
- continue
- for block in item.get("content", []) or []:
- if block.get("type") == "output_text" and block.get("text"):
- chunks.append(block["text"])
- return "\n".join(chunks)
+def shared_files(response: dict, base_url: str, key: str) -> List[Dict[str, str]]:
+ """List files the sandbox shared, as ``[{filename, url}]``.
-
-def extract_csv(response: dict) -> Optional[str]:
- """Find the fenced CSV in the sandbox stdout, then the message text.
-
- Returns the CSV body (without fences), or None if nothing usable is found.
+ Prefers the ``share_file`` items embedded in the response ``output`` (they
+ carry a ready download ``url``); falls back to ``GET /v1/responses/{id}/
+ files`` and constructs the content path.
"""
- fence = re.compile(
- re.escape(CSV_START) + r"\s*(.*?)\s*" + re.escape(CSV_END), re.S
+ files: List[Dict[str, str]] = []
+ for item in response.get("output", []) or []:
+ if item.get("type") == "share_file" and item.get("url"):
+ files.append({"filename": item.get("filename", ""), "url": item["url"]})
+ if files:
+ return files
+
+ response_id = response.get("id")
+ if not response_id:
+ return files
+ status, body = _request(
+ "GET", f"{base_url}{RESPONSES_PATH}/{response_id}/files", key, None, timeout=60
)
- for haystack in (_sandbox_stdout(response), _message_text(response)):
- match = fence.search(haystack)
- if match and match.group(1).strip():
- return match.group(1).strip()
- # Fallback: a fenced ```csv block in the message.
- block = re.search(r"```csv\s*(.*?)```", _message_text(response), re.S)
- if block:
- lines = block.group(1).strip().splitlines()
- if lines and "date" in lines[0].lower():
- return block.group(1).strip()
+ if status >= 400:
+ return files
+ for item in body.get("data", []) or []:
+ if item.get("id"):
+ files.append({
+ "filename": item.get("filename", ""),
+ "url": f"{RESPONSES_PATH}/{response_id}/files/{item['id']}/content",
+ })
+ return files
+
+
+def pick_file(files: List[Dict[str, str]], suffix: str) -> Optional[Dict[str, str]]:
+ """Return the first shared file whose name ends with ``suffix``."""
+ for f in files:
+ if f.get("filename", "").lower().endswith(suffix):
+ return f
return None
def parse_csv(csv_text: str) -> Tuple[List[datetime], List[float]]:
- """Parse `date,close` CSV text into parallel lists, sorted by date."""
+ """Parse `date,close` CSV text into parallel lists, sorted by date.
+
+ Used to validate the downloaded CSV and report the series length — the
+ chart itself is rendered inside the sandbox.
+ """
reader = csv.DictReader(csv_text.splitlines())
if not reader.fieldnames:
raise RuntimeError("Empty CSV.")
@@ -287,37 +353,6 @@ def parse_csv(csv_text: str) -> Tuple[List[datetime], List[float]]:
return [r[0] for r in rows], [r[1] for r in rows]
-def render_chart(
- dates: List[datetime],
- closes: List[float],
- ticker: str,
- period_label: str,
- png_path: Path,
-) -> None:
- """Render a closing-price line chart to ``png_path``."""
- import matplotlib
-
- matplotlib.use("Agg") # headless: no display needed
- import matplotlib.pyplot as plt
- from matplotlib.dates import AutoDateLocator, ConciseDateFormatter
-
- fig, ax = plt.subplots(figsize=(10, 5))
- ax.plot(dates, closes, color="#1f77b4", linewidth=1.6)
- ax.fill_between(dates, closes, min(closes), color="#1f77b4", alpha=0.08)
- ax.set_title(f"{ticker.upper()} closing price — {period_label}")
- ax.set_xlabel("Date")
- ax.set_ylabel("Close (USD)")
- ax.grid(True, linestyle="--", alpha=0.4)
-
- locator = AutoDateLocator()
- ax.xaxis.set_major_locator(locator)
- ax.xaxis.set_major_formatter(ConciseDateFormatter(locator))
-
- fig.tight_layout()
- fig.savefig(png_path, dpi=150)
- plt.close(fig)
-
-
def sandbox_invocations(response: dict) -> int:
details = (response.get("usage") or {}).get("tool_calls_details") or {}
return (details.get("sandbox") or {}).get("invocation", 0) or 0
@@ -347,23 +382,27 @@ def build_period_phrase(
return period, PERIOD_PHRASES.get(period, f"the past {period}")
-def fetch_price_series(
+def fetch_chart(
base_url: str,
key: str,
ticker: str,
+ period: str,
+ period_label: str,
period_phrase: str,
+ start: Optional[str],
+ end: Optional[str],
model: str,
attempts: int,
max_steps: int,
poll_timeout: int,
on_attempt=None,
-) -> Tuple[List[datetime], List[float], str, dict]:
- """Run up to ``attempts`` background sandbox calls until a usable CSV parses.
+) -> Tuple[bytes, bytes, List[datetime], dict]:
+ """Run up to ``attempts`` background sandbox calls until both files come back.
- Returns ``(dates, closes, csv_text, response)``. Raises ``RuntimeError`` if
- no attempt yields a parseable ``date,close`` CSV. ``on_attempt(n, total,
- note)`` is an optional progress callback (``note`` is None at the start of
- an attempt, or a short failure reason).
+ Returns ``(csv_bytes, png_bytes, dates, response)``. Raises ``RuntimeError``
+ if no attempt yields a downloadable CSV+PNG pair with a usable series.
+ ``on_attempt(n, total, note)`` is an optional progress callback (``note`` is
+ None at the start of an attempt, or a short failure reason).
"""
response: dict = {}
for attempt in range(1, attempts + 1):
@@ -371,7 +410,8 @@ def fetch_price_series(
on_attempt(attempt, attempts, None)
try:
response = run_sandbox_request(
- base_url, key, ticker, period_phrase, model, max_steps, poll_timeout
+ base_url, key, ticker, period, period_label, period_phrase,
+ start, end, model, max_steps, poll_timeout,
)
except (RuntimeError, TimeoutError) as err:
if on_attempt:
@@ -383,21 +423,33 @@ def fetch_price_series(
on_attempt(attempt, attempts, f"request failed: {response.get('error')}")
continue
- candidate = extract_csv(response)
- if not candidate:
+ files = shared_files(response, base_url, key)
+ csv_file = pick_file(files, ".csv")
+ png_file = pick_file(files, ".png")
+ if not csv_file or not png_file:
+ have = ", ".join(f.get("filename", "?") for f in files) or "none"
if on_attempt:
- on_attempt(attempt, attempts, "no fenced CSV in output")
+ on_attempt(attempt, attempts, f"missing CSV/PNG (got: {have})")
continue
+
+ try:
+ csv_bytes = _download(base_url, key, csv_file["url"])
+ png_bytes = _download(base_url, key, png_file["url"])
+ except (urllib.error.URLError, TimeoutError) as err:
+ if on_attempt:
+ on_attempt(attempt, attempts, f"download failed: {err}")
+ continue
+
try:
- dates, closes = parse_csv(candidate)
+ dates, _ = parse_csv(csv_bytes.decode("utf-8", "replace"))
except RuntimeError as err:
if on_attempt:
on_attempt(attempt, attempts, f"unusable CSV: {err}")
continue
- return dates, closes, candidate, response
+ return csv_bytes, png_bytes, dates, response
raise RuntimeError(
- f"Could not obtain a usable price CSV from the sandbox after "
+ f"Could not obtain a usable chart from the sandbox after "
f"{attempts} attempt(s). Sandbox data fetching is best-effort "
"(third-party sources rate-limit); try more attempts or rerun."
)
@@ -407,7 +459,8 @@ def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Plot a stock's closing-price history using the Perplexity Agent "
- "API sandbox tool (background task), rendered locally."
+ "API sandbox tool (background task). The sandbox fetches the prices "
+ "and renders the chart; both come back as downloadable files."
)
)
parser.add_argument("ticker", help="Ticker symbol, e.g. AAPL, MSFT, NVDA.")
@@ -424,11 +477,11 @@ def main() -> int:
"--attempts",
type=int,
default=3,
- help="Max background calls to try until a usable CSV comes back "
+ help="Max background calls to try until the chart comes back "
"(each is a separate sandbox session). Default 3.",
)
parser.add_argument(
- "--max-steps", type=int, default=25, help="Agent max_steps per attempt."
+ "--max-steps", type=int, default=15, help="Agent max_steps per attempt."
)
parser.add_argument(
"--poll-timeout",
@@ -468,16 +521,17 @@ def _log(attempt: int, total: int, note: Optional[str]) -> None:
if note is None:
print(
f"[attempt {attempt}/{total}] Asking the sandbox to fetch "
- f"{ticker} closing prices over {period_phrase}...",
+ f"{ticker} closing prices over {period_phrase} and plot them...",
file=sys.stderr,
)
else:
print(f" {note}", file=sys.stderr)
try:
- dates, closes, csv_text, response = fetch_price_series(
- args.base_url, key, ticker, period_phrase, args.model,
- args.attempts, args.max_steps, args.poll_timeout, on_attempt=_log,
+ csv_bytes, png_bytes, dates, response = fetch_chart(
+ args.base_url, key, ticker, args.period, period_label, period_phrase,
+ args.start, args.end, args.model, args.attempts, args.max_steps,
+ args.poll_timeout, on_attempt=_log,
)
except RuntimeError as err:
print(f"Error: {err}", file=sys.stderr)
@@ -486,13 +540,13 @@ def _log(attempt: int, total: int, note: Optional[str]) -> None:
if args.keep_json and response:
(out_dir / f"{slug}.json").write_text(json.dumps(response, indent=2))
- csv_path.write_text(csv_text + "\n")
- render_chart(dates, closes, ticker, period_label, png_path)
+ csv_path.write_bytes(csv_bytes)
+ png_path.write_bytes(png_bytes)
print(f"\nData points: {len(dates)} "
f"({dates[0]:%Y-%m-%d} → {dates[-1]:%Y-%m-%d})")
print(f"CSV: {csv_path}")
- print(f"Chart: {png_path}")
+ print(f"Chart: {png_path} (fetched and rendered in the sandbox)")
print(f"Sandbox invocations: {sandbox_invocations(response)}")
cost = total_cost(response)
if cost is not None:
diff --git a/docs/examples/finance-chart-sandbox/requirements.txt b/docs/examples/finance-chart-sandbox/requirements.txt
index 632e087..d1d59d8 100644
--- a/docs/examples/finance-chart-sandbox/requirements.txt
+++ b/docs/examples/finance-chart-sandbox/requirements.txt
@@ -1,2 +1,5 @@
-# The Agent API is called over raw HTTP (stdlib urllib) — no SDK needed.
-matplotlib>=3.7
+# No third-party dependencies.
+#
+# The Agent API is called over raw HTTP (stdlib urllib). The sandbox both
+# fetches the prices and renders the chart, so the CSV and PNG are downloaded
+# as files — this CLI needs nothing beyond the Python standard library (3.9+).
diff --git a/docs/examples/finance-chart-sandbox/webapp/app.py b/docs/examples/finance-chart-sandbox/webapp/app.py
index 0f3fd53..427b821 100644
--- a/docs/examples/finance-chart-sandbox/webapp/app.py
+++ b/docs/examples/finance-chart-sandbox/webapp/app.py
@@ -8,7 +8,8 @@
Phase 1 (data) A *background* Agent API request gives the model the
``sandbox`` tool, which resolves the ticker + period from
the question, fetches the daily closing prices inside an
- isolated container, and prints a META + ``date,close`` CSV.
+ isolated container, and **writes them to a CSV file**
+ (downloaded here) plus a tiny META block on stdout.
Phase 2 (answer) A *streaming* request (no sandbox) writes a short
natural-language analysis of that series, token by token.
@@ -26,7 +27,7 @@
Execution runs in a worker thread and writes incremental state onto the job, so
the SSE stream merely *tails* that state — reconnects never re-run the work.
-CSV-extraction/parsing helpers are reused from the CLI module
+CSV parsing and shared-file helpers are reused from the CLI module
(``finance_chart_sandbox``); only the API call differs (SDK here, raw HTTP there).
"""
@@ -59,34 +60,40 @@
_LOCK = threading.Lock()
DEFAULT_MODEL = "openai/gpt-5.5"
+POLL_TIMEOUT = 300
META_START = "===META_START==="
META_END = "===META_END==="
-# Phase 1: data-gathering inside the sandbox.
+# Phase 1: data-gathering AND charting inside the sandbox.
DATA_PROMPT = f"""You answer natural-language questions about a stock's recent
-price history by producing a chart-ready CSV.
-
-You have the `sandbox` tool — an isolated Python environment that includes the
-`perplexity` SDK (web search and URL fetch) plus pandas and the standard
-library.
-
-Do this:
-1. Read the user's question and determine the stock TICKER (resolve a company
- name to its symbol, e.g. "apple" -> AAPL) and the time PERIOD they asked
- about (default to the last 6 months if none is given).
-2. Use the sandbox to obtain the DAILY closing prices for that ticker over that
- period: search the web and/or fetch a historical-price page with a clean
- date/close table. If a source fails or is rate-limited, try another. Never
- fabricate or interpolate prices — use only values you actually retrieved.
-3. Print to stdout, in exactly this order and nothing else:
+price history by producing a CSV file and a line-chart PNG.
+
+You have the `sandbox` tool — an isolated Python environment with
+`urllib`/`requests`, pandas, matplotlib, the standard library, and a writable
+working directory.
+
+The files `{fcs.CSV_NAME}` and `{fcs.PNG_NAME}` ARE the deliverable; they are
+returned to the caller as downloadable artifacts. The task is complete only
+once both exist. Do this:
+1. Read the question and determine the stock TICKER (resolve a company name to
+ its symbol, e.g. "apple" -> AAPL) and the PERIOD as a Yahoo range token
+ (1mo, 3mo, 6mo, 1y, 2y, 5y; default 6mo).
+2. Fetch the daily closing prices from Yahoo Finance's v8 chart JSON at exactly
+ `https://query1.finance.yahoo.com/v8/finance/chart/?range=&interval=1d`
+ with a browser `User-Agent` header (e.g. `Mozilla/5.0`). Parse
+ `result.timestamp` (epoch) with `result.indicators.quote[0].close`; drop
+ null closes. If it is rate-limited, fall back to the Stooq daily CSV
+ (`https://stooq.com/q/d/l/?s=.us&i=d`). Never fabricate prices.
+3. Write `{fcs.CSV_NAME}`: header `date,close`; ascending; dates YYYY-MM-DD;
+ close a plain number.
+4. Render a line chart and save `{fcs.PNG_NAME}` (~10x5in @150dpi; line #1f77b4
+ with a light fill; dashed grid; x-label "Date", y-label "Close (USD)";
+ title " closing price —