Skip to main content
Build a tool that researches any topic and creates an interactive HTML report, using Perplexity’s Agent API and perplexity/kimi-k3.
The output is a draft. Review the claims before you publish anything.

Prerequisites

  • Python 3.10 or newer (tested on 3.12)
  • A Perplexity API key
  • Internet access for live runs

Installation

Copy the ten parts under Full code into a single file named data_story.py, then set up an environment:
The pinned version keeps request serialization and background-response handling predictable.

API key setup

The SDK picks this up automatically. Don’t paste the key into the script.

Quick start

That runs the default quick profile. For a higher-budget version at a specific path:

Usage

Preview the exact request without spending anything:
If a run outlives your terminal, pick it back up instead of paying for a new one:
Reuse the same --receipt path when resuming so the original request, any earlier errors, and the resume history all land in one file. It defaults to <output>.receipt.json.

Configuration

Override any of it:
max_output_tokens is a ceiling, not a reservation. You’re billed for the work the run actually does. Check current pricing before large runs. The ceiling covers reasoning tokens as well as the visible page, and this model reasons at length before it writes. A budget that looks generous for a single HTML file can still run out mid-document, which is why quick sets 49,152 rather than a number closer to the finished page size. If a run does exhaust its budget, the script says so and names the ceiling instead of reporting a malformed document. All six effort levels work. In perplexityai==0.43.1 the generated type doesn’t include max, so the script routes that one value through the SDK’s extra_body pass-through and uses the typed reasoning field for everything else.

Dry-run preview

--dry-run is deterministic, needs no API key, and spends nothing. Abridged output:
A completed run writes two files:

How it works

  1. Build one Agent API request with web_search, an effort level, an output ceiling, and a step limit.
  2. Submit it once with background=True and store=True, with create-retries off.
  3. The response ID comes back immediately. Write it to the receipt before anything else, because that ID is your recovery path.
  4. Poll client.responses.retrieve(response_id) with bounded timeouts and backoff, printing status changes and new search queries as they appear.
  5. Kimi K3 tags every stat card and chart mark with a numeric result ID and leaves a PERPLEXITY_SOURCES placeholder.
  6. On completion, validate the document, match every referenced ID against the API’s search_results, inject the real URLs and a Content Security Policy, then write the file atomically.
Only completed counts as success. queued and in_progress mean keep waiting; anything else is treated as terminal, so a new status can never trap you in an infinite poll. If the create call dies before returning an ID, the outcome is genuinely unknown. The CLI records submission_unknown and stops rather than risk double-billing you. Check your API activity before resubmitting.

Trace every number to a source

K3 never writes an external URL. The prompt asks it for citation fragments like #source-3 and data-source-id="3", and the CLI substitutes the real URL for result 3 from the API’s structured output. A model can hallucinate a URL; it can’t hallucinate an array index that has to match. The validator rejects incomplete documents, unknown source IDs, model-written URLs, remote assets, frames, active forms, network-capable JavaScript, non-focusable chart marks, SVG SMIL animation, and marks drawn outside their chart’s viewBox. It also blocks global svg { width: 100% } rules, which quietly turn 16px icons into full-page graphics. That catches structural and rendering failures. It says nothing about whether a sentence interprets its source correctly.

Prompting notes

  • Keep research and page-building in one two-phase prompt so K3 can connect source IDs to the markup it writes.
  • Ask for the source’s own terminology and rule out inferred scope. A citation can be real while the sentence around it overstates the finding.
  • Specify the chart contract mechanically: numeric data-source-id, matching citation, focusable geometry, shared axis scale, visible bounds, tooltip per mark.
  • Require CSS animation with a reduced-motion query. SMIL is rejected because CSS can’t reliably switch it off.
  • Scope responsive sizing to figure svg, never to every SVG on the page.

Full code

The script is split into ten parts below. Each part is collapsed so you can read what it does before opening it. Expand a part to read or copy it, and append the ten in order into a single file named data_story.py.

1. Configure the model and run profiles

Imports, the model ID, and the two run profiles. PENDING_STATUSES is the allowlist that keeps polling alive. Every status outside it ends the run, so a new server-side status can never leave you looping forever. CSP_META is the Content Security Policy stamped into the finished page, which is what makes the output safe to open locally.
data_story.py (part 1 of 10)

2. Write the two-phase prompt

One prompt, two phases: research first, then build the page. Keeping them together is deliberate, because K3 needs the search results in the same context to wire each number back to the result that produced it. Note that it asks for data-source-id="3", never a URL. That’s the whole grounding trick.
data_story.py (part 2 of 10)

3. Define the errors and the run receipt

Five exception types that separate “still working” from “failed” from “we genuinely don’t know.” SubmissionUnknownError is the important one: if the connection dies before an ID comes back, you can’t tell whether you were billed. atomic_write_text writes to a temp file and renames, so a crash mid-write can’t leave you with a half-written report.
data_story.py (part 3 of 10)

4. Build the request and record the response

build_request assembles the payload and routes max effort through extra_body (the typed field doesn’t accept it in SDK 0.43.1). checkpoint_response saves the response ID to disk the moment it arrives, before any other work, so a crash on the next line still leaves you a --resume path.
data_story.py (part 4 of 10)

5. Poll until the run finishes

Retrieve the response on an interval until it finishes or the local deadline expires. Transient errors get bounded retries; new search queries print as they appear so a ten-minute run isn’t a blank terminal. Hitting the deadline raises rather than killing the job. The run keeps going server-side and you resume with the ID.
data_story.py (part 5 of 10)

6. Submit the run exactly once

The paid call, made exactly once. background=True and store=True make it durable; automatic create-retries are turned off so a flaky network can’t quietly submit twice. Everything after this point is recovery rather than resubmission.
data_story.py (part 6 of 10)

7. Extract the HTML and measure the SVG

Pull the document out of the response, then the geometry helpers: svg_viewbox reads the coordinate space and primitive_bounds computes where a shape actually lands. These feed the check that catches chart marks drawn outside their own chart. check_output_budget runs first and handles the most common way a run fails. When the model spends its entire output budget, the page stops mid-tag and the document is genuinely malformed, so every downstream check reports a broken document rather than the real cause. Comparing output_tokens against the ceiling separates the two, and the error names the ceiling and the flag to raise it.
data_story.py (part 7 of 10)

8. Validate the generated document

An HTMLParser subclass that walks the generated document and rejects anything unsafe or broken: remote assets, frames, active forms, network-capable JavaScript, unknown source IDs, model-written URLs, non-focusable chart marks, SMIL animation, out-of-bounds geometry, and global svg { width: 100% } rules. The longest section here, and the reason the output is trustworthy enough to open.
data_story.py (part 8 of 10)

9. Inject the real source URLs

Where grounding actually happens. Every data-source-id the model wrote is matched against the API’s structured search_results, and the real title, date, and URL are substituted in. An ID with no matching result fails the run. Three details keep the injected list from looking bolted on. has_sources_heading checks whether the model already wrote its own Sources heading above the placeholder, and skips ours when it did, so the page never shows the word twice. SOURCE_STYLE then styles the list using inherit and currentColor rather than fixed colors, so it picks up whatever palette the model chose and a :target rule highlights the entry a citation jumps to. renumber_sources handles the numbering. The model cites raw search-result IDs, which are sparse, so a marker reading [33] would sit above an ordered list that renders it as 3.. The function maps each cited ID to its position in order of first appearance, then rewrites the marker text, the anchor target, and the data-source-id attribute together in a single pass so a swap like 1 to 2 and 2 to 1 cannot cascade.
data_story.py (part 9 of 10)

10. Parse arguments and run the CLI

Argument parsing, cost formatting, and main, which wires it all together: parse, resolve the profile, submit or resume, poll, validate, finalize, write the receipt, and print the summary. This section also handles --dry-run, which prints the request and exits without an API key.
data_story.py (part 10 of 10)

Offline verification

These checks make no network requests and spend no credits:

Limitations

  • It’s a draft. Structural citation checks can’t tell whether a sentence misreads or overstates its source.
  • Chart geometry can be in-bounds and still wrong. Marks are checked against the viewBox, not against the axis they imply. Verify the scale.
  • Check mobile label sizes. A chart can avoid overflow and still be unreadable at 390px.
  • Sparse search coverage fails closed. If results are thin or contradictory, validation fails and nothing is written.
  • High-effort runs take minutes. Use --resume with the response ID rather than resubmitting.
  • A dense topic can still exhaust the budget. Reasoning and the page share one ceiling. The run fails after you have paid for it, so raise --max-output-tokens rather than retrying the same budget.
  • One page, one file. Multi-page output needs a different approach.
  • Receipts exclude the API key but may include topic and source snippets. Review before sharing.

Resources