Skip to main content
Suppose you need to know how coverage of a topic changed week by week. An ordinary search does not help, because results are ranked for today. In a probe for this recipe, a question about one September week, sent without date filters, returned 19 results: only 3 were from that week, and 9 were from the day the probe ran. Anyone following a product launch, a competitor, or a regulation ends up rebuilding the week-by-week picture by hand. With the Agent API, this becomes a short script:
  • Date-filtered search. web_search accepts publication-date filters. One request per week returns only pages published that week, with citations.
  • Effort levels instead of model and prompt choices. Effort levels set how much work the model does. effort="fast" runs one quick search step, right for a narrow dated question. effort="medium" gives the model more steps to read all the weeks and explain how the story changed.
  • Code the model writes and runs. A third request adds the sandbox tool. The model writes Python to count which outlets covered each week, and the API runs it.
  • Your code does the rest. It splits the range into weeks, runs the fast requests in parallel, and writes the timeline and the chart.
You get a Markdown timeline with a cited summary for every week, a narrative of how the coverage changed with the turning points named, a table of outlets per week, and a chart.
Here is what came back for the iPhone 18 Pro, September 1 to October 26. First the Week by week block the script prints, then the turning points the synthesis named.
Each headline is the model’s one-line answer for that week, written from pages dated inside it.

One recorded run at 1.5x speed, no cuts. The terminal prints each week as its fast request finishes, then streams the synthesis while the sandbox step runs. The left pane fills from the run's own output, then shows the turning points, the sandbox table, and the chart your code draws.

Bar chart titled iPhone 18 Pro: sources per window and turning points. Eight bars for window starts from Sep 01 to Oct 20 with heights 13, 14, 13, 15, 17, 15, 19 and 20 distinct dated sources. Numbered red markers sit on the Sep 08, Sep 22, Oct 06 and Oct 20 bars, with a key below reading 1. Sep 08: Rumor becomes product, 2. Sep 22: Early reliability scrutiny, 3. Oct 06: Demand narrative fractures, 4. Oct 20: Supply and repair take focus.

The chart the script wrote. Bars: distinct dated sources per week, capped at the 20 results requested per call. Markers: the weeks the synthesis named as turning points. The model never sees this chart.

The recorded run cost about five cents: $0.0141 for the eight fast requests, $0.0015 for the synthesis, and $0.0323 for the sandbox step ($0.03 of it is the sandbox session). The rest of this page says “window” rather than “week”, because --step lets a window be a day or a month.

Filter by date instead of asking in the prompt

Putting the dates in the prompt does not work. The search tool never sees your prompt. It ranks pages by relevance and recency, so a prompt about “the week of September 15” still gets today’s pages back. In the unfiltered probe above, the model had only the 3 pages that happened to fall in that week. With the date filters on, the same window returned 17 pages dated inside it. The date filters decide which pages the model sees. The prompt decides what it does with them. web_search has four date filters, all in MM/DD/YYYY format: search_after_date_filter and search_before_date_filter on the publication date, last_updated_after_filter and last_updated_before_filter on the last update. This recipe uses the publication pair.
Both bounds were inclusive in every probe and the recorded run: results dated on the filter day itself came back. The web search filter reference is the source of truth if that changes.

Check what you need

  • Python 3.12 on macOS or Linux. The shell commands use Unix syntax.
  • A Perplexity API key in PERPLEXITY_API_KEY. The perplexity library reads it on its own.
  • About five cents of API credit per eight-window run, three of them for the sandbox session.
The recipe never names a model. It picks an effort level for each request instead, and the API chooses the model and tools behind it. fast runs one model step with one search, enough for a narrow dated question. medium runs several steps with its own searches, which the synthesis needs; the coverage step uses medium too and adds the sandbox tool. Each response reports the exact version it ran, such as fast@2026-10-16.0. Pass that string as effort to get the same behavior next time.

Set up the project

Create a directory, a virtual environment, and the dependency file. matplotlib draws the chart and pytest runs the offline tests.
requirements.txt
You will build four files:
  • timeline.py makes the window and synthesis requests.
  • coverage.py makes the sandbox request.
  • report.py turns everything into Markdown, JSON, and a PNG.
  • compare.py re-runs one window at several effort levels.
Each section below explains one piece of a file, then shows it. The full files are under “Save the full files”.

Split the range into windows

split_windows walks from --from to --to in steps of --step days. Both ends of each window are inclusive, and the last window is as short as it needs to be. Each Window formats itself as a filter dictionary, because the API wants MM/DD/YYYY, not ISO dates.
September 1 to October 26 with a seven-day step gives eight windows, the last ending on October 26. September 1 to September 17 gives three, the last one three days long. The offline tests cover both.

Send one fast request per window

Each window gets the same prompt with its dates filled in. The prompt asks for a Headline: line of at most twelve words, two or three cited sentences, and a plain statement when nothing in range came back. The web_search override carries the filters and raises max_results from the fast effort level’s default of 10 to 20, so a busy week can show more sources than a quiet one.
The request is one client.responses.create call with effort="fast", the prompt, and the tool override:
The response carries two output items this recipe reads:
  • search_results lists every page the search returned: id, url, title, date, and last_updated.
  • message holds the text, with citation markers like [3] that point at a search result’s id.
collect maps the markers back to their results and counts the distinct URLs with a publication date inside the window. That count is what the chart’s bars show.

Run the windows in parallel

The windows are independent, so they run at the same time through AsyncPerplexity, with a semaphore capping how many are in flight. The default of 6 fits the 8 QPS of Tier 2 and above on the rate limits page. On Tier 0 or Tier 1, pass --concurrency 1 or --concurrency 3. The client uses max_retries=0, so the library’s silent retries are off and a 429 shows up in your terminal. create_with_retry retries up to twice and honors Retry-After.
main ties it together:
  • Schedules all the windows with asyncio.as_completed, so each prints its stats line and headline as it finishes.
  • Sorts the results, writes a first timeline.md with the per-window sections, writes windows.json with one record per window, and prints the Week by week block.
  • Starts the synthesis and the sandbox step together with asyncio.gather. The synthesis streams to the terminal; the sandbox result prints when both are done.
If either medium effort request fails, the weeks are already on disk.

Describe how the story changed

A fast request sees one window, so it cannot tell you that late September’s delivery estimates contradict mid-October’s order-cut reports. The synthesis step sends all eight window summaries in one medium request, because reading them and reconciling them takes more than one model step. synthesis_input lays out every window as a heading, its summary, and the URLs it cited. SYNTHESIS_PROMPT asks for three Markdown sections and fixes the format of the turning-point lines so your code can parse them back out for the chart.
The request uses effort="medium" and stream=True. It also gives the synthesis’s own web_search the whole range as a date filter, so a search it runs to check a claim does not pull in today’s coverage. The loop prints each response.output_text.delta as it arrives and reads the final response, with usage and effort version, from the response.completed event.

Count outlets per window with the sandbox tool

The synthesis explains the story. It is not the place for arithmetic: ask a model how many distinct outlets covered each week and you get a plausible number, not a counted one. The sandbox tool fixes that. The model writes Python, a container runs it, and the response carries the code and its output in a sandbox_results item.
A request with the sandbox enabled cannot carry web_search date filters; the API returns a 400. So the sandbox step is its own request. It does not search; it works on data your code already collected.
Your code reduces each window’s results to a list of hostnames and sends that as JSON. The prompt tells the model to save the JSON to a file and parse it, not retype it into code.
Without the save-to-file instruction, the model typed all 127 hostnames into its script by hand, wrote the script twice, and used 46,000 tokens. With it, the recorded run’s script is 11 lines.
run_coverage adds {"type": "sandbox"} to the medium effort level’s tools, then reads the code and exit code from the sandbox_results item, the table from the message item, and the session cost from usage.tool_calls_details. Your own code could build this table in ten lines. The step is here to show the pattern (send data in, get code and a result back) on numbers you can check against windows.json.

Write the timeline and draw the chart

report.py makes no API calls. write_markdown writes one section per window (headline, the model’s text, the sources it cited), then the synthesis, then the sandbox table. write_windows_json writes the same data as records. turning_points reads only the text between the “Turning points” heading and the next heading. An earlier version read the whole text, and the Contradictions section, which uses the same - date to date: line shape, showed up on the chart as extra markers. draw_chart keeps the attribution straight: the bars come from counts your code took from the search results, the markers from turning points the model named. A turning point gets a marker when its start date matches a window start. When the synthesis merges two windows into one span, the marker sits on the span’s first window.

Save the full files

Save these files next to requirements.txt. timeline.py imports from report.py and coverage.py; compare.py imports from timeline.py.
timeline.py
report.py
coverage.py
compare.py

Run the script

The iPhone 18 Pro, from the week before Apple’s September 9 event to October 26, is a dense story with primary sources you can check. This is the recorded run’s terminal output, trimmed: the streamed turning points, the sandbox stats line, the summary table, and the per-request timings are cut, and the --out path is shown as the default out/:
Keep the effort version strings. fast@2026-10-16.0 names the exact model, prompt, tools, and limits that produced these results. Pass that string as effort instead of plain fast to make tomorrow’s run behave like today’s.

Check the result against the sources

Three dated claims from the run, checked against the pages it cited:
  • September 9 launch. The Sep 08 to Sep 14 window says Apple announced the iPhone 18 Pro on September 9 with a 48-megapixel variable-aperture camera, with preorders from September 12. Apple’s press release, dated September 9, 2026, describes the “48MP Fusion Main camera with variable aperture” and says “Pre-orders begin Saturday, September 12.” Correct.
  • October 9 order cut. The Oct 06 to Oct 12 window says Apple asked suppliers to cut October component orders “by at least 15%, citing softer demand and higher memory-chip costs,” and notes Apple did not confirm it. The cited Indian Express report, dated October 9, 2026, carries a Reuters story with “slashed by at least 15% … Nikkei reported” and “Reuters could not immediately verify the report.” Correct, hedge included.
  • October 20 supplier announcement. The Oct 20 to Oct 26 window names Qnity Electronics as producing films for the variable-aperture camera and cites “nearly 100 components” from U.S. suppliers. The cited MacRumors article, dated October 20, 2026, says “Delaware-based Qnity Electronics will produce ‘advanced films’ for the variable aperture camera system on the iPhone 18 Pro models.” Correct.
Two things to keep in mind when you read a result:
  • A fast effort request makes one search and leads with one story. The Sep 22 to Sep 28 window led with the Face ID reports, not the iOS 27.0.1 fix that closed them on September 28.
  • The sandbox table counts outlets among the results the search returned, capped at 20 per call. “19 distinct outlets” means the sample was spread across many sites, not that only 19 sites covered it.

Compare effort levels on one window

compare.py sends one window’s prompt and filters at three effort levels. Here is the Sep 22 to Sep 28 window at effort="fast", effort="medium", and effort="high", run at the same moment:
The “tool result items” row counts search_results and fetch_url_results items, the closest the response gets to a step count. The medium level ran two searches, saw 30 distinct dated pages, and led with the software fix the fast request missed. For a window where the story turns, that second search is what you pay for. The high level ran four tool calls on a window one search covers, at about 22 times the fast cost. Five fast runs of this window on the same day led with the Face ID reports four times and the iOS 27.0.1 fix once. Both were in the week. If one window matters on its own, re-run it at the medium level for about twice the cost.

Run the offline tests

test_timeline.py and test_report.py need no network or API key. Fake clients stand in for the API, including one that raises RateLimitError and one that returns a sandbox_results item. The tests cover the window arithmetic, the filter format, citation extraction, the headline split, the turning-point parser, the semaphore cap, the retry path, the Markdown and JSON writers, the sandbox request shape, the chart, and the summary table.
test_timeline.py
test_report.py

Keep these limits in mind

  • The bounds are inclusive, as observed. Results dated on both filter days came back in every probe. Windows here do not overlap because each starts the day after the last one ends. The reference page does not state inclusivity, so check a boundary date in your own run if you depend on it.
  • Undated pages do not come back under a publication-date filter. No result arrived with date: null when the filters were on; the unfiltered probe returned one. Pages with no publication date in the index will not show up in your windows.
  • last_updated filters answer a different question. Every page in the probes had a last_updated weeks after its date, because pages get re-indexed as they change. Use the last_updated pair for content that is current, such as documentation or pricing. Use the publication pair for what was said when.
  • The source count is a capped sample. Each fast effort request asks for 20 results, so the bars top out at 20 and a window with 200 articles looks like one with 20. Read the bars as a density hint and the text for the rest.
  • Each window is blind to the others. The request for September 22 knows nothing about October 13. The synthesis is the only place the story gets connected.
  • Cost scales with windows. Eight windows cost $0.0141 at the fast effort level and sent 5,753 tokens into the synthesis. A 52-window year is about $0.09 of fast requests and a synthesis input of roughly 50,000 tokens; synthesize quarters first, then the quarters. The sandbox session costs $0.03 whether you have 8 windows or 52.
  • The sandbox and date filters do not mix in one request. Keep the filtered searches in their own requests and hand the sandbox step their data, as this recipe does.
  • The model only computes what it can read. Data in the prompt reaches the sandbox only if the model writes it to a file. Tell it to save the input before parsing, keep the payload small (the hostnames prompt here is about 3 KB), and read the returned code once per effort version.
  • Effort levels are versioned. This page used fast@2026-10-16.0, medium@2026-10-22.1, and high@2026-10-22.0. A new version can change the model, the default max_results, or the citation marker format, which is why collect accepts both [3] and [web:3] and the script prints the version strings.
  • Set --step to 1 for a day-by-day view of one busy week, or 30 for a year in twelve windows.
  • Add search_domain_filter to the filters dictionary to restrict each window to a few publications and compare how two outlets covered the same weeks.
  • Swap in last_updated_after_filter and last_updated_before_filter to ask which pages changed that week rather than what was published.
  • Hand the sandbox step windows.json and ask for the day-by-day spread of publication dates inside each window, or the share of each window’s results from the recurring outlets.
  • Replace the print lines in main with writes to a database table keyed by topic and window, and run the script on a schedule so the timeline extends itself each week.
  • Give the synthesis a JSON schema when you want turning points as data rather than Markdown lines.

Filter web search by date

Every filter the search tool accepts, including domain, recency, and date ranges.

Choose an effort level

What each level bundles, how versions work, and how to pin one.

Run code in the sandbox

How the model runs code in a container and what the response carries back.

Give the agent a sandbox

When to give the agent a sandbox so it computes exact answers instead of estimating them.