# Muriel developer docs > Muriel answers questions about U.S. public drinking water from the compliance sample results states publish. Python SDK: `pip install muriel` (import from `muriel`). JavaScript SDK: `npm install @muriel-ai/sdk`. REST API: https://api.murielai.io, authenticated with an API key in the X-API-Key header (the SDKs read MURIEL_API_KEY). MCP connector: https://mcp.murielai.io/mcp. Every page below is also at https://docs.murielai.io/md/.md, and all of them together at https://docs.murielai.io/llms-full.txt. # Overview > Muriel developer docs · https://docs.murielai.io/#overview The Muriel API is an agent first. Send a question in plain English and get back the answer, the records behind it, and — if you ask — exactly the fields your code needs. It is the same agent that answers at murielai.io, over the same data: compliance sample results for U.S. public water systems, current and historical. [Get an API key](https://murielai.io/account) 92M+ Sample results 63K+ Water systems 32 States 1975 Earliest result Install$ pip install muriel *One call* ```python import muriel # reads MURIEL_API_KEY from the environment answer = muriel.query("Which Illinois systems have detected PFOA or PFOS above 4 ppt this year?") print(answer.summary) # the agent's answer, in plain English for r in answer.results: # the records it rests on print(r.get("system_name"), r.get("result_analyte_name"), r.get("result_value"), r.get("result_unit")) ``` ```javascript import { query } from '@muriel-ai/sdk' // reads MURIEL_API_KEY from the environment const answer = await query('Which Illinois systems have detected PFOA or PFOS above 4 ppt this year?') console.log(answer.summary) // the agent's answer, in plain English for (const r of answer.results) { // the records it rests on console.log(r.system_name, r.result_analyte_name, r.result_value, r.result_unit) } ``` ### Three ways in, one agent ### 1 · The agent `muriel.query()` or `POST /api/v1/agent/query`. Plain-English in; answer, records and an execution log out. Start here. ### 2 · Inside AI tools The MCP connector puts Muriel's tools inside the AI assistants and coding tools you already use. Sign in with your Muriel account; no key to copy. ### 3 · Traditional API Exact, parameter-driven routes for pipelines: records by system, state, contaminant, group or address; water systems; locations; coverage. ### Exactly the fields you need Pass `schema` — a list of field names, a JSON Schema, or (in Python) a Pydantic model or List[Model] — and the agent shapes its answer to it. See [Structured Output](https://docs.murielai.io/md/models.md). *Structured output* ```python import muriel # Name the fields you want; get back a dict with exactly those fields. latest = muriel.query( "What was the most recent lead result for Chicago Water?", pwsid="IL0316000", schema=["sample_date", "result", "units", "above_action_level"], ) print(latest) # {'sample_date': '2026-09-18', 'result': 0.0021, 'units': 'mg/L', 'above_action_level': False} ``` ```javascript import { query } from '@muriel-ai/sdk' // Name the fields you want; get back an object with exactly those fields. const latest = await query('What was the most recent lead result for Chicago Water?', { pwsid: 'IL0316000', schema: ['sample_date', 'result', 'units', 'above_action_level'], }) console.log(latest) // { sample_date: '2026-09-18', result: 0.0021, units: 'mg/L', above_action_level: false } ``` ### How it is billed A key acts as your Muriel account and spends its one balance of credits, on one price list for the app, the API and the MCP connector. Agent questions are metered like a question in the app (typically 1–3 credits); data calls on the traditional routes cost 1 credit per 1,000 records returned, added up across calls. Every account gets 8 free credits a month. See [Credits, Limits & Errors](https://docs.murielai.io/md/exceptions.md). **Monitoring results, not a compliance determination** Answers describe the published monitoring results. The state primacy agency decides violations, and state rules can be stricter than the federal ones. --- # Quickstart > Getting Started · Muriel developer docs · https://docs.murielai.io/#quickstart From nothing to an answer in about five minutes. You ask in plain English; the agent picks the data, runs the queries and returns the answer with the records behind it. 1 Get an API key Sign in at [murielai.io/account](https://murielai.io/account) and create a key under**API keys**. It is shown once — copy it then. New accounts start with free credits. 2 Install the SDK Python 3.8+ or Node.js 18+. Nothing else is needed: the agent runs on Muriel's servers. ```python pip install muriel ``` ```javascript npm install @muriel-ai/sdk ``` 3 Set the key The SDKs read `MURIEL_API_KEY`. You can also pass the key in code. *Terminal* ```bash export MURIEL_API_KEY="muriel_live_..." ``` 4 Ask ```python import muriel answer = muriel.query("Show me the latest lead samples in Illinois") print(answer.summary) print(answer.count, "records") ``` ```javascript import { query } from '@muriel-ai/sdk' const answer = await query('Show me the latest lead samples in Illinois') console.log(answer.summary) console.log(answer.count, 'records') ``` 5 Read the answer `results` holds the records, `summary` the answer in prose, and `execution_log` what the agent did to get there. ```python for r in answer.results[:5]: print(r.get("sample_collection_date"), r.get("system_name"), r.get("result_value"), r.get("result_unit")) for step in answer.execution_log: # which tools the agent ran, with what input print(step["tool"], step["input"]) print(answer.warning) # set when something needs your attention ``` ```javascript for (const r of answer.results.slice(0, 5)) { console.log(r.sample_collection_date, r.system_name, r.result_value, r.result_unit) } for (const step of answer.executionLog) { // which tools the agent ran, with what input console.log(step.tool, step.input) } console.log(answer.warning) // set when something needs your attention ``` 6 Scope it and shape it `pwsid` limits the question to one water system, `since` to results on or after a date, and `schema` returns exactly the fields you name. ```python import muriel event = muriel.query( "What is the most notable new result for Chicago Water?", pwsid="IL0316000", since="2026-09-01", schema=["event_type", "analyte", "value", "units", "sample_date", "insight"], ) print(event["insight"]) ``` ```javascript import { query } from '@muriel-ai/sdk' const event = await query('What is the most notable new result for Chicago Water?', { pwsid: 'IL0316000', since: '2026-09-01', schema: ['event_type', 'analyte', 'value', 'units', 'sample_date', 'insight'], }) console.log(event.insight) ``` ### No SDK? Plain HTTP *curl* ```bash curl https://api.murielai.io/api/v1/agent/query \ -H "X-API-Key: $MURIEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"prompt": "Show me the latest lead samples in Illinois", "max_records": 50}' ``` Interactive reference for every route: [api.murielai.io/docs](https://api.murielai.io/docs). **Need exact, parameter-driven queries?** For pipelines that page through a system's full history or pull one contaminant across the country, use the [Traditional API](https://docs.murielai.io/md/get-results.md) . Its routes cost 1 credit per 1,000 records returned; agent queries are charged per question. --- # API Keys > Getting Started · Muriel developer docs · https://docs.murielai.io/#authentication Every request carries an API key. A key acts as your Muriel account: it spends that account's one balance of credits (agent questions per question, data calls per 1,000 records), and the account's rate limit applies. ### Create a key 1. Sign in at [murielai.io/account](https://murielai.io/account). Your email address must be confirmed (signing in with the emailed link confirms it). 2. Under **API keys**, name the key and choose **Create key**. 3. Copy it immediately. Only a hash is stored, so it cannot be shown again; if you lose it, revoke it and make another. Keys start with `muriel_live_`. You can hold up to 10 active keys — one per script or service makes revoking one painless. ### Use it *Terminal* ```bash export MURIEL_API_KEY="muriel_live_..." ``` ```python import muriel # From the environment (recommended) answer = muriel.query("Latest nitrate results in Georgia") # Or explicitly answer = muriel.query("Latest nitrate results in Georgia", api_key="muriel_live_...") agent = muriel.Agent(api_key="muriel_live_...") client = muriel.MurielClient(api_key="muriel_live_...") ``` ```javascript import { query, Agent, MurielClient } from '@muriel-ai/sdk' // From the environment (recommended) const answer = await query('Latest nitrate results in Georgia') // Or explicitly const answer2 = await query('Latest nitrate results in Georgia', { apiKey: 'muriel_live_...' }) const agent = new Agent({ apiKey: 'muriel_live_...' }) const client = new MurielClient({ apiKey: 'muriel_live_...' }) ``` *HTTP* ```bash # Either header works curl -H "X-API-Key: $MURIEL_API_KEY" https://api.murielai.io/api/v1/records/system/IL0316000?limit=5 curl -H "Authorization: Bearer $MURIEL_API_KEY" https://api.murielai.io/api/v1/records/system/IL0316000?limit=5 ``` ### Base URL ```python # MURIEL_BASE_URL overrides the default https://api.murielai.io, # for example to point at a local server during development. import os os.environ["MURIEL_BASE_URL"] = "http://localhost:8080" ``` ```javascript // MURIEL_BASE_URL overrides the default https://api.murielai.io, // for example to point at a local server during development. const agent = new Agent({ baseUrl: 'http://localhost:8080' }) ``` ### Keep keys server-side A key spends your credits. Keep it in an environment variable or a secrets manager, never in browser code or a public repository. If one leaks, revoke it on the account page; it stops working immediately. ### Using Muriel inside an AI tool instead? The MCP connector does not use API keys: you sign in with your Muriel account and approve the tool. See **MCP Connector**. --- # Ask the Agent > The Agent · Muriel developer docs · https://docs.murielai.io/#agent The agent is the primary interface. It is the same one that answers at murielai.io: it chooses among Muriel's data tools (sample results, trends, compliance math, notable events, the historical archive, the federal rules), runs them on Muriel's servers, and returns the answer with the records it used. You need only an API key. Agent `muriel.query(prompt, pwsid=None, since=None, schema=None, max_records=1000, api_key=None ) → AgentQueryResponse | dict | Model` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | promptRequired | str | — | The question, in plain English. Name the place, contaminant, threshold and time frame when they matter. | | pwsid | str | None | Limit the question to one public water system, e.g. `IL0316000`. | | since | str \| date | None | Only results and events on or after this date (`YYYY-MM-DD`). | | schema | list \| dict \| Model | None | Return the answer shaped to this: a list of field names, a JSON Schema, or a Pydantic model or List[Model] (Python). See Structured Output. | | max_records | int | 1000 | Cap on the records returned in `results` (1–10,000). | | api_key | str | env | Defaults to `MURIEL_API_KEY`. JavaScript takes these as an options object. | *Basic usage* ```python import muriel answer = muriel.query("PFAS detections in California above 4 ppt this year") print(answer.count) # records returned print(answer.summary) # the agent's answer print(answer.warning) # None, or what needs your attention for r in answer.results: print(r.get("system_name"), r.get("result_analyte_name"), r.get("result_value"), r.get("result_unit")) ``` ```javascript import { query } from '@muriel-ai/sdk' const answer = await query('PFAS detections in California above 4 ppt this year') console.log(answer.count) // records returned console.log(answer.summary) // the agent's answer console.log(answer.warning) // undefined, or what needs your attention for (const r of answer.results) { console.log(r.system_name, r.result_analyte_name, r.result_value, r.result_unit) } ``` ### The Agent class `muriel.query()` keeps one `Agent` per key for you. Make your own when you want to set a timeout or reuse a session explicitly. ```python from muriel import Agent # One HTTP session for many questions; closes on exit. with Agent(api_key="muriel_live_...", timeout=120) as agent: a = agent.query("Coliform positives in Georgia in the last 30 days") b = agent.query("Is lead trending up at Chicago Water?", pwsid="IL0316000") ``` ```javascript import { Agent } from '@muriel-ai/sdk' const agent = new Agent({ apiKey: 'muriel_live_...', timeoutMs: 120_000 }) const a = await agent.query('Coliform positives in Georgia in the last 30 days') const b = await agent.query('Is lead trending up at Chicago Water?', { pwsid: 'IL0316000' }) ``` ### The response | Field | Type | Description | | --- | --- | --- | | results | list[dict] | The records the agent’s data tools returned. Sample results use the same fields as the Traditional API’s records; other rows (events, rankings, systems) carry their own fields, so read them with .get(). | | count | int | len(results). | | summary | str | The agent’s answer in plain English, as plain text: no Markdown, ready for an email, a database field or your own UI. | | summary_markdown | str | The same answer with its Markdown formatting (bold, lists, tables), as the web app shows it. In JavaScript: summaryMarkdown. | | execution_log | list[dict] | Each tool the agent ran, with its input. | | warning | str \| None | Set when the answer needs a caveat: results capped, nothing structured came back, or a step failed partway. | | documents | list[dict] | Present when you asked for a CSV or PDF: filename, url, rows. Open the url signed in to your Muriel account. | | credits | dict | What this question cost (charged) and the account’s balance afterwards. | ### Over HTTP *Request* ```bash POST https://api.murielai.io/api/v1/agent/query X-API-Key: muriel_live_... Content-Type: application/json { "prompt": "Is lead trending up at Chicago Water?", "pwsid": "IL0316000", // optional: one water system "since": "2025-01-01", // optional: results on or after this date "schema": null, // optional: JSON Schema for the answer "max_records": 1000 // optional: cap on results (1-10000) } ``` *Response* ```json { "results": [ { "pwsid": "IL0316000", "result_analyte_name": "LEAD", ... }, ... ], "count": 48, "summary": "Lead at Chicago Water is flat to slightly down since 2019 ...", "execution_log": [ { "tool": "analyte_trend", "input": { "pwsid": "IL0316000", "contaminant": "lead" } } ], "warning": null, "documents": [ { "filename": "chicago-lead.csv", "url": "https://murielai.io/api/documents/...", "rows": 48 } ], "credits": { "charged": 2, "balance": 186 } } ``` ### Questions it answers well #### Compliance “Is Chicago's water meeting federal limits for lead and copper?” “Which systems in Georgia had a nitrate result above the MCL in the last year?” “Compute the lead 90th percentile for the last three monitoring rounds at IL0316000” “Any coliform positives for IL1970450 this year, and did it trigger a Level 1 assessment?” #### What changed “What new results were published in California this week?” “Show me this month's high-severity events” “What came in from Oregon since Monday that I should look at?” #### Trends “Which systems are within 20% of an MCL and trending up?” “How has arsenic changed at Phoenix's wells over the last ten years?” “Where is nitrate climbing toward 10 mg/L in Iowa?” #### PFAS “Where has PFOA or PFOS been detected above 4 ppt in Pennsylvania?” “Which New Jersey systems have a PFAS running annual average above the federal limit?” #### Exports “Show me the latest lead samples and export to a CSV” “Export every PFAS result for Chicago Water since 2020 to a CSV” **Tips** - Use `pwsid` when you know the system; it is faster and cheaper than describing it. - Give thresholds and units: *“above 4 ppt”*, *“over the MCL”*. - Ask for compliance math by name: running annual average, 90th percentile. - Answers describe published monitoring results, not a compliance determination; the state primacy agency decides violations. --- # Structured Output > The Agent · Muriel developer docs · https://docs.murielai.io/#models Pass `schema` and the agent answers in exactly that shape. One extra model call shapes the answer from the records it used; it is billed with the rest of the question. ### Three ways to say what you want *schema=* ```python import muriel from pydantic import BaseModel # 1. A list of field names -> a dict with exactly those keys muriel.query("Newest lead result at Chicago Water", pwsid="IL0316000", schema=["sample_date", "value_mg_l", "above_action_level"]) # 2. A Pydantic model -> a validated instance class Trend(BaseModel): contaminant: str direction: str # "up", "down" or "flat" since_year: int note: str trend = muriel.query("Is nitrate rising at IA1234567?", schema=Trend) print(trend.direction) # 3. List[Model] -> a list of validated instances, one per item from typing import List trends = muriel.query("Nitrate trend at each of Iowa's ten largest systems", schema=List[Trend]) for t in trends: print(t.contaminant, t.direction) # 4. A JSON Schema dict muriel.query("Top five PFAS results in New Jersey this year", schema={ "type": "object", "properties": {"systems": {"type": "array", "items": {"type": "object", "properties": { "pwsid": {"type": "string"}, "analyte": {"type": "string"}, "ppt": {"type": "number"}}}}} }) ``` ```javascript import { query } from '@muriel-ai/sdk' // 1. A list of field names -> an object with exactly those keys await query('Newest lead result at Chicago Water', { pwsid: 'IL0316000', schema: ['sample_date', 'value_mg_l', 'above_action_level'], }) // 2. A JSON Schema object await query('Top five PFAS results in New Jersey this year', { schema: { type: 'object', properties: { systems: { type: 'array', items: { type: 'object', properties: { pwsid: { type: 'string' }, analyte: { type: 'string' }, ppt: { type: 'number' } } } } }, }, }) ``` With a schema, the SDK returns the shaped answer itself rather than an `AgentQueryResponse`. Over HTTP, send a JSON Schema as `"schema"` and read `results[0]`. Fields the data does not support come back `null` rather than invented. ### Built-in models The shapes the SDK uses for agent responses and individual records. `WaterQualityRecord`Pydantic Model A single water quality test result. All fields from get_results() map to this model. | Field | Type | Description | | --- | --- | --- | | result_uuid | str | Unique record identifier | | state | str | State abbreviation | | pwsid | str | Public Water System ID | | system_name | str | Water system name | | result_analyte_name | str | Chemical or test name | | analyte_group | str | Analyte group category (pfas, dbps, etc.) | | result_value | str | Raw test result value | | result_unit | str | Unit of measurement | | concentration_value | Optional[float] | Numeric concentration | | concentration_units | Optional[str] | Concentration units | | result_non_detect | Optional[bool] | True if below detection limit | | sample_collection_date | str | ISO date of sample collection | | result_laboratory | Optional[str] | Testing laboratory name | | result_sample_point | Optional[str] | Sampling point ID | | result_facility_id | Optional[str] | Facility identifier | | result_latitude | Optional[float] | Sampling location latitude | | result_longitude | Optional[float] | Sampling location longitude | | result_address | Optional[str] | Sampling location address | | distance_miles | Optional[float] | Distance from query address (address queries only) | ```python from muriel.models import WaterQualityRecord # Records returned by get_results() can be validated record = WaterQualityRecord(**results[0]) print(record.result_analyte_name) # "Lead" print(record.result_value) # "0.012" print(record.concentration_value) # 0.012 print(record.sample_collection_date) # "2023-06-15" print(record.result_latitude) # 41.8781 print(record.result_longitude) # -87.6298 ``` `GeocodingData`Pydantic Model Geographic coordinates and address data for a facility or sampling point. | Field | Type | Description | | --- | --- | --- | | latitude | float | Decimal degrees latitude | | longitude | float | Decimal degrees longitude | | address | Optional[str] | Raw address string | | address_formatted | Optional[str] | Formatted full address | | source | Optional[str] | Geocoding service (Google, Mapbox, etc.) | | confidence | Optional[int] | Match confidence score (0–100) | | match_type | Optional[str] | Geocode match type (street, city, etc.) | | postcode | Optional[str] | ZIP / postal code | | county | Optional[str] | County name | `AgentQueryResponse`Pydantic Model Returned by agent.query() when no Pydantic schema is provided. | Field | Type | Description | | --- | --- | --- | | results | List[Dict] | The records the agent used. Sample results carry the WaterQualityRecord fields; other rows (events, rankings, systems) carry their own, so read them with .get() | | count | int | len(results) | | summary | str | The answer in plain English, as plain text | | summary_markdown | Optional[str] | The same answer with its Markdown formatting (summaryMarkdown in JavaScript) | | execution_log | List[Dict] | Each tool the agent ran: {"tool": ..., "input": {...}} (executionLog in JavaScript) | | warning | Optional[str] | Set when the answer needs a caveat: results capped, nothing structured came back, or a step failed | | documents | List[Dict] | Files produced when you asked for a CSV or PDF: filename, url, rows. Empty otherwise | | credits | Optional[Dict] | What the question cost and the balance afterwards: {"charged": int, "balance": int} | ```python from muriel.models import AgentQueryResponse # Returned by agent.query() when no schema is passed response: AgentQueryResponse = agent.query("Lead in IL") print(response.count) # 142 print(response.summary) # "Found 142 lead records..." print(response.execution_log) # [{"tool": "get_sample_results", "input": {...}}, ...] print(response.warning) # None or a caveat print(response.credits) # {"charged": 2, "balance": 46} for doc in response.documents: # CSV or PDF files, when you asked for one print(doc["filename"], doc["url"]) ``` --- # Example Questions > The Agent · Muriel developer docs · https://docs.murielai.io/#agent-tasks The Muriel agent doesn't just answer questions — it does work. Tell it what you need and it retrieves, analyzes, and structures the results. Below are ready-to-run examples of tasks you can hand off directly. 📋 System Reports Ask the agent to produce a comprehensive report for any water system — detected contaminants, EPA comparisons, results over a federal limit, and a plain-English risk summary. ⚖️ Compare Systems Give the agent a list of systems or a region and ask it to rank them by risk, contamination profile, or compliance status. 📈 Trend Analysis Ask the agent to trace how any analyte has moved year-over-year and whether conditions are improving, worsening, or stable. 🔍 Contamination Screening Ask the agent to scan all systems in a state and surface every analyte currently over its EPA limit — sorted by severity. 🏛️ Due Diligence Get a pass/caution/fail assessment with flagged exceedances, trend history, and candidate monitoring gaps — useful for real estate, M&A, or compliance reviews. 🔔 Ongoing Monitoring Pair the agent with a scheduler to run weekly digests — detecting new exceedances, resolving old ones, and alerting only when something changes. 📋 Generate full water quality reports Ask the agent to produce a comprehensive report for any water system — detected contaminants, EPA comparisons, results over a federal limit, and a plain-English risk summary. ```python from muriel import Agent from pydantic import BaseModel from typing import List, Optional class ContaminantSummary(BaseModel): analyte: str detection_count: int max_concentration: float units: str exceeds_epa_limit: bool epa_limit: Optional[float] class SystemReport(BaseModel): pwsid: str system_name: str city: str state: str population_served: Optional[int] contaminants_detected: List[ContaminantSummary] results_over_mcl: int risk_summary: str with Agent() as agent: report: SystemReport = agent.query( "Generate a full water quality report for the Chicago Water Department " "(IL0316000). Include all detected contaminants, EPA limit comparisons, " "results over a federal limit, and a plain-English risk summary.", schema=SystemReport ) print(f"Report: {report.system_name}") print(f"Population served: {report.population_served:,}") print(f"Results over an MCL: {report.results_over_mcl}") print(f"\nRisk summary: {report.risk_summary}") print(f"\nContaminants detected:") for c in report.contaminants_detected: flag = "⚠️ EXCEEDS LIMIT" if c.exceeds_epa_limit else "✓" print(f" {flag} {c.analyte}: {c.max_concentration} {c.units}") ``` ⚖️ Compare systems side by side Give the agent a list of systems or a region and ask it to rank them by risk, contamination profile, or compliance status. ```python from muriel import Agent from pydantic import BaseModel from typing import List, Literal class SystemSnapshot(BaseModel): pwsid: str system_name: str pfas_detections: int max_pfas_ppt: float lead_detections: int max_lead_ppb: float results_over_mcl: int overall_risk: Literal["low", "moderate", "high"] class ComparisonReport(BaseModel): systems: List[SystemSnapshot] safest_system: str highest_risk_system: str analyst_notes: str with Agent() as agent: comparison: ComparisonReport = agent.query( "Compare water quality across the five largest public water systems " "in Illinois. Focus on PFAS, lead, and results over federal limits. " "Rank them by overall risk and note which is safest.", schema=ComparisonReport ) print(comparison.analyst_notes) print(f"\nSafest system: {comparison.safest_system}") print(f"Highest risk: {comparison.highest_risk_system}") for s in comparison.systems: print(f" {s.system_name}: PFAS {s.max_pfas_ppt:.1f} ppt, " f"Lead {s.max_lead_ppb:.1f} ppb, " f"Risk: {s.overall_risk.upper()}") ``` 📈 Analyze how contamination changes over time Ask the agent to trace how any analyte has moved year-over-year and whether conditions are improving, worsening, or stable. ```python from muriel import Agent from pydantic import BaseModel from typing import List, Literal class YearlyDataPoint(BaseModel): year: int avg_concentration: float max_concentration: float sample_count: int pct_above_limit: float class TrendAnalysis(BaseModel): analyte: str state: str data_points: List[YearlyDataPoint] trend_direction: Literal["improving", "worsening", "stable"] trend_summary: str recommendation: str with Agent() as agent: trend: TrendAnalysis = agent.query( "Analyze how PFAS levels have changed year-over-year in Illinois " "public water systems from 2020 to 2024. Include average and peak " "concentrations per year, and tell me if things are improving.", schema=TrendAnalysis ) print(f"Trend: {trend.trend_direction.upper()}") print(f"Summary: {trend.trend_summary}") print(f"\nYear-by-year:") for dp in trend.data_points: bar = "█" * int(dp.avg_concentration * 10) print(f" {dp.year}: {dp.avg_concentration:.2f} ppt avg {bar}") print(f"\nRecommendation: {trend.recommendation}") ``` 🔍 Screen entire states for active exceedances Ask the agent to scan all systems in a state and surface every analyte currently over its EPA limit — sorted by severity. ```python from muriel import Agent from pydantic import BaseModel from typing import List class ContaminationAlert(BaseModel): pwsid: str system_name: str analyte: str concentration: float units: str epa_limit: float times_over_limit: float sample_date: str action_required: str with Agent() as agent: alerts: List[ContaminationAlert] = agent.query( "Screen all Texas water systems for any analyte currently exceeding " "its EPA maximum contaminant level. Return only active exceedances " "with the most recent sample date, sorted by how many times over " "the limit they are.", schema=List[ContaminationAlert] ) print(f"Found {len(alerts)} active exceedances in Texas\n") for alert in alerts: print(f"⚠️ {alert.system_name}") print(f" {alert.analyte}: {alert.concentration} {alert.units} " f"({alert.times_over_limit:.1f}x over EPA limit)") print(f" Action: {alert.action_required}") ``` 🏛️ Run regulatory due diligence on a system Get a pass/caution/fail assessment with flagged exceedances, trend history, and candidate monitoring gaps — useful for real estate, M&A, or compliance reviews. ```python from muriel import Agent from pydantic import BaseModel from typing import List, Optional, Literal class RegulatoryFlag(BaseModel): issue_type: str analyte: Optional[str] severity: Literal["critical", "moderate", "minor"] description: str date_identified: Optional[str] resolved: bool class DueDiligenceReport(BaseModel): pwsid: str system_name: str service_area: str flags: List[RegulatoryFlag] critical_count: int overall_assessment: Literal["pass", "caution", "fail"] recommendation: str with Agent() as agent: report: DueDiligenceReport = agent.query( "Run a regulatory due-diligence check on water system CA3010037. " "Identify any history of results over an MCL, candidate monitoring gaps, " "PFAS or lead exceedances, or unresolved compliance issues. " "Give an overall pass/caution/fail assessment.", schema=DueDiligenceReport ) status_emoji = {"pass": "✅", "caution": "⚠️", "fail": "❌"} print(f"{status_emoji[report.overall_assessment]} {report.system_name}") print(f"Assessment: {report.overall_assessment.upper()}") print(f"\n{report.recommendation}") if report.flags: print(f"\nFlags ({report.critical_count} critical):") for f in report.flags: print(f" [{f.severity.upper()}] {f.issue_type}: {f.description}") ``` 🔔 Run on a schedule to catch new problems Pair the agent with a scheduler to run weekly digests — detecting new exceedances, resolving old ones, and alerting only when something changes. ```python from muriel import Agent from pydantic import BaseModel from typing import List, Optional from datetime import date, timedelta class NewExceedance(BaseModel): pwsid: str system_name: str analyte: str concentration: float units: str epa_limit: float sample_date: str is_new_since_last_check: bool class MonitoringSummary(BaseModel): check_date: str state: str new_exceedances: List[NewExceedance] resolved_since_last_check: int alert_required: bool digest: str # Run this on a schedule (daily, weekly, etc.) with Agent() as agent: since = (date.today() - timedelta(days=7)).isoformat() summary: MonitoringSummary = agent.query( f"Check for any new PFAS or lead exceedances in Ohio water systems " f"since {since}. Compare to prior period and flag anything that " f"wasn't exceeding limits before. Summarize in a digest.", schema=MonitoringSummary ) if summary.alert_required: print(f"🚨 ALERT — {len(summary.new_exceedances)} new exceedances in OH") for exc in summary.new_exceedances: print(f" {exc.system_name}: {exc.analyte} {exc.concentration} {exc.units}") else: print("✓ No new exceedances this week") print(f"\nDigest: {summary.digest}") ``` ### How the agent executes tasks When you call `agent.query()` with a task description, the agent breaks it down into one or more structured API calls against the full Muriel dataset, then assembles the results into whatever shape you asked for. The `execution_log` on every response shows you exactly what it did. 1 Parse the task The agent reads your prompt and identifies entities: state, analyte, time range, thresholds, output shape. 2 Query the dataset It translates those entities into one or more structured queries against 92M+ results — filtering, sorting, and paginating as needed. 3 Synthesize & return Results are assembled into your schema (or AgentQueryResponse), with a plain-English summary generated alongside. **Describe the output, not just the query** The more you tell the agent about the shape of result you want — *"grouped by system"* , *"ranked by severity"* , *"include a one-sentence recommendation"* — the more useful its output will be. Pair this with a Pydantic schema and you get typed, structured data ready for your pipeline with no post-processing. --- # Credits, Limits & Errors > The Agent · Muriel developer docs · https://docs.murielai.io/#exceptions ### One price list Every way into Muriel spends one balance of credits on one account: the web app, the agent API, the traditional routes, the MCP connector and A2A. There are two meters. | Call | Cost | Limit | | --- | --- | --- | | Agent question web app, `agent.query()` | Metered on the work the question takes, the same as in the app: typically 1–3 credits, never more than 8. The records the answer returns are included. The response reports `credits.charged` and the balance. | 60 / minute / key | | Data call traditional routes, MCP tools, A2A skills | **1 credit per 1,000 records returned**, added up across calls, so a 50-record lookup uses 0.05 of a credit. A call that returns nothing, or fails, is free. `/health` and `/coverage` are always free. | 60 / minute / key; up to 1,000 records per call, offset up to 10,000. MCP: 30 / minute, 2,000 / day per account | Every account gets **8 free credits a month**, usable on any of them. For more, email info@murielai.io. The machine-readable list is at `GET https://murielai.io/api/pricing`. Each data response carries the meter in its headers: `X-Muriel-Records`, `X-Muriel-Credits-Charged` and `X-Muriel-Credits-Balance`. Whole credits are taken as the running total crosses each 1,000; the remainder carries to your next call. A question that fails before reaching the model is not charged. One that fails partway is charged for what it used. When the balance is empty, every metered call returns 402 (`MurielCreditsError`) before it runs. All Muriel errors inherit from `MurielAPIError`. Whether you're calling `agent.query()` or a traditional API function, the same exception hierarchy applies. Each exception carries a `message`, `status_code`, and optional `response` dict. Base `MurielAPIError` Base exception class for all Muriel API errors. Catch this to handle any API error in a single except block. `message: str` `status_code: int` `response: dict` 401 `MurielAuthenticationError` Raised when the API key is invalid, missing, or expired. Do not retry — update the key. `message: str` `status_code: int (401)` 400 `MurielValidationError` Raised when request parameters are invalid (e.g., unknown state code, invalid date format, conflicting filters). `message: str` `status_code: int (400)` `response: dict` 402 `MurielCreditsError` The key's account is out of credits. Raised before the call runs, by an agent query or a traditional route alike, so nothing was charged. Add credits (email info@murielai.io), then retry. The free allowance renews monthly. `message: str` `status_code: int (402)` `response: dict (balance, contact)` 404 `MurielNotFoundError` Raised when the requested resource does not exist (e.g., unknown PWSID, facility, or sampling point). `message: str` `status_code: int (404)` 429 `MurielRateLimitError` More than 60 requests in a minute on one key. Wait retry_after seconds (retryAfter in JavaScript) and retry. When fetching many pages (limit=None), the SDK waits and retries for you. `message: str` `status_code: int (429)` `retry_after: Optional[int] (seconds; SDK 0.2.10 / JS 0.2.7)` 5xx `MurielServerError` Server-side errors. 501: the route's data is not available yet (requirements, violations) — do not retry. 502/503/504: safe to retry with backoff. `message: str` `status_code: int` ### Error handling with retry logic ```python from muriel import get_results from muriel.exceptions import ( MurielAPIError, MurielAuthenticationError, MurielValidationError, MurielNotFoundError, MurielRateLimitError, MurielServerError, MurielCreditsError, ) import time def fetch_with_retry(pwsid: str, api_key: str, retries: int = 3): for attempt in range(retries): try: return get_results(pwsid=pwsid, api_key=api_key) except MurielAuthenticationError: # No point retrying — API key is wrong raise except MurielValidationError as e: print(f"Bad request: {e.message}") raise except MurielNotFoundError: print(f"System {pwsid} not found") return [] except MurielRateLimitError as e: if attempt < retries - 1: wait = e.retry_after or 20 # seconds, from the Retry-After header print(f"Rate limited — retrying in {wait}s") time.sleep(wait) else: raise except MurielServerError as e: if attempt < retries - 1: print(f"Server error ({e.status_code}) — retrying...") time.sleep(5) else: raise except MurielAPIError as e: # Catch-all for other API errors print(f"API error {e.status_code}: {e.message}") raise return [] ``` --- # MCP Connector > In AI Tools · Muriel developer docs · https://docs.murielai.io/#mcp Put Muriel inside the AI tools you already use: any assistant or coding tool that supports remote MCP servers. The assistant calls Muriel's tools when a question needs drinking water data and answers from what they return. ### 1 · Add the connector One URL, `https://mcp.murielai.io/mcp`. Then sign in to your Muriel account in the browser window that opens and click **Allow**. There is no API key to copy. #### Claude *Claude* ```bash # Settings → Connectors → Add custom connector Name Muriel URL https://mcp.murielai.io/mcp ``` #### Claude Code *Claude Code* ```bash claude mcp add --transport http --scope user muriel https://mcp.murielai.io/mcp # then, inside Claude Code: /mcp → muriel → Authenticate ``` #### Cursor *Cursor* ```json // ~/.cursor/mcp.json { "mcpServers": { "muriel": { "url": "https://mcp.murielai.io/mcp" } } } ``` #### VS Code *VS Code* ```json // .vscode/mcp.json { "servers": { "muriel": { "type": "http", "url": "https://mcp.murielai.io/mcp" } } } ``` #### Other *Other* ```bash # Any client that supports remote MCP servers with OAuth: https://mcp.murielai.io/mcp # A client that only runs local (stdio) servers: npx -y mcp-remote https://mcp.murielai.io/mcp ``` ### 2 · Ask in your assistant Try “What data does Muriel have for Illinois?” — it should call `data_coverage`. Connected apps appear on your [account page](https://murielai.io/account), where you can disconnect them. ### The tools | Tool | What it does | | --- | --- | | data_coverage | What data exists overall, for a state, or for one system. | | find_water_system | Turn a utility name or place into a PWSID. | | get_sample_results | Sample results for a system, with filters. | | summarize_sample_results | Counts, ranges and detections across results. | | rank_systems_by_size | Systems in a state, largest first. | | get_notable_events | What is new: exceedances, positives, jumps from the prior result. | | get_record_changes | Results that appeared, disappeared or were amended. | | analyte_trend | Is a contaminant going up, at a system or a sampling point. | | check_compliance_from_data | Running annual averages, 90th percentiles and the rest of the compliance math. | | check_monitoring_gaps | Candidate missed monitoring by contaminant and sample point. | | historical_depth | How far back the record goes for a state or system. | | deep_history_search | Results from the historical archive. | | lookup_standard | The federal limit for a contaminant. | | evaluate_result | One value against its federal limit. | | resolve_analyte | Map a state’s analyte name to the federal standard. | | search_regulations | Search 40 CFR 141–143. | **Connector or API?** Use the connector when a person is asking questions inside an AI tool. Use the agent API ( `muriel.query()` ) when your own code asks. Both spend the same credits: the connector's tools by records returned (1 credit per 1,000), the agent API per question. --- # Sample Results > Traditional API · Muriel developer docs · https://docs.murielai.io/#get-results Exact, parameter-driven access to sample results: by water system, state, analyte group or street address, with filters for dates, detections, concentration and location. 1 credit per 1,000 records returned, added up across calls; 60 requests a minute per key. For a question rather than a query, **ask the agent**. ### Examples #### By PWSID ```python from muriel import get_results results = get_results( pwsid="IL0316000", limit=50 ) print(f"{len(results)} records") print(results[0]["result_analyte_name"], results[0]["result_value"]) ``` ```javascript import { getResults } from '@muriel-ai/sdk' const results = await getResults({ pwsid: 'IL0316000', limit: 50 }) console.log(`${results.length} records`) console.log(results[0].result_analyte_name, results[0].result_value) ``` #### By State ```python from muriel import get_results results = get_results( state="IL", analyte="Lead", detections_only=True, min_concentration=0.005, limit=100 ) ``` ```javascript import { getResults } from '@muriel-ai/sdk' const results = await getResults({ state: 'IL', analyte: 'Lead', detectionsOnly: true, minConcentration: 0.005, limit: 100, }) ``` #### Date Range ```python from muriel import get_results results = get_results( pwsid="CA3010037", start_date="2023-01-01", end_date="2024-12-31", analyte_group="metals_inorganic", limit=100 ) ``` ```javascript import { getResults } from '@muriel-ai/sdk' const results = await getResults({ pwsid: 'CA3010037', startDate: '2023-01-01', endDate: '2024-12-31', analyteGroup: 'metals_inorganic', limit: 100, }) ``` #### Geocoded Only ```python from muriel import get_results # Only records with lat/lng coordinates results = get_results( state="CA", analyte_group="pfas", geocoded_only=True, limit=100 ) for r in results: lat = r.get("result_latitude") lng = r.get("result_longitude") print(f"{r['system_name']}: {lat}, {lng}") ``` ```javascript import { getResults } from '@muriel-ai/sdk' // Only records with lat/lng coordinates const results = await getResults({ state: 'CA', analyteGroup: 'pfas', geocodedOnly: true, limit: 100, }) for (const r of results) { console.log(`${r.system_name}: ${r.result_latitude}, ${r.result_longitude}`) } ``` #### Pagination ```python from muriel import get_results # limit=None returns every record; the SDK pages 1,000 at a time for you. results = get_results(pwsid="IL0316000", limit=None) print(f"Total: {len(results)} records") # A state, analyte group or address query stops at the API's 10,000-record # offset limit, with a warning. Narrow it with dates or an analyte instead: lead_2025 = get_results(state="IL", analyte="Lead", start_date="2025-01-01", end_date="2025-12-31", limit=None) ``` ```javascript import { getResults } from '@muriel-ai/sdk' // limit: null returns every record; the SDK pages 1,000 at a time for you. const results = await getResults({ pwsid: 'IL0316000', limit: null }) console.log(`Total: ${results.length} records`) // A state, analyte group or address query stops at the API's 10,000-record // offset limit, with a console warning. Narrow it with dates or an analyte instead: const lead2025 = await getResults({ state: 'IL', analyte: 'Lead', startDate: '2025-01-01', endDate: '2025-12-31', limit: null }) ``` GET `get_results(...) → List[Dict]` *Full signature* ```python get_results( pwsid=None, # str — Public Water System ID address=None, # str — Street address for geographic search state=None, # str — State abbreviation (e.g. "IL") analyte=None, # str — Analyte name (e.g. "Lead") result_analyte_name=None,# str — Alias for analyte analyte_group=None, # str — Analyte group key (see Analyte Groups) api_key=None, # str — API key (or MURIEL_API_KEY env var) num=None, # int — Alias for limit limit=10, # int — Max records. None = every record; over 1,000 is paged for you latest=True, # bool — Sort by most recent first start_date=None, # str — ISO 8601 start date "YYYY-MM-DD" end_date=None, # str — ISO 8601 end date "YYYY-MM-DD" radius_miles=10.0, # float— Search radius for address queries geocoded_only=False, # bool — Only records with coordinates detections_only=False, # bool — Only detected concentrations cas_number=None, # str — Filter by CAS number min_concentration=None, # float— Minimum concentration threshold exclude_censored=False, # bool — Exclude censored data presence_indicator=None, # str — "P" (detected) | "A" (absent) offset=0, # int — Pagination offset base_url=None, # str — Override API base URL era=..., # str — "all" (history included) or "recent"; see below ) -> List[Dict[str, Any]] ``` ```javascript getResults({ pwsid, // string — Public Water System ID state, // string — State abbreviation (e.g. "IL") analyte, // string — Analyte name (e.g. "Lead") analyteGroup, // string — Analyte group key (see Analyte Groups) apiKey, // string — API key (or MURIEL_API_KEY env var) limit, // number — Max records (default 100). null = every record; over 1,000 is paged for you startDate, // string — ISO 8601 start date "YYYY-MM-DD" endDate, // string — ISO 8601 end date "YYYY-MM-DD" geocodedOnly, // boolean — Only records with coordinates detectionsOnly, // boolean — Only detected concentrations minConcentration, // number — Minimum concentration threshold presenceIndicator, // string — "P" (detected) | "A" (absent) offset, // number — Pagination offset baseUrl, // string — Override API base URL era, // 'all' | 'recent' — see below }): Promise ``` ### Parameters | Parameter | Type | Default | Description | | --- | --- | --- | --- | | pwsid | str | None | Public Water System Identifier (e.g. "IL0316000"). Required if state is not provided. | | state | str | None | Two-letter state code (e.g. "IL", "CA"). Returns all systems in that state. | | analyte | str | None | Specific analyte name to filter results (e.g. "Lead", "PFOA"). | | analyte_group | str | None | Analyte group key (pfas, metals_inorganic, dbps, microbial, etc.). | | limit | int | 10 | Number of records to return. Maximum 1,000 per request. | | era | str | by route | "all" includes the historical archive (back to the 1970s for many states); "recent" is the window states currently publish. Defaults: "all" for a system or state; "recent" for an address; "recent" for an analyte group. | | start_date | str | None | Filter records on or after this date. Format: YYYY-MM-DD. | | end_date | str | None | Filter records on or before this date. Format: YYYY-MM-DD. | | geocoded_only | bool | False | Return only records that include latitude/longitude coordinates. | | detections_only | bool | False | Return only records where the analyte was detected. | | min_concentration | float | None | Records with concentration_value at or above this, in mg/L (lead action level: 0.015). | | presence_indicator | str | None | "P" = present/detected, "A" = absent/not detected. Used for microbial tests. | | offset | int | 0 | Number of records to skip. Use with limit for pagination. | | api_key | str | None | API key. Falls back to MURIEL_API_KEY environment variable. | ### Each record | Field | Description | | --- | --- | | result_uuid | Stable identifier for the result. | | state, pwsid, system_name | Where it was sampled: state, water system ID and name. | | result_analyte_name, analyte_group | What was tested, as the state names it, and its group (see Analyte Groups). | | result_value, result_unit | The value and unit exactly as published. | | concentration_value, concentration_units | The value converted to mg/L, so results compare across states. Null when the source gives no unit. | | result_non_detect, result_less_than_indicator | Not detected; "Y" when reported as below a reporting limit. | | result_presence_absence_indicator | "P" present or "A" absent, for coliform and E. coli. | | sample_collection_date, result_collection_timestamp | When the sample was taken. | | result_sample_point, result_sample_location, result_facility_id | Where in the system: sample point, location text, facility. | | result_latitude, result_longitude, result_address_formatted | Coordinates, when the sample has been located. | | result_laboratory, result_method_code, result_sample_type | Lab, analytical method and sample type. | | era | "recent" (the window states currently publish) or "historical" (the archive). | | distance_miles, location_basis | Address searches only. Results for the system that serves the address come first (distance 0), then nearby systems. location_basis is "sample" when the sample has its own coordinates, otherwise "service_area" (placed by its system's EPA service-area boundary; distance approximate). | **Tip: Use analyte_group for broad queries** Instead of knowing exact analyte names, use `analyte_group="pfas"` to retrieve all PFAS compounds at once. See the Analyte Groups reference for all available group keys. --- # Contaminant Feeds > Traditional API · Muriel developer docs · https://docs.murielai.io/#contaminants The newest results for one contaminant (or group) across every state: lead, PFAS, coliform, nitrate, disinfection byproducts, arsenic, manganese and VOCs. Each is a `GET /api/v1/records/…` route, wrapped by a function in both SDKs (`getLead()` and so on in JavaScript). They read the window states currently publish (add `era="all"` to include the archive). Concentrations are in mg/L. **Every matching record, unless you set a limit** Left out, `limit` means all of them: the SDK follows the API's cursor 1,000 records at a time. A nationwide feed can run to hundreds of thousands of records, at 1 credit per 1,000 and 60 requests a minute per key, so narrow it first (`min_concentration`, `detections_only`, `geocoded_only`) or pass a limit while you explore. Requires SDK 0.2.9 (Python) or 0.2.6 (JavaScript). Metals`get_lead(...) → List[Dict]`EPA: 0.015 mg/L action level Returns lead test results. The EPA action level is 15 ppb (0.015 mg/L). Elevated lead in drinking water is primarily from household plumbing, not the source water. ```python from muriel import get_lead # All lead results lead = get_lead() # Above EPA action level action_level = get_lead(min_concentration=0.015) # Geocoded detections mapped = get_lead(detections_only=True, geocoded_only=True) ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | limit | int | None | Max records. Left out: every matching record, fetched 1,000 at a time | | detections_only | bool | False | Only detected results | | min_concentration | float | None | Min concentration (mg/L). Use 0.015 for action level. | | geocoded_only | bool | False | Only geocoded records | | api_key | str | None | API key or MURIEL_API_KEY env var | PFAS`get_pfas(...) → List[Dict]`EPA: 0.004 μg/L (4 ppt) advisory Returns PFAS ("forever chemicals") test results. Includes PFOA, PFOS, PFBS, GenX, and all compounds in the pfas analyte group. EPA advisory is 4 parts-per-trillion. ```python from muriel import get_pfas # All PFAS results pfas = get_pfas() # Above EPA advisory advisory = get_pfas(min_concentration=0.000004) # 4 ppt, in mg/L # Specific compound pfoa = get_pfas(result_analyte_name="PFOA", detections_only=True) ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | limit | int | None | Max records. Left out: every matching record, fetched 1,000 at a time | | detections_only | bool | False | Only detected results | | min_concentration | float | None | Min concentration (mg/L). 4 ppt is 0.000004. | | result_analyte_name | str | None | Filter by specific PFAS compound (e.g. "PFOA", "PFOS") | | geocoded_only | bool | False | Only geocoded records | | api_key | str | None | API key or MURIEL_API_KEY env var | Microbial`get_coliform(...) → List[Dict]`EPA: Zero tolerance (MCL) Returns total coliform rule (TCR) results including E. coli. The EPA requires zero detections. Use presence_indicator to filter by detected ("P") or absent ("A"). ```python from muriel import get_coliform # All coliform results all_tests = get_coliform() # Only positive detections positive = get_coliform(presence_indicator="P") ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | limit | int | None | Max records. Left out: every matching record, fetched 1,000 at a time | | presence_indicator | str | None | "P" = present/detected, "A" = absent | | geocoded_only | bool | False | Only geocoded records | | api_key | str | None | API key or MURIEL_API_KEY env var | Inorganic`get_nitrate(...) → List[Dict]`EPA: 10 mg/L MCL Returns nitrate test results. The EPA MCL is 10 mg/L (as nitrogen). Nitrates are primarily from agricultural runoff, fertilizers, and septic systems. ```python from muriel import get_nitrate # Nitrate above MCL over_mcl = get_nitrate(min_concentration=10) ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | limit | int | None | Max records. Left out: every matching record, fetched 1,000 at a time | | detections_only | bool | False | Only detected results | | min_concentration | float | None | Min concentration (mg/L). Use 10 for MCL. | | geocoded_only | bool | False | Only geocoded records | | api_key | str | None | API key or MURIEL_API_KEY env var | DBPs`get_dbps(...) → List[Dict]`EPA: TTHM: 80 μg/L, HAA5: 60 μg/L Returns disinfection by-products (DBPs) including TTHMs (trihalomethanes) and HAA5 (haloacetic acids). These form when chlorine reacts with organic matter. ```python from muriel import get_dbps # Total THMs above MCL tthm = get_dbps(result_analyte_name="TTHM", min_concentration=80) # All HAA5 results haa5 = get_dbps(result_analyte_name="HAA5") ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | limit | int | None | Max records. Left out: every matching record, fetched 1,000 at a time | | detections_only | bool | False | Only detected results | | min_concentration | float | None | Min concentration (mg/L). TTHM 80 μg/L is 0.080. | | result_analyte_name | str | None | Filter by DBP type (TTHM, HAA5, CHLOROFORM, etc.) | | geocoded_only | bool | False | Only geocoded records | | api_key | str | None | API key or MURIEL_API_KEY env var | Metals`get_arsenic(...) → List[Dict]`EPA: 0.010 mg/L MCL (10 ppb) Returns arsenic test results. The EPA MCL is 10 ppb (0.010 mg/L). Naturally occurring in groundwater, especially in the western US. ```python from muriel import get_arsenic # Above MCL over_mcl = get_arsenic(min_concentration=0.010) ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | limit | int | None | Max records. Left out: every matching record, fetched 1,000 at a time | | detections_only | bool | False | Only detected results | | min_concentration | float | None | Min concentration (mg/L). Use 0.010 for MCL. | | geocoded_only | bool | False | Only geocoded records | | api_key | str | None | API key or MURIEL_API_KEY env var | VOCs`get_vocs(...) → List[Dict]`EPA: Varies by compound Returns volatile organic compound (VOC) results including benzene, toluene, TCE, PCE, and many more. Often from industrial contamination, underground storage tanks, or dry cleaning. ```python from muriel import get_vocs # Benzene detections benzene = get_vocs(result_analyte_name="BENZENE", detections_only=True) # All VOC results vocs = get_vocs() ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | limit | int | None | Max records. Left out: every matching record, fetched 1,000 at a time | | detections_only | bool | False | Only detected results | | min_concentration | float | None | Min concentration | | result_analyte_name | str | None | Filter by VOC (BENZENE, TOLUENE, TCE, PCE, etc.) | | geocoded_only | bool | False | Only geocoded records | | api_key | str | None | API key or MURIEL_API_KEY env var | Metals`get_manganese(...) → List[Dict]`EPA: 0.05 mg/L secondary MCL Returns manganese test results. The EPA secondary MCL is 0.05 mg/L. Manganese occurs naturally in groundwater and can cause aesthetic issues at higher concentrations. ```python from muriel import get_manganese above_smcl = get_manganese(min_concentration=0.05) ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | limit | int | None | Max records. Left out: every matching record, fetched 1,000 at a time | | detections_only | bool | False | Only detected results | | min_concentration | float | None | Min concentration (mg/L). Use 0.05 for secondary MCL. | | geocoded_only | bool | False | Only geocoded records | | api_key | str | None | API key or MURIEL_API_KEY env var | --- # Water Systems > Traditional API · Muriel developer docs · https://docs.murielai.io/#systems Public water systems are identified by PWSID: a two-letter state code and seven characters, e.g. `IL0316000`. Use this route to list the systems a state holds results for. ### get_systems() GET `/api/v1/systems/{state}` Systems with results in a state, with name, population served, service connections, size band, how many results are held and the newest sample date. ```python from muriel import get_systems # limit=None returns every system in the state; the SDK pages for you resp = get_systems(state="IL", limit=None) print(resp["system_count"], "of", resp["total_systems"], "systems") for s in resp["records"]: print(s["pwsid"], s["pws_name"], s["population_served_count"], s["latest_sample"]) ``` ```javascript import { getSystems } from '@muriel-ai/sdk' // limit: null returns every system in the state; the SDK pages for you const systems = await getSystems({ state: 'IL', limit: null }) for (const s of systems) { console.log(s.pwsid, s.pws_name, s.population_served_count, s.latest_sample) } ``` | Parameter | Type | Description | | --- | --- | --- | | stateRequired | str | Two-letter state code. | | limit | int | Max systems per call, up to 20,000 (default 100). Pass `None` for every system in the state. | | offset | int | Systems to skip, for paging by hand. The response's `total_systems` and `has_more` say how many there are and whether another page follows. | *Response* ```json { "state": "IL", "system_count": 100, "total_systems": 1734, "systems": ["IL0010100", "IL0010150", ...], "records": [ { "pwsid": "IL0316000", "pws_name": "CHICAGO", "state_code": "IL", "population_served_count": 2700000, "service_connections_count": 497000, "size_band": "very large", "results_held": 41205, "latest_sample": "2026-10-06" } ], "limit": 100, "offset": 0, "has_more": true } ``` ### Monitoring requirements and violations Muriel holds sample results, not state violation or requirement records, so `get_requirements()` and `get_violations()` return HTTP 501 for now. Ask the agent instead: it computes candidate monitoring gaps and exceedances from the results. ```python import muriel answer = muriel.query("Which Illinois systems look behind on their required monitoring?") print(answer.summary) ``` ```javascript import { query } from '@muriel-ai/sdk' const answer = await query('Which Illinois systems look behind on their required monitoring?') console.log(answer.summary) ``` --- # Facilities > Traditional API · Muriel developer docs · https://docs.murielai.io/#facilities Access geocoded facility records for public water systems. Includes latitude/longitude coordinates, formatted addresses, and geocoding confidence scores for mapping and spatial analysis. **Coverage is thin** Most states publish a sample point code rather than a facility ID or an address, so few facilities and sampling points have a location yet. An empty list comes with a `note` saying so. To find results for a place, search by address on [Sample Results](https://docs.murielai.io/md/get-results.md) : it matches the address to the water system that serves it. ### get_state_facilities() GET `get_state_facilities(state,api_key,limit=100 ) → Dict` Returns all geocoded facilities for a given US state. Each facility record includes the public water system identifier, system name, state-assigned facility ID, and detailed geocoding data. ```python from muriel import get_state_facilities facilities = get_state_facilities( state="IL", limit=100 ) print(f"Total facilities: {facilities['facility_count']}") for f in facilities['facilities']: if f.get('geocoding'): geo = f['geocoding'] print(f"{f['pwsid']} — {f['system_name']}") print(f" Location: {geo['latitude']}, {geo['longitude']}") print(f" Address: {geo['address_formatted']}") print(f" Confidence: {geo['confidence']}%") ``` | Parameter | Type | Description | | --- | --- | --- | | stateRequired | str | Two-letter state code (e.g. "IL", "CA", "TX") | | limit | int | Max facilities to return (default: 100) | | api_key | str | API key or MURIEL_API_KEY env var | *Response shape* ```json { "count": 1247, "facilities": [ { "pwsid": "IL0316000", "system_name": "CHICAGO WATER DEPT", "facility_state_assigned_id": "00001", "geocoding": { "latitude": 41.8781, "longitude": -87.6298, "address": "333 S State St", "address_formatted": "333 S State St, Chicago, IL 60604", "source": "Google", "confidence": 97, "match_type": "street", "postcode": "60604", "county": "Cook" } } ] } ``` ### get_facility_geocoding() GET `get_facility_geocoding(pwsid,facility_id,api_key) → Dict` Returns geocoding data for a specific facility by its PWSID and facility ID. Use this for precise coordinate lookup when you already know the facility identifiers. ```python from muriel import get_facility_geocoding # Get geocoding for a specific facility geo = get_facility_geocoding( pwsid="IL0316000", facility_id="00001" ) print(geo['latitude'], geo['longitude']) print(geo['address_formatted']) print(f"Source: {geo['source']}, Confidence: {geo['confidence']}%") ``` | Parameter | Type | Description | | --- | --- | --- | | pwsidRequired | str | Public Water System Identifier | | facility_idRequired | str | State-assigned facility ID | | api_key | str | API key or MURIEL_API_KEY env var | *Response shape* ```json { "latitude": 41.8781, "longitude": -87.6298, "address": "333 S State St", "address_formatted": "333 S State St, Chicago, IL 60604", "source": "Google", "confidence": 97, "match_type": "street", "postcode": "60604", "county": "Cook" } ``` **171,073 geocoded sampling points** Locations come from the samples themselves: a facility or sampling point appears once a sample taken there has been located, from the state's own coordinates or by geocoding the address it reports. Confidence scores range from 0–100. A score of 90+ indicates a high-quality coordinate match. --- # Sampling Points > Traditional API · Muriel developer docs · https://docs.murielai.io/#sampling-points Access geocoded sampling point records. Sampling points are the specific locations where water is collected for testing — entry points, distribution system taps, source water locations, etc. ### get_state_sampling_points() GET `get_state_sampling_points(state,api_key,limit=100 ) → Dict` Returns all geocoded sampling points for a state. Use this to build maps or link test results to physical locations. ```python from muriel import get_state_sampling_points points = get_state_sampling_points(state="CA", limit=100) print(f"Total sampling points: {points['count']}") for p in points['sampling_points']: geo = p.get('geocoding') if geo: print(f"{p['pwsid']} / {p['sample_point']}") print(f" {geo['latitude']}, {geo['longitude']}") ``` | Parameter | Type | Description | | --- | --- | --- | | stateRequired | str | Two-letter state code | | limit | int | Max sampling points (default: 100) | | api_key | str | API key or MURIEL_API_KEY env var | *Response shape* ```json { "count": 4821, "sampling_points": [ { "pwsid": "CA3010037", "system_name": "CITY OF LOS ANGELES", "sample_point": "EP", "facility_state_assigned_id": "00001", "geocoding": { "latitude": 34.0522, "longitude": -118.2437, "address_formatted": "200 N Spring St, Los Angeles, CA 90012", "confidence": 95, "source": "Google" } } ] } ``` ### get_sampling_point_geocoding() GET `get_sampling_point_geocoding(pwsid,sample_point,facility_id=None ) → Dict` Get geocoding for a specific sampling point. Pass `facility_id` to disambiguate when a sample point ID exists at multiple facilities. ```python from muriel import get_sampling_point_geocoding geo = get_sampling_point_geocoding( pwsid="IL0316000", sample_point="EP", facility_id="00001" # optional ) print(f"Coordinates: {geo['latitude']}, {geo['longitude']}") print(f"Address: {geo['address_formatted']}") print(f"Confidence: {geo['confidence']}%") ``` | Parameter | Type | Description | | --- | --- | --- | | pwsidRequired | str | Public Water System Identifier | | sample_pointRequired | str | Sample point identifier (e.g. "EP", "DS1") | | facility_id | str | Optional facility ID for disambiguation | | api_key | str | API key or MURIEL_API_KEY env var | --- # Coverage & Stats > Traditional API · Muriel developer docs · https://docs.murielai.io/#coverage Which states Muriel covers, how far back each goes, and how current it is. Check coverage before reading an empty result as good news: no rows usually means a state does not publish that data, not that the water is clean. ### GET /api/v1/coverage Per state: status (`current`, `stale` or `dead` — the last means only historical data is held), results held, the earliest result and the newest. *Request* ```bash # Public: no key needed curl https://api.murielai.io/api/v1/coverage ``` *Response (trimmed)* ```json { "available": true, "total_rows": 88446413, "states": [ { "code": "CA", "name": "California", "status": "current", "rows": 2861034, "history_from": "1975-11-18", "through": "2026-10-08" }, ... ], "counts": { "current": 16, "stale": 1, "dead": 15 } } ``` ### GET /api/v1/stats and /api/v1/stats/{state} Totals for the whole store and per state, with the earliest and latest sample dates. ```python from muriel import MurielClient with MurielClient() as client: stats = client.get("/api/v1/stats") print(stats["total_records"], stats["latest_timestamp"]) il = client.get("/api/v1/stats/IL") ``` ```javascript import { MurielClient } from '@muriel-ai/sdk' const client = new MurielClient() const stats = await client.get('/api/v1/stats') console.log(stats.total_records, stats.latest_timestamp) const il = await client.get('/api/v1/stats/IL') ``` ### GET /api/v1/health Public. `{"ok": true, "api_version": "1.0.0", "data": true}` --- # Latest Results > Traditional API · Muriel developer docs · https://docs.murielai.io/#latest-results Recipes for the newest results: what each state published most recently, what came in this week, a system's current readings, and a watcher that returns only what is new since its last run. All but the last use data calls: no language model, about a second each, 1 credit per 1,000 records. The same recipes are in a [Jupyter notebook](https://github.com/Muriel-AI-Inc/muriel-sdk-python/blob/main/examples/latest_results.ipynb). **How fresh is "latest"?** - Muriel pulls each state's published results as the state updates them, many of them daily. In the fastest states a result is available a day or two after the sample was taken. - Every route returns newest first by default. Filter on `sample_collection_date` with `start_date` / `end_date` (`startDate` / `endDate` in JavaScript) to get a window. - `era="recent"` keeps a query to the window states currently publish, which is where new results land; it skips the historical archive. ### Setup Imports and two helpers: a date N days ago, and the columns worth showing. ```python import json import os from datetime import date, timedelta from pathlib import Path import pandas as pd from muriel import MurielClient, get_coliform, get_latest, get_lead, get_results, query # Reads MURIEL_API_KEY from the environment, or pass api_key="..." to each call. assert os.environ.get("MURIEL_API_KEY"), "Set MURIEL_API_KEY first" TODAY = date.today().isoformat() def days_ago(n): return (date.today() - timedelta(days=n)).isoformat() COLS = ["state", "pwsid", "system_name", "result_analyte_name", "sample_collection_date", "result_value", "result_unit"] ``` ```javascript import { MurielClient, getColiform, getLatest, getLead, getResults, query } from '@muriel-ai/sdk' import { existsSync, readFileSync, writeFileSync } from 'node:fs' // Reads MURIEL_API_KEY from the environment, or pass apiKey: '...' to each call. const TODAY = new Date().toISOString().slice(0, 10) const daysAgo = n => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10) const show = r => ({ state: r.state, pwsid: r.pwsid, system: r.system_name, analyte: r.result_analyte_name, date: r.sample_collection_date, value: r.result_value, unit: r.result_unit, }) ``` ### How current each state is Start here. "Latest" means the newest result a state has published, so check each state's dates first: last_updated is when Muriel last received the state's data, through is the newest sample date on file. Free: the coverage route needs no credits. ```python with MurielClient() as client: cov = client.get("/api/v1/coverage") fresh = pd.DataFrame(cov["states"])[["code", "name", "last_updated", "through"]] fresh.sort_values("through", ascending=False) ``` ```javascript const cov = await new MurielClient().get('/api/v1/coverage') console.table(cov.states .map(s => ({ state: s.code, name: s.name, lastUpdated: s.last_updated, through: s.through })) .sort((a, b) => (b.through ?? '').localeCompare(a.through ?? ''))) ``` ### The newest results nationwide The 1,000 most recently collected results across every state, any contaminant, newest first. Sample dates in the future (data-entry errors) are left out. 1 credit. ```python latest = pd.DataFrame(get_latest(limit=1000)) print(f"{len(latest):,} results collected " f"{latest['sample_collection_date'].min()} to {latest['sample_collection_date'].max()}") latest[COLS].head(20) ``` ```javascript const latest = await getLatest({ limit: 1000 }) const dates = latest.map(r => r.sample_collection_date).filter(Boolean).sort() console.log(`${latest.length} results collected ${dates[0]} to ${dates.at(-1)}`) console.table(latest.slice(0, 20).map(show)) ``` ### Everything a state collected in the last few days Every Texas result with a sample date in the last 3 days, counted by contaminant. limit=None pages through all of them, up to the API's 10,000-record limit for a state query; shorten the window if the SDK warns that it stopped there. 1 credit per 1,000 records. ```python recent = pd.DataFrame(get_results(state="TX", start_date=days_ago(3), end_date=TODAY, era="recent", limit=None)) print(f"{len(recent):,} Texas results collected since {days_ago(3)}") recent.groupby("result_analyte_name").size().sort_values(ascending=False).head(15) ``` ```javascript const recent = await getResults({ state: 'TX', startDate: daysAgo(3), endDate: TODAY, era: 'recent', limit: null, }) console.log(`${recent.length} Texas results collected since ${daysAgo(3)}`) const byAnalyte = {} for (const r of recent) byAnalyte[r.result_analyte_name] = (byAnalyte[r.result_analyte_name] ?? 0) + 1 console.table(Object.entries(byAnalyte).sort((a, b) => b[1] - a[1]).slice(0, 15)) ``` ### A system right now: the latest result for each contaminant One page of a system's current results, newest first, kept to the first (newest) row per contaminant. One call and 1 credit; use it for a status panel or an alert check. ```python PWSID = "IL0316000" # Chicago rows = pd.DataFrame(get_results(pwsid=PWSID, era="recent", limit=1000)) rows = rows[rows["sample_collection_date"] <= TODAY] snapshot = rows.drop_duplicates("result_analyte_name") # newest first, so the first row is the latest snapshot[["result_analyte_name", "sample_collection_date", "result_value", "result_unit", "result_non_detect"]].sort_values("result_analyte_name") ``` ```javascript const PWSID = 'IL0316000' // Chicago const rows = await getResults({ pwsid: PWSID, era: 'recent', limit: 1000 }) // Rows come newest first, so the first row for each contaminant is its latest. const snapshot = new Map() for (const r of rows) { if ((r.sample_collection_date ?? '') > TODAY) continue if (!snapshot.has(r.result_analyte_name)) snapshot.set(r.result_analyte_name, r) } console.table([...snapshot.values()].map(r => ({ analyte: r.result_analyte_name, date: r.sample_collection_date, value: r.result_value, unit: r.result_unit, nonDetect: r.result_non_detect, }))) ``` ### Recent positives and exceedances E. coli present in the last 30 days, and lead above the 15 µg/L action level (0.015 mg/L) in the last 90, nationwide. The contaminant feeds return newest first; the date window is applied to that page. These are published results, not violation determinations. 2 credits. ```python def in_window(df, days): d = df["sample_collection_date"] return df[(d >= days_ago(days)) & (d <= TODAY)] positives = pd.DataFrame(get_coliform(presence_indicator="P", era="recent", limit=1000)) ecoli = positives[positives["result_analyte_name"].str.contains( r"e\.?\s*coli|escherichia", case=False, na=False)] print("E. coli present, last 30 days") display(in_window(ecoli, 30)[COLS]) lead = pd.DataFrame(get_lead(min_concentration=0.015, era="recent", limit=1000)) print("Lead above the action level, last 90 days") display(in_window(lead, 90)[COLS]) ``` ```javascript const inWindow = (rows, days) => rows.filter(r => r.sample_collection_date >= daysAgo(days) && r.sample_collection_date <= TODAY) const positives = await getColiform({ presenceIndicator: 'P', era: 'recent', limit: 1000 }) const ecoli = positives.filter(r => /e\.?\s*coli|escherichia/i.test(r.result_analyte_name ?? '')) console.log('E. coli present, last 30 days') console.table(inWindow(ecoli, 30).map(show)) const lead = await getLead({ minConcentration: 0.015, era: 'recent', limit: 1000 }) console.log('Lead above the action level, last 90 days') console.table(inWindow(lead, 90).map(show)) ``` ### A watcher: only what is new since the last run The building block for a near-real-time feed. Run it on a schedule (cron, a GitHub Action, Airflow); each run returns only results it has not returned before. result_uuid is derived from the result itself, so it is the same on every call, and a corrected value arrives as a new result. The 3-day window overlaps each run, which catches results a state publishes late; the state file only keeps IDs from that window, so it never grows. ```python SEEN = Path("muriel_seen_TX.json") def new_results(state="TX", days=3): """Results collected in the last `days` days that earlier runs have not returned.""" seen = set(json.loads(SEEN.read_text())) if SEEN.exists() else set() rows = get_results(state=state, start_date=days_ago(days), end_date=TODAY, era="recent", limit=None) fresh = [r for r in rows if r["result_uuid"] not in seen] SEEN.write_text(json.dumps(sorted(r["result_uuid"] for r in rows))) return pd.DataFrame(fresh, columns=COLS + ["result_uuid"]) new = new_results() print(f"{len(new):,} new results") # the first run returns the whole window new[COLS].head(20) ``` ```javascript const SEEN = 'muriel_seen_TX.json' // Results collected in the last `days` days that earlier runs have not returned. async function newResults(state = 'TX', days = 3) { const seen = new Set(existsSync(SEEN) ? JSON.parse(readFileSync(SEEN, 'utf8')) : []) const rows = await getResults({ state, startDate: daysAgo(days), endDate: TODAY, era: 'recent', limit: null, }) const fresh = rows.filter(r => !seen.has(r.result_uuid)) writeFileSync(SEEN, JSON.stringify(rows.map(r => r.result_uuid).sort())) return fresh } const fresh = await newResults() console.log(`${fresh.length} new results`) // the first run returns the whole window console.table(fresh.slice(0, 20).map(show)) ``` ### What changed, in words (the agent) When you want a reading rather than rows: the agent with since= looks only at results and events on or after that date. Tens of seconds and 1 to 3 credits, like any agent question. ```python answer = query( "What new results came in for this system, and does anything stand out " "against federal limits?", pwsid="IL0316000", since=days_ago(30), ) print(answer.summary) ``` ```javascript const answer = await query( 'What new results came in for this system, and does anything stand out ' + 'against federal limits?', { pwsid: 'IL0316000', since: daysAgo(30) }, ) console.log(answer.summary) ``` --- # Data Recipes > Traditional API · Muriel developer docs · https://docs.murielai.io/#data-recipes Complete examples built from the data calls. They read the data store directly, with no language model, so each returns in about a second and costs 1 credit per 1,000 records. Use them for lookups and screens; keep the agent for questions that need judgment. The same recipes are in a [Jupyter notebook](https://github.com/Muriel-AI-Inc/muriel-sdk-python/blob/main/examples/data_recipes.ipynb). For the newest results (what came in this week, a watcher for new results), see [Latest Results](https://docs.murielai.io/md/latest-results.md). **Data calls or the agent?** - **Data calls** (these recipes): about a second each, 1 credit per 1,000 records. Best when you know what to fetch. - **The agent**: tens of seconds, 1–3 credits a question. Best for judgment across sources: assessments, comparisons, explanations. - Violation and monitoring-requirement records are not in the API yet. Ask the agent about exceedances and monitoring gaps, which it computes from the results. ### Setup Imports and one helper used by the recipes below: a system's full history, newest first, fetched 1,000 records at a time (the API pages up to 10,000). ```python import os import pandas as pd from muriel import get_results, get_systems # Reads MURIEL_API_KEY from the environment, or pass api_key="..." to each call. assert os.environ.get("MURIEL_API_KEY"), "Set MURIEL_API_KEY first" def system_history(pwsid, max_records=10_000, **filters): """Every result for a system, newest first, paging 1,000 at a time.""" rows, offset = [], 0 while offset < max_records: page = get_results(pwsid=pwsid, limit=1000, offset=offset, era="all", **filters) rows += page if len(page) < 1000: break offset += 1000 return pd.DataFrame(rows) ``` ```javascript import { getResults, getSystems, Agent } from '@muriel-ai/sdk' // Reads MURIEL_API_KEY from the environment, or pass apiKey: '...' to each call. // Every result for a system, newest first, paging 1,000 at a time. async function systemHistory(pwsid, maxRecords = 10_000) { const rows = [] for (let offset = 0; offset < maxRecords; offset += 1000) { const page = await getResults({ pwsid, limit: 1000, offset, era: 'all' }) rows.push(...page) if (page.length < 1000) break } return rows } ``` ### Everything on file for one system A system's full record, then the most recent result for each contaminant. ```python PWSID = "CA3010037" df = system_history(PWSID) print(f"{len(df):,} results for {df['system_name'].iloc[0] if len(df) else PWSID}") latest = (df.sort_values("sample_collection_date") .groupby("result_analyte_name").tail(1) .sort_values("result_analyte_name")) latest[["result_analyte_name", "sample_collection_date", "result_value", "result_unit", "result_non_detect"]] ``` ```javascript const PWSID = 'CA3010037' const rows = await systemHistory(PWSID) console.log(`${rows.length} results for ${rows[0]?.system_name ?? PWSID}`) // The most recent result for each contaminant const latest = new Map() for (const r of rows) { const prev = latest.get(r.result_analyte_name) if (!prev || (r.sample_collection_date ?? '') > (prev.sample_collection_date ?? '')) { latest.set(r.result_analyte_name, r) } } console.table([...latest.values()].map(r => ({ analyte: r.result_analyte_name, date: r.sample_collection_date, value: r.result_value, unit: r.result_unit, nonDetect: r.result_non_detect, }))) ``` ### Lead over the action level across a state Lead results above 15 µg/L (0.015 mg/L) in New Jersey, ranked by system. min_concentration is in mg/L. ```python lead = pd.DataFrame(get_results(state="NJ", analyte="Lead", detections_only=True, min_concentration=0.015, limit=500)) print(f"{len(lead):,} results") (lead.groupby(["pwsid", "system_name"]).size() .sort_values(ascending=False).head(15).rename("samples over 15 µg/L")) ``` ```javascript const lead = await getResults({ state: 'NJ', analyte: 'Lead', detectionsOnly: true, minConcentration: 0.015, limit: 500 }) const bySystem = new Map() for (const r of lead) { const key = `${r.pwsid} ${r.system_name}` bySystem.set(key, (bySystem.get(key) ?? 0) + 1) } console.log(`${lead.length} results`) console.table([...bySystem].sort((a, b) => b[1] - a[1]).slice(0, 15) .map(([system, samples]) => ({ system, samples }))) ``` ### PFAS detections in a state, by compound Detections in the pfas analyte group in California: how many, in how many systems, and the latest. ```python pfas = pd.DataFrame(get_results(state="CA", analyte_group="pfas", detections_only=True, limit=1000)) print(f"{len(pfas):,} detections") (pfas.groupby("result_analyte_name") .agg(detections=("pwsid", "size"), systems=("pwsid", "nunique"), latest=("sample_collection_date", "max")) .sort_values("detections", ascending=False).head(10)) ``` ```javascript const pfas = await getResults({ state: 'CA', analyteGroup: 'pfas', detectionsOnly: true, limit: 1000 }) const byCompound = new Map() for (const r of pfas) { const c = byCompound.get(r.result_analyte_name) ?? { detections: 0, systems: new Set(), latest: '' } c.detections += 1 c.systems.add(r.pwsid) if ((r.sample_collection_date ?? '') > c.latest) c.latest = r.sample_collection_date byCompound.set(r.result_analyte_name, c) } console.log(`${pfas.length} detections`) console.table([...byCompound].sort((a, b) => b[1].detections - a[1].detections).slice(0, 10) .map(([compound, c]) => ({ compound, detections: c.detections, systems: c.systems.size, latest: c.latest }))) ``` ### The largest systems in a state Every system in New Jersey (limit=None pages for you), largest first, with how many results Muriel holds and the newest sample. ```python systems = pd.DataFrame(get_systems(state="NJ", limit=None)["records"]) print(f"{len(systems):,} systems") systems.sort_values("population_served_count", ascending=False)[ ["pwsid", "pws_name", "population_served_count", "results_held", "latest_sample"]].head(10) ``` ```javascript const systems = await getSystems({ state: 'NJ', limit: null }) console.log(`${systems.length} systems`) console.table(systems .sort((a, b) => (b.population_served_count ?? 0) - (a.population_served_count ?? 0)) .slice(0, 10) .map(s => ({ pwsid: s.pwsid, name: s.pws_name, population: s.population_served_count, results: s.results_held, latest: s.latest_sample }))) ``` ### A due-diligence screen without the agent The agent's due-diligence example, built from data calls in seconds. It flags results over a federal limit (critical when recent) and key contaminants with nothing on file. The limits and rules are illustrative: one result over a limit is not a violation, since compliance is judged on running averages and by the state, so treat this as a screen. concentration_value is returned in mg/L. ```python from datetime import date, timedelta # Federal limits in mg/L (lead and copper are action levels, not MCLs). LIMITS_MG_L = { "ARSENIC": 0.010, "NITRATE": 10.0, "NITRITE": 1.0, "FLUORIDE": 4.0, "BARIUM": 2.0, "PFOA": 4e-6, "PFOS": 4e-6, "LEAD": 0.015, "COPPER": 1.3, "TOTAL TRIHALOMETHANES": 0.080, "TTHM": 0.080, "TOTAL THMS": 0.080, "TOTAL HALOACETIC ACIDS (HAA5)": 0.060, "HAA5": 0.060, } TO_MG_L = {"MG/L": 1, "PPM": 1, "UG/L": 1e-3, "µG/L": 1e-3, "PPB": 1e-3, "NG/L": 1e-6, "PPT": 1e-6} KEY_CONTAMINANTS = ("LEAD", "ARSENIC", "NITRATE", "PFOA") def to_mg_l(row): value = row.get("concentration_value") unit = str(row.get("concentration_units") or row.get("result_unit") or "").upper().replace(" ", "") factor = TO_MG_L.get(unit) return None if value is None or factor is None else float(value) * factor def show(mg_l): """A concentration in the unit it is usually quoted in.""" if mg_l < 1e-4: return f"{mg_l * 1e6:.3g} ng/L" if mg_l < 0.1: return f"{mg_l * 1e3:.3g} µg/L" return f"{mg_l:.3g} mg/L" def due_diligence(pwsid, recent_years=3): results = system_history(pwsid) cutoff = (date.today() - timedelta(days=365 * recent_years)).isoformat() flags, held = [], set() if len(results): r = results.copy() r["analyte"] = r["result_analyte_name"].str.upper().str.strip() r["limit_mg_l"] = r["analyte"].map(LIMITS_MG_L) r["mg_l"] = r.apply(to_mg_l, axis=1) held = set(r["analyte"]) over = r[r["limit_mg_l"].notna() & r["mg_l"].notna() & (r["mg_l"] > r["limit_mg_l"])] for analyte, g in over.groupby("analyte"): last = str(g["sample_collection_date"].max()) flags.append({"issue_type": "result over federal limit", "analyte": analyte, "severity": "critical" if last >= cutoff else "moderate", "description": f"{len(g)} result(s) over {show(g['limit_mg_l'].iloc[0])}, " f"highest {show(g['mg_l'].max())}, most recent {last}", "date_identified": last}) for name in KEY_CONTAMINANTS: if name not in held: flags.append({"issue_type": "no results on file", "analyte": name, "severity": "minor", "description": "nothing in the published record", "date_identified": None}) critical = sum(f["severity"] == "critical" for f in flags) assessment = "fail" if critical else "caution" if flags else "pass" name = results["system_name"].iloc[0] if len(results) else pwsid return {"pwsid": pwsid, "system_name": name, "results_on_file": len(results), "flags": flags, "critical_count": critical, "overall_assessment": assessment} report = due_diligence("CA3010037") status_emoji = {"pass": "✅", "caution": "⚠️", "fail": "❌"} print(f"{status_emoji[report['overall_assessment']]} {report['system_name']} " f"({report['results_on_file']:,} results on file)") print(f"Assessment: {report['overall_assessment'].upper()}, {report['critical_count']} critical") for f in report["flags"]: print(f" [{f['severity'].upper()}] {f['issue_type']}: {f['analyte']} {f['description']}") ``` ```javascript // Federal limits in mg/L (lead and copper are action levels, not MCLs). const LIMITS_MG_L = { ARSENIC: 0.010, NITRATE: 10.0, NITRITE: 1.0, FLUORIDE: 4.0, BARIUM: 2.0, PFOA: 4e-6, PFOS: 4e-6, LEAD: 0.015, COPPER: 1.3, 'TOTAL TRIHALOMETHANES': 0.080, TTHM: 0.080, 'TOTAL THMS': 0.080, 'TOTAL HALOACETIC ACIDS (HAA5)': 0.060, HAA5: 0.060, } const TO_MG_L = { 'MG/L': 1, PPM: 1, 'UG/L': 1e-3, 'µG/L': 1e-3, PPB: 1e-3, 'NG/L': 1e-6, PPT: 1e-6 } const KEY_CONTAMINANTS = ['LEAD', 'ARSENIC', 'NITRATE', 'PFOA'] const toMgL = r => { const unit = String(r.concentration_units ?? r.result_unit ?? '').toUpperCase().replace(/\s/g, '') const factor = TO_MG_L[unit] return r.concentration_value == null || factor == null ? null : Number(r.concentration_value) * factor } const show = mgL => mgL < 1e-4 ? `${+(mgL * 1e6).toPrecision(3)} ng/L` : mgL < 0.1 ? `${+(mgL * 1e3).toPrecision(3)} µg/L` : `${+mgL.toPrecision(3)} mg/L` async function dueDiligence(pwsid, recentYears = 3) { const results = await systemHistory(pwsid) const cutoff = new Date(Date.now() - recentYears * 365 * 864e5).toISOString().slice(0, 10) const over = new Map(), held = new Set() for (const r of results) { const analyte = (r.result_analyte_name ?? '').toUpperCase().trim() held.add(analyte) const limit = LIMITS_MG_L[analyte], mgL = toMgL(r) if (limit != null && mgL != null && mgL > limit) { const g = over.get(analyte) ?? { n: 0, max: 0, last: '', limit } g.n += 1; g.max = Math.max(g.max, mgL) if ((r.sample_collection_date ?? '') > g.last) g.last = r.sample_collection_date over.set(analyte, g) } } const flags = [...over].map(([analyte, g]) => ({ issueType: 'result over federal limit', analyte, severity: g.last >= cutoff ? 'critical' : 'moderate', description: `${g.n} result(s) over ${show(g.limit)}, highest ${show(g.max)}, most recent ${g.last}`, dateIdentified: g.last, })) for (const name of KEY_CONTAMINANTS) { if (!held.has(name)) flags.push({ issueType: 'no results on file', analyte: name, severity: 'minor', description: 'nothing in the published record', dateIdentified: null }) } const criticalCount = flags.filter(f => f.severity === 'critical').length return { pwsid, systemName: results[0]?.system_name ?? pwsid, resultsOnFile: results.length, flags, criticalCount, overallAssessment: criticalCount ? 'fail' : flags.length ? 'caution' : 'pass' } } const report = await dueDiligence('CA3010037') const statusEmoji = { pass: '✅', caution: '⚠️', fail: '❌' } console.log(`${statusEmoji[report.overallAssessment]} ${report.systemName} (${report.resultsOnFile} results on file)`) console.log(`Assessment: ${report.overallAssessment.toUpperCase()}, ${report.criticalCount} critical`) for (const f of report.flags) { console.log(` [${f.severity.toUpperCase()}] ${f.issueType}: ${f.analyte} ${f.description}`) } ``` ### The agent for the narrative only If you want a written recommendation, ask the agent for just that, scoped to the system. This is the slow step (tens of seconds); the recipes above take seconds. ```python from muriel import Agent with Agent() as agent: answer = agent.query( "In two or three sentences, give a due-diligence recommendation for this system " "based on its results over federal limits and any monitoring gaps.", pwsid="CA3010037", ) print(answer.summary) ``` ```javascript const agent = new Agent() const answer = await agent.query( 'In two or three sentences, give a due-diligence recommendation for this system ' + 'based on its results over federal limits and any monitoring gaps.', { pwsid: 'CA3010037' }, ) console.log(answer.summary) ``` --- # MurielClient > SDK Reference · Muriel developer docs · https://docs.murielai.io/#client The base HTTP client class. Handles authentication, connection pooling and error parsing; call any route by its path. Most users won't need this directly — use the functional API or OO clients instead. ### Initialization `MurielClient(api_key=None,base_url=None,timeout=30)` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | api_key | str | None | API key. Falls back to MURIEL_API_KEY env var. | | base_url | str | "https://api.murielai.io" | Override the API base URL. | | timeout | int | 30 | Request timeout in seconds. | ```python from muriel import MurielClient # Basic init client = MurielClient(api_key="YOUR_API_KEY") # With custom timeout and base URL client = MurielClient( api_key="YOUR_API_KEY", base_url="https://api.murielai.io", timeout=60 ) ``` ### Context manager (recommended) Use the context manager to ensure the HTTP session is properly closed after use. This is especially important in long-running applications. ```python from muriel import MurielClient # Context manager — session is closed automatically with MurielClient(api_key="YOUR_API_KEY") as client: data = client.get("/api/v1/records/system/IL0316000", params={"limit": 50}) print(data["record_count"], "records") ``` ### Methods `get(endpoint, params=None)`→ DictMake an authenticated GET request. `post(endpoint, json=None)`→ DictMake an authenticated POST request. `close()`→ NoneClose the underlying HTTP session. *Raw requests* ```python from muriel import MurielClient client = MurielClient(api_key="YOUR_API_KEY") # GET request: any route in the API reference, with its query parameters data = client.get("/api/v1/records/state/IL", params={ "result_analyte_name": "Lead", "limit": 100, }) records = data["records"] # POST request: the agent response = client.post("/api/v1/agent/query", json={ "prompt": "Find PFAS in Texas", "max_records": 50 }) # Always close when not using context manager client.close() ``` ### Error handling ```python from muriel import MurielClient from muriel.exceptions import ( MurielAPIError, MurielRateLimitError, MurielAuthenticationError, ) import time client = MurielClient(api_key="YOUR_API_KEY") try: data = client.get("/api/v1/records/system/IL0316000", params={"limit": 50}) except MurielRateLimitError as e: wait = e.retry_after or 20 # seconds, from the Retry-After header print(f"Rate limited; retry in {wait} seconds") time.sleep(wait) except MurielAuthenticationError: print("Invalid API key") except MurielAPIError as e: print(f"API error {e.status_code}: {e.message}") finally: client.close() ``` --- # OO Clients > SDK Reference · Muriel developer docs · https://docs.murielai.io/#oo-clients Thin wrapper classes that give you an object-oriented interface over the functional API. Useful when you want to manage a persistent client instance across your application. ### SamplesClient Client`SamplesClient(api_key=None,token=None,base_url=None)` Query water quality sample results with an object-oriented interface. `get_samples(pwsid, state, analyte, sample_date_start, sample_date_end, compliance_status, limit, **kwargs)`→ DictGet sample results with filtering. ```python from muriel import SamplesClient client = SamplesClient(api_key="YOUR_API_KEY") # By system samples = client.get_samples(pwsid="IL0316000") # By state + analyte lead_samples = client.get_samples( state="IL", analyte="Lead", detections_only=True, limit=100 ) # With date range dated = client.get_samples( pwsid="CA3010037", analyte="PFOA", sample_date_start="2022-01-01", sample_date_end="2024-12-31" ) ``` ### SystemsClient Client`SystemsClient(api_key=None,token=None,base_url=None)` Query public water system metadata. `get_systems(state, limit, system_princ, system_statu)`→ DictGet water systems for a state with optional type and status filters. ```python from muriel import SystemsClient client = SystemsClient(api_key="YOUR_API_KEY") # All active community water systems in California systems = client.get_systems( state="CA", system_statu="A", system_princ="CWS", limit=100 ) for s in systems['records']: print(s['pwsid'], s['pws_name']) ``` ### RequirementsClient Client`RequirementsClient(api_key=None,token=None,base_url=None)` Monitoring requirements. Not available yet: calls return HTTP 501 (MurielServerError). Ask the agent about monitoring gaps instead. `get_requirements(pwsid, state, limit, system_princ)`→ DictGet sampling requirements by system or state. ```python from muriel import RequirementsClient client = RequirementsClient(api_key="YOUR_API_KEY") # Requirements for a system reqs = client.get_requirements(pwsid="IL0316000") # All requirements in a state state_reqs = client.get_requirements( state="TX", limit=100 ) ``` **When to use OO clients vs. functional API** Use the functional API ( `get_results()` , etc.) for one-off scripts and notebooks. Use OO clients when you want to inject a client into a service class, mock it in tests, or share a session across many calls. --- # Analyte Groups > SDK Reference · Muriel developer docs · https://docs.murielai.io/#analyte-groups Every record in the dataset is assigned to one of 13 analyte groups based on chemical classification and EPA regulatory categories. Groups let you query entire contaminant families at once — just mention the group or contaminant type in your query. ```python from muriel import MurielClient client = MurielClient(api_key="your_api_key") # Reference analyte groups in plain-English queries response = client.query("PFAS contamination in Illinois water systems since 2020") response = client.query("lead and copper results in Cook County") response = client.query("radiological results above the MCL in Arizona water systems") ``` 288 analytes across 13 groups `"pfas"`PFAS38 analytes Per- and polyfluoroalkyl substances ("forever chemicals") including legacy compounds and emerging alternatives. 11CL-PF3OUDS9CL-PF3ONSADONAADSORBABLE ORGANIC FLUORINEHFPO-DAHEXAFLUOROPROPYLENE OXIDE DIMER ACID (HFN-ETHYL PERFLUOROCTANESULFONAMIDOACETICNETFOSAANMEFOSAANMETHYL PERFLUOROOCTANESULFONAMIDOACETICNONAFLUORO NFDHAPERFLUORO PFEESAPERFLUORO PFMBAPERFLUORO PFMPAPERFLUORO(2-ETHOXYETHANE)SULFONIC ACID (PERFLUOROBUTANE SULFONIC ACID (PFBS)PERFLUOROBUTANESULFONIC ACID (PFBS)PERFLUOROBUTANOIC ACID (PFBA)PERFLUORODECANE SULFONIC ACID 8:2 FTSPERFLUORODECANOIC ACID (PFDA)PERFLUORODODECANOIC ACID (PFDA)PERFLUORODODECANOIC ACID (PFDOA)PERFLUOROHEPTANESULFONIC ACID (PFHPS)PERFLUOROHEPTANOIC ACID (PFHPA)PERFLUOROHEXANE SULFONIC ACID (PFHXS)PERFLUOROHEXANE SULFONIC ACID 4:2 FTSPERFLUOROHEXANOIC ACID (PFHXA)PERFLUORONONANOIC ACID (PFNA)PERFLUOROOCTANE SULFONIC ACID 6:2 FTSPERFLUOROCTANE SULFONIC ACID (PFOS)PERFLUOROCTANOIC ACID (PFOA)PERFLUOROPENTANESULFONIC ACID (PFPES)PERFLUOROPENTANOIC ACID (PFPEA)PERFLUOROTETRADECANOIC ACID (PFTA)PERFLUOROTRIDECANOIC ACID (PFTRDA)PERFLUOROUNDECANOIC ACID (PFUNA)PERFLOUROHEXANE SULFONIC ACID (PFHXS)TOTAL PFOA AND PFOS `"microbial"`Microbial3 analytes Bacteria, fecal indicators, and biological markers of contamination. COLIFORM (TCR)E. COLIFECAL COLIFORM `"dbps"`Disinfection By-Products14 analytes Compounds formed when chlorine and other disinfectants react with organic matter in source water. BROMODICHLOROMETHANEBROMOFORMCHLORATECHLORITECHLOROFORMDIBROMOACETIC ACIDDIBROMOCHLOROMETHANEDICHLOROACETIC ACIDMONOBROMOACETIC ACIDMONOCHLOROACETIC ACIDTOTAL HALOACETIC ACIDS (HAA5)TOTAL TRIHALOMETHANES (TTHM)TRICHLOROACETIC ACIDTTHM `"radiological"`Radiological13 analytes Radioactive elements occurring naturally in groundwater or from industrial sources. COMBINED RADIUM (-226 & -228)COMBINED URANIUMGROSS ALPHA PARTICLE ACTIVITYGROSS ALPHA, EXCL. RADON & UGROSS ALPHA, EXCLUDING RN & UGROSS ALPHA, INCL. RADON & UGROSS ALPHA, INCLUDING RN & UGROSS BETA PARTICLE ACTIVITYRADIUM, COMBINED (226, 228)RADIUM-226RADIUM-228URANIUMURANIUM-238 `"pesticides_herbicides"`Pesticides & Herbicides44 analytes Agricultural chemicals including organochlorine pesticides, carbamates, triazines, and herbicides. 2,4,5-T2,4,5-TP2,4,5-TP (SILVEX)2,4-D3-HYDROXYCARBOFURANACETOCHLORACIFLUORFENALACHLOR (LASSO)ALDICARBALDICARB SULFONEALDICARB SULFOXIDEALDRINATRAZINEBENTAZONBROMACILBUTACHLORCARBARYLCARBOFURANCHLORDANECYANAZINEDALAPONDICAMBADIELDRINDINOSEBDIQUATENDOTHALLENDRINGLYPHOSATEHEPTACHLORHEPTACHLOR EPOXIDEHEXACHLOROBENZENEMETHIOCARBMETHOMYLMETHOXYCHLORMETOLACHLORMETRIBUZINMOLINATEOXAMYLPICLORAMPROPACHLORSIMAZINETHIOBENCARB (BOLERO)TOXAPHENETRIFLURALIN `"vocs"`Volatile Organic Compounds25 analytes Industrial solvents, fuel additives, and dry-cleaning chemicals that evaporate readily at room temperature. 1,1,1-TRICHLOROETHANE1,1,2-TRICHLOROETHANE1,1-DICHLOROETHYLENE1,2-DIBROMO-3-CHLOROPROPANE1,2-DICHLOROETHANE1,2-DICHLOROPROPANEBENZENECARBON TETRACHLORIDECHLOROBENZENECIS-1,2-DICHLOROETHYLENECIS-1,3-DICHLOROPROPENEDICHLOROMETHANEETHYLBENZENEETHYLENE DIBROMIDEMETHYL ETHYL KETONEMETHYL TERT-BUTYL ETHERO-XYLENETETRACHLOROETHYLENETOLUENETRANS-1,2-DICHLOROETHYLENETRICHLOROETHYLENEVINYL CHLORIDEXYLENE, META AND PARAXYLENESXYLENES, TOTAL `"metals_inorganic"`Metals & Inorganic34 analytes Heavy metals, metalloids, and inorganic elements regulated under the Safe Drinking Water Act. ALUMINUMANTIMONYANTIMONY, TOTALARSENICBARIUMBERYLLIUMBERYLLIUM, TOTALBORON, TOTALCADMIUMCALCIUMCHROMIUMCHROMIUM, HEXCOBALT, TOTALCOPPERCOPPER, FREEIRONLEADLITHIUM, TOTALMAGNESIUMMANGANESEMERCURYMOLYBDENUM, TOTALNICKELPOTASSIUMSELENIUMSILVERSODIUMSTRONTIUMTHALLIUMTHALLIUM, TOTALTIN, TOTALTITANIUM, TOTALVANADIUM, TOTALZINC `"physical"`Physical / Aesthetic23 analytes Physical and chemical parameters measuring water quality characteristics — pH, turbidity, conductivity, and more. AGGRESSIVE INDEXALKALINITY, BICARBONATEALKALINITY, CARBONATEALKALINITY, TOTALCOLORCONDUCTIVITYCONDUCTIVITY @ 25 C UMHOS/CMCORROSIVITYFLOW (PUMPING) RATE (GAL/M)HARDNESS, TOTAL (AS CACO3)LANGELIER INDEX (PH(S))LANGELIER INDEX AT SOURCE TEMP.ODOROXIDATION-REDUCTION POTENTIAL (EH)PHPH FIELDPH, FIELDRUN TIME PRIOR TO SAMPLING (MIN)SOLIDS,TOTAL DISSOLVED (TDS)TDSTEMPERATURE (CENTIGRADE)TURBIDITYTURBIDITY, FIELD `"nutrients"`Nutrients11 analytes Nitrogen and phosphorus compounds, primarily from agricultural runoff and wastewater. NITRATENITRATE (AS N)NITRATE+NITRITE (AS N)NITRATE-NITRITENITRITENITRITE (AS N)NITROGEN-AMMONIA (AS N)NITROGEN-AMMONIA AS (N)ORTHOPHOSPHATEPHOSPHORUS, TOTALTOTAL KJELDAHL NITROGEN (IN WATER MG/L) `"pcbs_dioxins"`PCBs & Dioxins9 analytes Polychlorinated biphenyls (Aroclors) and chlorinated dioxins — persistent industrial pollutants. 2,3,7,8-TCDDAROCLOR 1016AROCLOR 1221AROCLOR 1232AROCLOR 1242AROCLOR 1248AROCLOR 1254AROCLOR 1260TOTAL POLYCHLORINATED BIPHENYLS (PCB) `"other_organics"`Other Organics12 analytes Regulated organic compounds including halides, PAHs, cyanide, and secondary contaminants. 1,4-DIOXANEASBESTOSBENZO (A) PYRENEBENZO(A)PYRENECHLORIDECYANIDEFLUORIDEFOAMING AGENTS (SURFACTANTS)NAPHTHALENEPERCHLORATEPHENOLSSULFATE `"other_chemicals"`Other Chemicals62 analytes Additional monitored compounds not classified in the primary groups above. 1,1,1,2-TETRACHLOROETHANE1,1,2,2-TETRACHLOROETHANE1,1-DICHLOROETHANE1,1-DICHLOROPROPENE1,2,3-TRICHLOROBENZENE1,2,3-TRICHLOROPROPANE1,2,4-TRICHLOROBENZENE1,2,4-TRIMETHYLBENZENE1,3,5-TRIMETHYLBENZENE1,3-DICHLOROPROPANE1,3-DICHLOROPROPENE1-NAPHTHOL2,2-DICHLOROPROPANE4-METHYL-2-PENTANONEBAYGONBROMIDEBROMOBENZENEBROMOCHLOROMETHANEBROMOMETHANEBUTYLBENZYL PHTHALATECARBON DISULFIDECARBON, TOTALCARBON, TOTAL ORGANIC (TOC)CHLOROETHANECHLOROMETHANEDI(2-ETHYLHEXYL) ADIPATEDI(2-ETHYLHEXYL) PHTHALATEDIBROMOMETHANEDICHLORODIFLUOROMETHANEDIMETHOATEETHYL-TERT-BUTYL ETHERHEXACHLOROBUTADIENEHEXACHLOROCYCLOPENTADIENEHYDROXIDE AS CALCIUM CARBONATEISOPROPYL ETHERISOPROPYLBENZENELASSOM-DICHLOROBENZENEMDLMONOCHLOROBENZENEMRLN-BUTYLBENZENEN-PROPYLBENZENENANO-CHLOROTOLUENEO-DICHLOROBENZENEP-CHLOROTOLUENEP-DICHLOROBENZENEP-ISOPROPYLTOLUENEPENTACHLOROPHENOLRESIDUAL CHLORINESEC-BUTYLBENZENESILICASPECTRACIDESTYRENETERT-AMYL-METHYL ETHERTERT-BUTYLBENZENETERTIARY BUTYL ALCOHOL (TBA)TOTAL DDTTRANS-1,3-DICHLOROPROPENETRICHLOROFLUOROMETHANETRICHLOROTRIFLUOROETHANE `"unknown"`Unknown0 analytes Analytes that could not be matched to a known regulatory group. No analytes matched --- # Pagination > SDK Reference · Muriel developer docs · https://docs.murielai.io/#pagination The API returns up to 1,000 records per request. The SDKs page for you: pass `limit=None` (`limit: null` in JavaScript) for every record, or any limit over 1,000. One system pages through its whole record; a state, analyte group or address query stops at an offset of 10,000, so narrow those by date or analyte. You can still page by hand with `limit` and `offset`. ### Max per request **1,000 records** ### Default limit **10 (Python) · 100 (JS)** ### End detection Empty array or fewer than `limit` records ### Basic pagination Use `offset` to skip records. The default offset is `0`. ```python from muriel import get_results # Page 1 page1 = get_results(state="IL", analyte="Lead", limit=100, offset=0) # Page 2 page2 = get_results(state="IL", analyte="Lead", limit=100, offset=100) # Page 3 page3 = get_results(state="IL", analyte="Lead", limit=100, offset=200) ``` ### Pagination loop pattern Use a loop to collect all records across multiple pages. Stop when a page returns fewer records than `limit`, or when the page is empty. ```python from muriel import get_results def fetch_all(state: str, analyte: str, api_key: str): """Fetch all records using pagination.""" records = [] offset = 0 limit = 100 while True: page = get_results( state=state, analyte=analyte, limit=limit, offset=offset, api_key=api_key ) if not page: break records.extend(page) offset += limit # Stop if we got fewer records than the limit if len(page) < limit: break return records all_lead_il = fetch_all("IL", "Lead", api_key="YOUR_KEY") print(f"Total records: {len(all_lead_il)}") ``` ### Generator pattern For memory-efficient processing of large datasets, use a generator that yields records one at a time without loading everything into memory. ```python from muriel import get_results from typing import Generator, Dict, Any def paginate( api_key: str, limit: int = 100, **kwargs ) -> Generator[Dict[str, Any], None, None]: """Yield records one page at a time.""" offset = 0 while True: page = get_results( **kwargs, limit=limit, offset=offset, api_key=api_key ) if not page: break yield from page offset += limit if len(page) < limit: break # Usage for record in paginate(api_key="YOUR_KEY", state="CA", analyte_group="pfas"): process(record) ``` **Rate limits apply per request** Rapid pagination can trigger rate limiting. Add a small delay between requests when fetching large datasets. See your plan documentation for rate limit details.