pyrtan / sourcev0.19.0 · main

Add seventeen more trees that run in a browser

Claude · 2026-10-28 · f19cc5967e97da2eb6213a9e62fa46e16e71b700 · parents 2df3d68

Every example now runs in a page but the six that reach a box or any site at all (disk-space, log-errors, modem-check, room-temperature, rendered-headline, site-status). Each new tree calls one API that lets other origins read it, needs no key, and decides on the status code before the body:

forecast tomorrow's weather: sky, low and high, chance of rain uv-index UV now and today's highest, with the WHO band local-time the time in a place (Open-Meteo's geocoder gives the zone, timeapi.io the clock) elevation how high a place is (the geocoder carries it) postal-code a postcode's place (Zippopotam.us; a bare ZIP is US) wayback the Wayback Machine's latest copy of a site rhymes rhymes from Datamuse, joined into a sentence github-repo a repository's description, stars, forks, open issues go-module the latest version of a Go module (proxy.golang.org) homebrew a Homebrew formula's stable version pokemon a Pokémon's number and types (types joined) my-ip the public address requests come from (ipify) advice a piece of advice (Advice Slip) cat-fact a fact about cats (catfact.ninja) next-launch the next rocket launch, flown ones left out (Launch Library) space-news the three latest spaceflight articles apod NASA's Astronomy Picture of the Day (DEMO_KEY)

Every branch is recorded in tests/fixtures/examples.json and tested. GitHub's and NASA's 200s are written by hand from their documented shapes (this sandbox's proxy blocks api.github.com, and NASA's demo key was spent from its address); the Open-Meteo forecast and UV answers likewise, as Open-Meteo was rate-limiting the sandbox. Conformance covers every new sentence: 264/264.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jeoK8iFSW7tkCrzXLZ

Changes

 README.md                             |   14 +-
 examples/advice/advice.toml           |   48 +
 examples/apod/apod.toml               |   53 +
 examples/cat-fact/cat-fact.toml       |   48 +
 examples/elevation/elevation.toml     |   67 +
 examples/forecast/forecast.toml       |  128 +
 examples/github-repo/github-repo.toml |   69 +
 examples/go-module/go-module.toml     |   54 +
 examples/homebrew/homebrew.toml       |   57 +
 examples/local-time/local-time.toml   |   78 +
 examples/my-ip/my-ip.toml             |   49 +
 examples/next-launch/next-launch.toml |   66 +
 examples/pokemon/pokemon.toml         |   60 +
 examples/postal-code/postal-code.toml |   65 +
 examples/rhymes/rhymes.toml           |   60 +
 examples/space-news/space-news.toml   |   71 +
 examples/uv-index/uv-index.toml       |  103 +
 examples/wayback/wayback.toml         |   63 +
 src/pyrtan/page.py                    |   11 +
 tests/conformance/make_vectors.py     |   27 +
 tests/conformance/vectors.json        | 7015 ++++++++++++++++++++++++++++++++++++++++++++---------
 tests/fixtures/examples.json          |  344 +++
 tests/test_catalog.py                 |    3 +-
 tests/test_examples.py                |   76 +
 24 files changed, 7538 insertions(+), 1091 deletions(-)
diff --git a/README.md b/README.md
index 750f4aa..316cd97 100644
--- a/README.md
+++ b/README.md
@@ -201,11 +201,15 @@ its `link`; and the gaps. The same console lives at
join one, the browser is the hub. It holds the catalog's trees, reads the sentence and runs the tree in the page (`pyrtan.js`, the same runtime and the same reading as Python's), and keeps gaps and unanswered sentences
-while the page is open. Nineteen of the examples run there: weather,
-sunrise and sunset, air quality, Wikipedia, a dictionary, synonyms,
-exchange rates, Hacker News (top stories, an item, a search), books,
-npm and PyPI versions, GitHub releases, earthquakes, where the ISS is,
-public holidays, a dad joke, and small talk. Trees that need a box (files, programs, serial
+while the page is open. Every example runs there but six: weather
+today and tomorrow, sunrise, UV and air quality, local time, elevation,
+postcodes, Wikipedia, words (definitions, synonyms, rhymes), exchange
+rates, Hacker News, GitHub, packages (npm, PyPI, Go, Homebrew), books,
+earthquakes, the ISS, launches, space news, NASA's picture of the day,
+holidays, the Wayback Machine, Pokémon, your IP, jokes, advice and cat
+facts. The six that need a hub reach a box or any site at all:
+`disk-space`, `log-errors`, `modem-check`, `room-temperature`,
+`rendered-headline` and `site-status`. Trees that need a box (files, programs, serial
lines, brokers, a browser to drive) or a site that does not let a page read it say so; for those, join a hub started with `--cors https://pyrtan.com` by giving its URL and token. Forget it and the diff --git a/examples/advice/advice.toml b/examples/advice/advice.toml new file mode 100644 index 0000000..fbb4118 --- /dev/null +++ b/examples/advice/advice.toml
@@ -0,0 +1,48 @@
+# A piece of advice, from the Advice Slip API.
+
+name = "advice"
+version = 1
+namespace = "pyrtan"
+description = "A random piece of advice, from the Advice Slip API."
+tags = ["fun", "read-only"]
+license = "MIT"
+hosts = ["api.adviceslip.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "give me [some] advice"
+[[triggers]]
+pattern = "[any] advice"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.adviceslip.com/advice" }
+into = "resp"
+extract = [{ into = "advice", json = "json.slip.advice" }, { into = "id", json = "json.slip.id" }]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "has-advice" },
+ { else = "unexpected" },
+]
+
+[nodes.has-advice]
+kind = "decide"
+on = "advice"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.advice }}"
+output = { advice = "{{ state.advice }}", id = "{{ state.id }}" }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The Advice Slip API answered {{ state.resp.status }}."
diff --git a/examples/apod/apod.toml b/examples/apod/apod.toml new file mode 100644 index 0000000..b2a82e6 --- /dev/null +++ b/examples/apod/apod.toml
@@ -0,0 +1,53 @@
+# NASA's Astronomy Picture of the Day. The public DEMO_KEY allows thirty
+# requests an hour from one address, which is plenty for one person.
+
+name = "apod"
+version = 1
+namespace = "pyrtan"
+description = "NASA's Astronomy Picture of the Day: its title, and the picture or video."
+tags = ["space", "science", "read-only"]
+license = "MIT"
+hosts = ["api.nasa.gov"]
+start = "fetch"
+
+[[triggers]]
+pattern = "astronomy picture of the day"
+[[triggers]]
+pattern = "[nasa] (apod|space picture) [of the day|today]"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY" }
+into = "resp"
+extract = [
+ { into = "title", json = "json.title" },
+ { into = "day", json = "json.date" },
+ { into = "media", json = "json.url" },
+ { into = "kind", json = "json.media_type" },
+ { into = "about", json = "json.explanation" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 429 }, then = "rate-limited" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "Astronomy Picture of the Day, {{ state.day }}: {{ state.title }}. {{ state.media }}"
+output = { date = "{{ state.day }}", title = "{{ state.title }}", media_type = "{{ state.kind }}", url = "{{ state.media }}", explanation = "{{ state.about }}", page = "https://apod.nasa.gov/apod/astropix.html" }
+
+[nodes.rate-limited]
+kind = "fail"
+reason = "rate-limited"
+say = "NASA's demo key has used up its hour from this address; try again later, or see https://apod.nasa.gov/apod/astropix.html"
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "NASA's API answered {{ state.resp.status }}."
diff --git a/examples/cat-fact/cat-fact.toml b/examples/cat-fact/cat-fact.toml new file mode 100644 index 0000000..3a08235 --- /dev/null +++ b/examples/cat-fact/cat-fact.toml
@@ -0,0 +1,48 @@
+# A fact about cats, from catfact.ninja.
+
+name = "cat-fact"
+version = 1
+namespace = "pyrtan"
+description = "A random fact about cats, from catfact.ninja."
+tags = ["fun", "animals", "read-only"]
+license = "MIT"
+hosts = ["catfact.ninja"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[tell me a] cat fact"
+[[triggers]]
+pattern = "tell me something about cats"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://catfact.ninja/fact" }
+into = "resp"
+extract = [{ into = "fact", json = "json.fact" }]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "has-fact" },
+ { else = "unexpected" },
+]
+
+[nodes.has-fact]
+kind = "decide"
+on = "fact"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.fact }}"
+output = { fact = "{{ state.fact }}" }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "catfact.ninja answered {{ state.resp.status }}."
diff --git a/examples/elevation/elevation.toml b/examples/elevation/elevation.toml new file mode 100644 index 0000000..58d177e --- /dev/null +++ b/examples/elevation/elevation.toml
@@ -0,0 +1,67 @@
+# How high a place is above the sea, from Open-Meteo's geocoder, which
+# carries each place's elevation with its name.
+
+name = "elevation"
+version = 1
+namespace = "pyrtan"
+description = "How high a place is above sea level, from Open-Meteo's geocoder."
+tags = ["geography", "read-only"]
+license = "MIT"
+hosts = ["geocoding-api.open-meteo.com"]
+start = "geocode"
+
+[[triggers]]
+pattern = "how high (is|above sea level is) {place}"
+[[triggers]]
+pattern = "[the] (elevation|altitude) of {place}"
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }}, {{ state.country }} is {{ state.height | int }} m above sea level."
+output = { place = "{{ state.name }}", country = "{{ state.country }}", elevation_m = "{{ state.height }}" }
+
+[nodes.geocode]
+kind = "observe"
+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }
+into = "geo"
+extract = [
+ { into = "hit", json = "json.results[0]" },
+ { into = "lat", json = "json.results[0].latitude" },
+ { into = "lon", json = "json.results[0].longitude" },
+ { into = "name", json = "json.results[0].name" },
+ { into = "country", json = "json.results[0].country" },
+ { into = "zone", json = "json.results[0].timezone" },
+ { into = "height", json = "json.results[0].elevation" },
+]
+next = "found"
+
+[nodes.found]
+kind = "decide"
+on = "hit"
+branches = [
+ { when = { exists = true }, then = "has-height" },
+ { else = "unknown-place" },
+]
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place"
+say = "I could not find a place called {{ inputs.place }}."
+
+[inputs.place]
+type = "str"
+description = "A town or city name"
+
+
+[nodes.has-height]
+kind = "decide"
+on = "height"
+branches = [
+ { when = { type = "number" }, then = "reply" },
+ { else = "no-height" },
+]
+
+[nodes.no-height]
+kind = "fail"
+reason = "no-height"
+say = "Open-Meteo knows {{ state.name }} but not how high it is."
diff --git a/examples/forecast/forecast.toml b/examples/forecast/forecast.toml new file mode 100644 index 0000000..9ab4204 --- /dev/null +++ b/examples/forecast/forecast.toml
@@ -0,0 +1,128 @@
+# Tomorrow's weather for a place, from Open-Meteo: the sky, the high and
+# low, and the chance of rain, in the place's own day.
+
+name = "forecast"
+version = 1
+namespace = "pyrtan"
+description = "Tomorrow's weather for a place: sky, high and low, and chance of rain, from Open-Meteo."
+tags = ["weather", "read-only"]
+license = "MIT"
+hosts = ["geocoding-api.open-meteo.com", "api.open-meteo.com"]
+start = "geocode"
+
+[[triggers]]
+pattern = "[what's the] weather (in|for) {place} tomorrow"
+[[triggers]]
+pattern = "[what's the] weather tomorrow (in|for) {place}"
+[[triggers]]
+pattern = "will it rain tomorrow in {place}"
+
+[nodes.tomorrow]
+kind = "observe"
+http = { method = "GET", url = "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=2" }
+into = "fc"
+extract = [
+ { into = "day", json = "json.daily.time[1]" },
+ { into = "code", json = "json.daily.weather_code[1]" },
+ { into = "high", json = "json.daily.temperature_2m_max[1]" },
+ { into = "low", json = "json.daily.temperature_2m_min[1]" },
+ { into = "rain", json = "json.daily.precipitation_probability_max[1]" },
+ { into = "unit", json = "json.daily_units.temperature_2m_max" },
+]
+next = "have-day"
+
+[nodes.have-day]
+kind = "decide"
+on = "high"
+branches = [
+ { when = { type = "number" }, then = "sky" },
+ { else = "no-forecast" },
+]
+
+# WMO weather codes, folded into words, as the weather tree does.
+[nodes.sky]
+kind = "decide"
+on = "code"
+branches = [
+ { when = { eq = 0 }, then = "clear" },
+ { when = { between = [1, 3] }, then = "cloudy" },
+ { when = { between = [45, 48] }, then = "fog" },
+ { when = { between = [51, 67] }, then = "rain" },
+ { when = { between = [71, 77] }, then = "snow" },
+ { when = { between = [80, 82] }, then = "rain" },
+ { when = { between = [85, 86] }, then = "snow" },
+ { when = { between = [95, 99] }, then = "storm" },
+ { else = "unknown-sky" },
+]
+
+[nodes.clear]
+kind = "set"
+set = { sky = "clear" }
+next = "reply"
+[nodes.cloudy]
+kind = "set"
+set = { sky = "cloudy" }
+next = "reply"
+[nodes.fog]
+kind = "set"
+set = { sky = "foggy" }
+next = "reply"
+[nodes.rain]
+kind = "set"
+set = { sky = "rain" }
+next = "reply"
+[nodes.snow]
+kind = "set"
+set = { sky = "snow" }
+next = "reply"
+[nodes.storm]
+kind = "set"
+set = { sky = "storms" }
+next = "reply"
+[nodes.unknown-sky]
+kind = "set"
+set = { sky = "weather code {{ state.code }}" }
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }}, {{ state.country }}, tomorrow ({{ state.day }}): {{ state.sky }}, {{ state.low }} to {{ state.high }}{{ state.unit }}, {{ state.rain }}% chance of rain."
+output = { place = "{{ state.name }}", country = "{{ state.country }}", date = "{{ state.day }}", sky = "{{ state.sky }}", low = "{{ state.low }}", high = "{{ state.high }}", unit = "{{ state.unit }}", rain_chance = "{{ state.rain }}" }
+
+[nodes.no-forecast]
+kind = "fail"
+reason = "no-forecast"
+say = "Open-Meteo found {{ state.name }} but gave no forecast for tomorrow (status {{ state.fc.status }})."
+
+[nodes.geocode]
+kind = "observe"
+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }
+into = "geo"
+extract = [
+ { into = "hit", json = "json.results[0]" },
+ { into = "lat", json = "json.results[0].latitude" },
+ { into = "lon", json = "json.results[0].longitude" },
+ { into = "name", json = "json.results[0].name" },
+ { into = "country", json = "json.results[0].country" },
+ { into = "zone", json = "json.results[0].timezone" },
+ { into = "height", json = "json.results[0].elevation" },
+]
+next = "found"
+
+[nodes.found]
+kind = "decide"
+on = "hit"
+branches = [
+ { when = { exists = true }, then = "tomorrow" },
+ { else = "unknown-place" },
+]
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place"
+say = "I could not find a place called {{ inputs.place }}."
+
+[inputs.place]
+type = "str"
+description = "A town or city name"
+
diff --git a/examples/github-repo/github-repo.toml b/examples/github-repo/github-repo.toml new file mode 100644 index 0000000..cf1484c --- /dev/null +++ b/examples/github-repo/github-repo.toml
@@ -0,0 +1,69 @@
+# A GitHub repository at a glance: what it is, its stars, forks and open
+# issues, from GitHub's REST API (no token: sixty requests an hour).
+
+name = "github-repo"
+version = 1
+namespace = "pyrtan"
+description = "A GitHub repository at a glance: description, language, stars, forks, open issues."
+tags = ["github", "software", "read-only"]
+license = "MIT"
+hosts = ["api.github.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "how many stars does {repo:word} have"
+[[triggers]]
+pattern = "github (repo|repository) {repo:word}"
+[[triggers]]
+pattern = "tell me about the {repo:word} repo"
+
+[inputs.repo]
+type = "str"
+pattern = "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$"
+description = "owner/name, as in: astral-sh/uv"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.github.com/repos/{{ inputs.repo }}", headers = { Accept = "application/vnd.github+json" } }
+into = "resp"
+extract = [
+ { into = "full", json = "json.full_name" },
+ { into = "about", json = "json.description" },
+ { into = "language", json = "json.language" },
+ { into = "stars", json = "json.stargazers_count" },
+ { into = "forks", json = "json.forks_count" },
+ { into = "issues", json = "json.open_issues_count" },
+ { into = "url", json = "json.html_url" },
+ { into = "archived", json = "json.archived" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 404 }, then = "no-repo" },
+ { when = { in = [403, 429] }, then = "rate-limited" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.full }}: {{ state.about }} ({{ state.language }}) · {{ state.stars }} stars, {{ state.forks }} forks, {{ state.issues }} open issues. {{ state.url }}"
+output = { repo = "{{ state.full }}", description = "{{ state.about }}", language = "{{ state.language }}", stars = "{{ state.stars }}", forks = "{{ state.forks }}", open_issues = "{{ state.issues }}", archived = "{{ state.archived }}", url = "{{ state.url }}" }
+
+[nodes.no-repo]
+kind = "fail"
+reason = "no-repo"
+say = "GitHub has no public repository {{ inputs.repo }}."
+
+[nodes.rate-limited]
+kind = "fail"
+reason = "rate-limited"
+say = "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "GitHub answered {{ state.resp.status }}."
diff --git a/examples/go-module/go-module.toml b/examples/go-module/go-module.toml new file mode 100644 index 0000000..3c79b65 --- /dev/null +++ b/examples/go-module/go-module.toml
@@ -0,0 +1,54 @@
+# The latest version of a Go module, from the Go module proxy.
+
+name = "go-module"
+version = 1
+namespace = "pyrtan"
+description = "The latest version of a Go module, and when it was published, from proxy.golang.org."
+tags = ["software", "go", "read-only"]
+license = "MIT"
+hosts = ["proxy.golang.org"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[what's the] latest version of {module:word} on go"
+[[triggers]]
+pattern = "go (module|version) {module:word}"
+
+[inputs.module]
+type = "str"
+pattern = "^[a-z0-9.\\-]+(/[A-Za-z0-9._~\\-]+)+$"
+description = "A module path, as in: github.com/gorilla/mux"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://proxy.golang.org/{{ inputs.module | lower }}/@latest" }
+into = "resp"
+extract = [
+ { into = "version", json = "json.Version" },
+ { into = "day", regex = "^(\\d{4}-\\d\\d-\\d\\d)", from = "json.Time" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { in = [404, 410] }, then = "no-module" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ inputs.module }} {{ state.version }}, published {{ state.day }}. https://pkg.go.dev/{{ inputs.module }}"
+output = { module = "{{ inputs.module }}", version = "{{ state.version }}", published = "{{ state.day }}", url = "https://pkg.go.dev/{{ inputs.module }}" }
+
+[nodes.no-module]
+kind = "fail"
+reason = "no-module"
+say = "The Go module proxy has no module {{ inputs.module }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The Go module proxy answered {{ state.resp.status }}."
diff --git a/examples/homebrew/homebrew.toml b/examples/homebrew/homebrew.toml new file mode 100644 index 0000000..93326f2 --- /dev/null +++ b/examples/homebrew/homebrew.toml
@@ -0,0 +1,57 @@
+# A Homebrew formula: what it is and its current stable version, from
+# formulae.brew.sh.
+
+name = "homebrew"
+version = 1
+namespace = "pyrtan"
+description = "A Homebrew formula's current stable version and what it is, from formulae.brew.sh."
+tags = ["software", "macos", "read-only"]
+license = "MIT"
+hosts = ["formulae.brew.sh"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[what's the] latest version of {formula:word} on homebrew"
+[[triggers]]
+pattern = "(brew|homebrew) (formula|version) {formula:word}"
+
+[inputs.formula]
+type = "str"
+pattern = "^[a-z0-9][a-z0-9@+._\\-]*$"
+description = "A formula name, as in: wget"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://formulae.brew.sh/api/formula/{{ inputs.formula | lower | url }}.json" }
+into = "resp"
+extract = [
+ { into = "name", json = "json.name" },
+ { into = "version", json = "json.versions.stable" },
+ { into = "about", json = "json.desc" },
+ { into = "home", json = "json.homepage" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 404 }, then = "no-formula" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }} {{ state.version }} on Homebrew: {{ state.about }}. {{ state.home }}"
+output = { formula = "{{ state.name }}", version = "{{ state.version }}", description = "{{ state.about }}", homepage = "{{ state.home }}", install = "brew install {{ state.name }}" }
+
+[nodes.no-formula]
+kind = "fail"
+reason = "no-formula"
+say = "Homebrew has no formula called {{ inputs.formula }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "formulae.brew.sh answered {{ state.resp.status }}."
diff --git a/examples/local-time/local-time.toml b/examples/local-time/local-time.toml new file mode 100644 index 0000000..4d24cb5 --- /dev/null +++ b/examples/local-time/local-time.toml
@@ -0,0 +1,78 @@
+# The time now in a place: its time zone from Open-Meteo's geocoder, the
+# clock in that zone from timeapi.io.
+
+name = "local-time"
+version = 1
+namespace = "pyrtan"
+description = "What time it is now in a place, and its time zone."
+tags = ["time", "read-only"]
+license = "MIT"
+hosts = ["geocoding-api.open-meteo.com", "timeapi.io"]
+start = "geocode"
+
+[[triggers]]
+pattern = "what time is it in {place}"
+[[triggers]]
+pattern = "[the] (time|local time) in {place}"
+
+[nodes.clock]
+kind = "observe"
+http = { method = "GET", url = "https://timeapi.io/api/time/current/zone?timeZone={{ state.zone | url }}" }
+into = "resp"
+extract = [
+ { into = "time", json = "json.time" },
+ { into = "weekday", json = "json.dayOfWeek" },
+ { into = "date", regex = "^(\\d{4}-\\d\\d-\\d\\d)", from = "json.dateTime" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { else = "no-clock" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "It is {{ state.time }} on {{ state.weekday }} {{ state.date }} in {{ state.name }}, {{ state.country }} ({{ state.zone }})."
+output = { place = "{{ state.name }}", country = "{{ state.country }}", time = "{{ state.time }}", date = "{{ state.date }}", weekday = "{{ state.weekday }}", timezone = "{{ state.zone }}" }
+
+[nodes.no-clock]
+kind = "fail"
+reason = "no-clock"
+say = "timeapi.io gave no time for {{ state.zone }} (status {{ state.resp.status }})."
+
+[nodes.geocode]
+kind = "observe"
+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }
+into = "geo"
+extract = [
+ { into = "hit", json = "json.results[0]" },
+ { into = "lat", json = "json.results[0].latitude" },
+ { into = "lon", json = "json.results[0].longitude" },
+ { into = "name", json = "json.results[0].name" },
+ { into = "country", json = "json.results[0].country" },
+ { into = "zone", json = "json.results[0].timezone" },
+ { into = "height", json = "json.results[0].elevation" },
+]
+next = "found"
+
+[nodes.found]
+kind = "decide"
+on = "hit"
+branches = [
+ { when = { exists = true }, then = "clock" },
+ { else = "unknown-place" },
+]
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place"
+say = "I could not find a place called {{ inputs.place }}."
+
+[inputs.place]
+type = "str"
+description = "A town or city name"
+
diff --git a/examples/my-ip/my-ip.toml b/examples/my-ip/my-ip.toml new file mode 100644 index 0000000..80e714c --- /dev/null +++ b/examples/my-ip/my-ip.toml
@@ -0,0 +1,49 @@
+# The public IP address this tree's requests come from, from ipify. In a
+# browser that is the visitor's; on a service, the service's.
+
+name = "my-ip"
+version = 1
+namespace = "pyrtan"
+description = "The public IP address requests from here come from, from ipify."
+tags = ["network", "read-only"]
+license = "MIT"
+hosts = ["api.ipify.org"]
+start = "fetch"
+
+[[triggers]]
+pattern = "what is my [public] ip [address]"
+[[triggers]]
+pattern = "my [public] ip [address]"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.ipify.org?format=json" }
+into = "resp"
+extract = [{ into = "ip", json = "json.ip" }]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "has-ip" },
+ { else = "unexpected" },
+]
+
+[nodes.has-ip]
+kind = "decide"
+on = "ip"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "Your public IP address is {{ state.ip }}."
+output = { ip = "{{ state.ip }}" }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "ipify answered {{ state.resp.status }}."
diff --git a/examples/next-launch/next-launch.toml b/examples/next-launch/next-launch.toml new file mode 100644 index 0000000..5f124d0 --- /dev/null +++ b/examples/next-launch/next-launch.toml
@@ -0,0 +1,66 @@
+# The next rocket launch anywhere, from The Space Devs' Launch Library:
+# what flies, when (UTC), and whether it is go. Launches that already flew
+# are left out. Without a key the API allows fifteen requests an hour.
+
+name = "next-launch"
+version = 1
+namespace = "pyrtan"
+description = "The next rocket launch anywhere, when, and its status, from the Launch Library."
+tags = ["space", "read-only"]
+license = "MIT"
+hosts = ["ll.thespacedevs.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[when is the] next (launch|rocket launch)"
+[[triggers]]
+pattern = "upcoming (launches|rocket launches)"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=1&mode=list&hide_recent_previous=true" }
+into = "resp"
+extract = [
+ { into = "name", json = "json.results[0].name" },
+ { into = "net", json = "json.results[0].net" },
+ { into = "state", json = "json.results[0].status.name" },
+ { into = "count", json = "json.count" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { when = { eq = 429 }, then = "rate-limited" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "name"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "none" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "Next launch: {{ state.name }}, {{ state.net }} UTC ({{ state.state }}). {{ state.count }} launches are scheduled."
+output = { name = "{{ state.name }}", net_utc = "{{ state.net }}", status = "{{ state.state }}", upcoming = "{{ state.count }}" }
+
+[nodes.none]
+kind = "done"
+say = "The Launch Library lists no upcoming launches."
+output = { upcoming = 0 }
+
+[nodes.rate-limited]
+kind = "fail"
+reason = "rate-limited"
+say = "The Launch Library allows fifteen requests an hour without a key; try again later."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The Launch Library answered {{ state.resp.status }}."
diff --git a/examples/pokemon/pokemon.toml b/examples/pokemon/pokemon.toml new file mode 100644 index 0000000..528bf2a --- /dev/null +++ b/examples/pokemon/pokemon.toml
@@ -0,0 +1,60 @@
+# A Pokémon's number, types, height and weight, from PokéAPI. The API
+# gives height in decimetres and weight in hectograms.
+
+name = "pokemon"
+version = 1
+namespace = "pyrtan"
+description = "A Pokémon's number, types, height and weight, from PokéAPI."
+tags = ["fun", "games", "read-only"]
+license = "MIT"
+hosts = ["pokeapi.co"]
+start = "fetch"
+
+[[triggers]]
+pattern = "pokemon {name:word}"
+[[triggers]]
+pattern = "what type is {name:word}"
+[[triggers]]
+pattern = "tell me about the pokemon {name:word}"
+
+[inputs.name]
+type = "str"
+pattern = "^[A-Za-z0-9\\-]+$"
+description = "A Pokémon's name or number, as in: pikachu"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://pokeapi.co/api/v2/pokemon/{{ inputs.name | lower | url }}" }
+into = "resp"
+extract = [
+ { into = "id", json = "json.id" },
+ { into = "name", json = "json.name" },
+ { into = "types", regex = "\"type\":\\s*\\{\\s*\"name\":\\s*\"([a-z]+)\"", all = true },
+ { into = "dm", json = "json.height" },
+ { into = "hg", json = "json.weight" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 404 }, then = "no-pokemon" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "#{{ state.id }} {{ state.name | title }}, {{ state.types | join }} type."
+output = { number = "{{ state.id }}", name = "{{ state.name }}", types = "{{ state.types }}", height_dm = "{{ state.dm }}", weight_hg = "{{ state.hg }}" }
+
+[nodes.no-pokemon]
+kind = "fail"
+reason = "no-pokemon"
+say = "PokéAPI knows no Pokémon called {{ inputs.name }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "PokéAPI answered {{ state.resp.status }}."
diff --git a/examples/postal-code/postal-code.toml b/examples/postal-code/postal-code.toml new file mode 100644 index 0000000..3ae0cc5 --- /dev/null +++ b/examples/postal-code/postal-code.toml
@@ -0,0 +1,65 @@
+# Where a postal code is, from Zippopotam.us: the place, its state or
+# region, and where it lies. A US ZIP code needs no country.
+
+name = "postal-code"
+version = 1
+namespace = "pyrtan"
+description = "The place a postal code belongs to, from Zippopotam.us (US ZIP codes, or a code in another country)."
+tags = ["geography", "read-only"]
+license = "MIT"
+hosts = ["api.zippopotam.us"]
+start = "fetch"
+
+[[triggers]]
+pattern = "where is zip [code] {code:word}"
+inputs = { country = "us" }
+[[triggers]]
+pattern = "[where is] (postcode|postal code) {code:word} in {country:word}"
+
+[inputs.code]
+type = "str"
+pattern = "^[A-Za-z0-9-]{2,10}$"
+description = "A postal code, as in: 90210"
+
+[inputs.country]
+type = "str"
+default = "us"
+pattern = "^[A-Za-z]{2}$"
+description = "A two-letter country code, as in: US, DE, GB"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.zippopotam.us/{{ inputs.country | lower }}/{{ inputs.code | url }}" }
+into = "resp"
+extract = [
+ { into = "place", json = "json.places[0][\"place name\"]" },
+ { into = "region", json = "json.places[0].state" },
+ { into = "country_name", json = "json.country" },
+ { into = "lat", json = "json.places[0].latitude" },
+ { into = "lon", json = "json.places[0].longitude" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 404 }, then = "unknown-code" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ inputs.code | upper }} is {{ state.place }}, {{ state.region }}, {{ state.country_name }}."
+output = { code = "{{ inputs.code | upper }}", place = "{{ state.place }}", region = "{{ state.region }}", country = "{{ state.country_name }}", latitude = "{{ state.lat | float }}", longitude = "{{ state.lon | float }}" }
+
+[nodes.unknown-code]
+kind = "fail"
+reason = "unknown-code"
+say = "Zippopotam.us knows no postal code {{ inputs.code | upper }} in {{ inputs.country | upper }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "Zippopotam.us answered {{ state.resp.status }}."
diff --git a/examples/rhymes/rhymes.toml b/examples/rhymes/rhymes.toml new file mode 100644 index 0000000..41c7cbd --- /dev/null +++ b/examples/rhymes/rhymes.toml
@@ -0,0 +1,60 @@
+# Words that rhyme, from Datamuse, most common first.
+
+name = "rhymes"
+version = 1
+namespace = "pyrtan"
+description = "Words that rhyme with a word, from Datamuse."
+tags = ["language", "read-only"]
+license = "MIT"
+hosts = ["api.datamuse.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "what rhymes with {word:word}"
+[[triggers]]
+pattern = "(rhymes|words that rhyme) (for|with) {word:word}"
+
+[inputs.word]
+type = "str"
+pattern = "^[A-Za-z][A-Za-z'\\-]*$"
+description = "One English word"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.datamuse.com/words?rel_rhy={{ inputs.word | lower | url }}&max=10" }
+into = "resp"
+extract = [
+ { into = "words", regex = "\"word\":\\s*\"([^\"]+)\"", all = true },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "words"
+branches = [
+ { when = { empty = true }, then = "none" },
+ { else = "reply" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "Rhymes with {{ inputs.word }}: {{ state.words | join }}."
+output = { word = "{{ inputs.word }}", rhymes = "{{ state.words }}" }
+
+[nodes.none]
+kind = "fail"
+reason = "no-rhymes"
+say = "Datamuse knows nothing that rhymes with {{ inputs.word }}."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "Datamuse answered {{ state.resp.status }}."
diff --git a/examples/space-news/space-news.toml b/examples/space-news/space-news.toml new file mode 100644 index 0000000..398dddd --- /dev/null +++ b/examples/space-news/space-news.toml
@@ -0,0 +1,71 @@
+# The latest spaceflight news, from the Spaceflight News API: the newest
+# three articles across the sites it follows.
+
+name = "space-news"
+version = 1
+namespace = "pyrtan"
+description = "The three latest spaceflight news articles, from the Spaceflight News API."
+tags = ["space", "news", "read-only"]
+license = "MIT"
+hosts = ["api.spaceflightnewsapi.net"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[latest] (space|spaceflight) news"
+[[triggers]]
+pattern = "what's new in space"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.spaceflightnewsapi.net/v4/articles/?limit=3" }
+into = "resp"
+extract = [
+ { into = "articles", json = "json.results" },
+ { into = "title", json = "json.results[0].title" },
+ { into = "site", json = "json.results[0].news_site" },
+ { into = "url", json = "json.results[0].url" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "articles"
+branches = [
+ { when = { empty = true }, then = "none" },
+ { else = "each" },
+]
+
+[nodes.each]
+kind = "each"
+over = "articles"
+limit = 3
+start = "one"
+into = "stories"
+next = "reply"
+
+[nodes.one]
+kind = "done"
+output = { title = "{{ item.title }}", site = "{{ item.news_site }}", url = "{{ item.url }}", published = "{{ item.published_at }}" }
+
+[nodes.reply]
+kind = "done"
+say = "Latest in space: {{ state.title }} ({{ state.site }}) {{ state.url }}"
+output = { stories = "{{ state.stories }}" }
+
+[nodes.none]
+kind = "done"
+say = "The Spaceflight News API has no articles right now."
+output = { stories = [] }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The Spaceflight News API answered {{ state.resp.status }}."
diff --git a/examples/uv-index/uv-index.toml b/examples/uv-index/uv-index.toml new file mode 100644 index 0000000..3031775 --- /dev/null +++ b/examples/uv-index/uv-index.toml
@@ -0,0 +1,103 @@
+# The ultraviolet index for a place, now and at its highest today, from
+# Open-Meteo, with the WHO's band for it.
+
+name = "uv-index"
+version = 1
+namespace = "pyrtan"
+description = "The UV index for a place, now and today's highest, with its WHO band, from Open-Meteo."
+tags = ["weather", "health", "read-only"]
+license = "MIT"
+hosts = ["geocoding-api.open-meteo.com", "api.open-meteo.com"]
+start = "geocode"
+
+[[triggers]]
+pattern = "[what's the] (uv|uv index) in {place}"
+[[triggers]]
+pattern = "do i need sunscreen in {place}"
+
+[nodes.uv]
+kind = "observe"
+http = { method = "GET", url = "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}&current=uv_index&daily=uv_index_max&timezone=auto&forecast_days=1" }
+into = "fc"
+extract = [
+ { into = "now", json = "json.current.uv_index" },
+ { into = "max", json = "json.daily.uv_index_max[0]" },
+]
+next = "band"
+
+# The WHO's UV index bands, for today's highest.
+[nodes.band]
+kind = "decide"
+on = "max"
+branches = [
+ { when = { type = "number", lt = 3 }, then = "low" },
+ { when = { lt = 6 }, then = "moderate" },
+ { when = { lt = 8 }, then = "high" },
+ { when = { lt = 11 }, then = "very-high" },
+ { when = { gte = 11 }, then = "extreme" },
+ { else = "no-reading" },
+]
+
+[nodes.low]
+kind = "set"
+set = { band = "low", advice = "no protection needed" }
+next = "reply"
+[nodes.moderate]
+kind = "set"
+set = { band = "moderate", advice = "sunscreen and a hat around midday" }
+next = "reply"
+[nodes.high]
+kind = "set"
+set = { band = "high", advice = "sunscreen, a hat, and shade at midday" }
+next = "reply"
+[nodes.very-high]
+kind = "set"
+set = { band = "very high", advice = "avoid the midday sun" }
+next = "reply"
+[nodes.extreme]
+kind = "set"
+set = { band = "extreme", advice = "stay out of the midday sun" }
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }}, {{ state.country }}: UV index {{ state.now }} now, up to {{ state.max }} today ({{ state.band }}): {{ state.advice }}."
+output = { place = "{{ state.name }}", country = "{{ state.country }}", uv_now = "{{ state.now }}", uv_max = "{{ state.max }}", band = "{{ state.band }}" }
+
+[nodes.no-reading]
+kind = "fail"
+reason = "no-reading"
+say = "Open-Meteo found {{ state.name }} but gave no UV index (status {{ state.fc.status }})."
+
+[nodes.geocode]
+kind = "observe"
+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }
+into = "geo"
+extract = [
+ { into = "hit", json = "json.results[0]" },
+ { into = "lat", json = "json.results[0].latitude" },
+ { into = "lon", json = "json.results[0].longitude" },
+ { into = "name", json = "json.results[0].name" },
+ { into = "country", json = "json.results[0].country" },
+ { into = "zone", json = "json.results[0].timezone" },
+ { into = "height", json = "json.results[0].elevation" },
+]
+next = "found"
+
+[nodes.found]
+kind = "decide"
+on = "hit"
+branches = [
+ { when = { exists = true }, then = "uv" },
+ { else = "unknown-place" },
+]
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place"
+say = "I could not find a place called {{ inputs.place }}."
+
+[inputs.place]
+type = "str"
+description = "A town or city name"
+
diff --git a/examples/wayback/wayback.toml b/examples/wayback/wayback.toml new file mode 100644 index 0000000..6a71ebc --- /dev/null +++ b/examples/wayback/wayback.toml
@@ -0,0 +1,63 @@
+# Whether the Internet Archive's Wayback Machine has a copy of a site, and
+# a link to its most recent one.
+
+name = "wayback"
+version = 1
+namespace = "pyrtan"
+description = "The Wayback Machine's latest copy of a site, if it has one."
+tags = ["web", "read-only"]
+license = "MIT"
+hosts = ["archive.org"]
+start = "fetch"
+
+[[triggers]]
+pattern = "is {site:host} archived"
+[[triggers]]
+pattern = "wayback [machine] {site:host}"
+[[triggers]]
+pattern = "[the] archived copy of {site:host}"
+
+[inputs.site]
+type = "str"
+description = "A host name, as in: example.com"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://archive.org/wayback/available?url={{ inputs.site | url }}" }
+into = "resp"
+extract = [
+ { into = "copy", json = "json.archived_snapshots.closest.url" },
+ { into = "stamp", json = "json.archived_snapshots.closest.timestamp" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "copy"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "none" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "The Wayback Machine has {{ inputs.site }}. Its latest copy: {{ state.copy }}"
+output = { site = "{{ inputs.site }}", url = "{{ state.copy }}", timestamp = "{{ state.stamp }}" }
+
+[nodes.none]
+kind = "done"
+say = "The Wayback Machine has no copy of {{ inputs.site }}."
+output = { site = "{{ inputs.site }}", url = "" }
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-status"
+say = "The Wayback Machine answered {{ state.resp.status }}."
diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 1bc0256..252f6e8 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -337,6 +337,17 @@ const examples = {
"holidays": "next holiday in US", "exchange-rate": "convert 100 USD to EUR", "synonyms": "synonyms for happy",
+ "forecast": "weather in Oslo tomorrow",
+ "uv-index": "uv index in Sydney",
+ "local-time": "what time is it in Tokyo",
+ "elevation": "how high is Quito",
+ "postal-code": "where is zip 90210",
+ "wayback": "is example.com archived",
+ "rhymes": "what rhymes with moon",
+ "github-repo": "how many stars does astral-sh/uv have",
+ "go-module": "latest version of github.com/gorilla/mux on go",
+ "homebrew": "brew formula wget",
+ "pokemon": "pokemon pikachu",
}; // Served by a Pyrtan, only its token is kept, under its own origin; served elsewhere, the hub URL and token. const KEY = "pyrtan.console:" + location.origin; diff --git a/tests/conformance/make_vectors.py b/tests/conformance/make_vectors.py index db73916..dcc218e 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py
@@ -107,6 +107,33 @@ CASES: list[dict] = [
{"intent": "exchange rate from usd to usd"}, {"intent": "synonyms for happy"}, {"intent": "another word for qqqzzz"},
+ {"intent": "weather in Oslo tomorrow"},
+ {"intent": "uv index in Sydney"},
+ {"intent": "what time is it in La Paz"},
+ {"intent": "how high is La Paz"},
+ {"intent": "how high is Oslo"},
+ {"intent": "where is zip 90210"},
+ {"intent": "postcode SW1A in GB"},
+ {"intent": "where is zip 00000"},
+ {"intent": "is example.com archived"},
+ {"intent": "is nothing-here-qqzz.example archived"},
+ {"intent": "what rhymes with cat"},
+ {"intent": "what rhymes with qqzzx"},
+ {"intent": "how many stars does astral-sh/uv have"},
+ {"intent": "github repo b-i-t-8/nothing-here"},
+ {"intent": "latest version of github.com/gorilla/mux on go"},
+ {"intent": "go module github.com/nope/nope-qq"},
+ {"intent": "brew formula wget"},
+ {"intent": "brew formula nope-qqzz"},
+ {"intent": "pokemon pikachu"},
+ {"intent": "what type is charizard"},
+ {"intent": "pokemon notapokemonqq"},
+ {"intent": "what is my ip"},
+ {"intent": "give me some advice"},
+ {"intent": "tell me a cat fact"},
+ {"intent": "when is the next launch"},
+ {"intent": "space news"},
+ {"intent": "astronomy picture of the day"},
] diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 801d22a..8b412b0 100644 --- a/tests/conformance/vectors.json +++ b/tests/conformance/vectors.json
@@ -7,6 +7,29 @@
"expect": { "name": "this browser", "trees": [
+ {
+ "description": "A random piece of advice, from the Advice Slip API.",
+ "hosts": [
+ "api.adviceslip.com"
+ ],
+ "inputs": {},
+ "language": {},
+ "license": "MIT",
+ "name": "advice",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "fun",
+ "read-only"
+ ],
+ "triggers": [
+ "give me [some] advice",
+ "[any] advice"
+ ],
+ "version": 1
+ },
{ "description": "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo.", "hosts": [
@@ -39,6 +62,30 @@
], "version": 1 },
+ {
+ "description": "NASA's Astronomy Picture of the Day: its title, and the picture or video.",
+ "hosts": [
+ "api.nasa.gov"
+ ],
+ "inputs": {},
+ "language": {},
+ "license": "MIT",
+ "name": "apod",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "space",
+ "science",
+ "read-only"
+ ],
+ "triggers": [
+ "astronomy picture of the day",
+ "[nasa] (apod|space picture) [of the day|today]"
+ ],
+ "version": 1
+ },
{ "description": "A book from Open Library: the best match for a title or an author, who wrote it, and when it first came out.", "hosts": [
@@ -70,6 +117,30 @@
], "version": 1 },
+ {
+ "description": "A random fact about cats, from catfact.ninja.",
+ "hosts": [
+ "catfact.ninja"
+ ],
+ "inputs": {},
+ "language": {},
+ "license": "MIT",
+ "name": "cat-fact",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "fun",
+ "animals",
+ "read-only"
+ ],
+ "triggers": [
+ "[tell me a] cat fact",
+ "tell me something about cats"
+ ],
+ "version": 1
+ },
{ "description": "echo for count", "hosts": [],
@@ -206,6 +277,35 @@
], "version": 1 },
+ {
+ "description": "How high a place is above sea level, from Open-Meteo's geocoder.",
+ "hosts": [
+ "geocoding-api.open-meteo.com"
+ ],
+ "inputs": {
+ "place": {
+ "description": "A town or city name",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "elevation",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "geography",
+ "read-only"
+ ],
+ "triggers": [
+ "how high (is|above sea level is) {place}",
+ "[the] (elevation|altitude) of {place}"
+ ],
+ "version": 1
+ },
{ "description": "An amount in another currency at today's European Central Bank reference rate, from Frankfurter.", "hosts": [
@@ -271,6 +371,37 @@
], "version": 1 },
+ {
+ "description": "Tomorrow's weather for a place: sky, high and low, and chance of rain, from Open-Meteo.",
+ "hosts": [
+ "geocoding-api.open-meteo.com",
+ "api.open-meteo.com"
+ ],
+ "inputs": {
+ "place": {
+ "description": "A town or city name",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "forecast",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "weather",
+ "read-only"
+ ],
+ "triggers": [
+ "[what's the] weather (in|for) {place} tomorrow",
+ "[what's the] weather tomorrow (in|for) {place}",
+ "will it rain tomorrow in {place}"
+ ],
+ "version": 1
+ },
{ "description": "The latest release of a GitHub repository.", "hosts": [
@@ -300,6 +431,67 @@
], "version": 2 },
+ {
+ "description": "A GitHub repository at a glance: description, language, stars, forks, open issues.",
+ "hosts": [
+ "api.github.com"
+ ],
+ "inputs": {
+ "repo": {
+ "description": "owner/name, as in: astral-sh/uv",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "github-repo",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "github",
+ "software",
+ "read-only"
+ ],
+ "triggers": [
+ "how many stars does {repo:word} have",
+ "github (repo|repository) {repo:word}",
+ "tell me about the {repo:word} repo"
+ ],
+ "version": 1
+ },
+ {
+ "description": "The latest version of a Go module, and when it was published, from proxy.golang.org.",
+ "hosts": [
+ "proxy.golang.org"
+ ],
+ "inputs": {
+ "module": {
+ "description": "A module path, as in: github.com/gorilla/mux",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "go-module",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "software",
+ "go",
+ "read-only"
+ ],
+ "triggers": [
+ "[what's the] latest version of {module:word} on go",
+ "go (module|version) {module:word}"
+ ],
+ "version": 1
+ },
{ "description": "Top stories on Hacker News, from its public API.", "hosts": [
@@ -422,6 +614,36 @@
], "version": 1 },
+ {
+ "description": "A Homebrew formula's current stable version and what it is, from formulae.brew.sh.",
+ "hosts": [
+ "formulae.brew.sh"
+ ],
+ "inputs": {
+ "formula": {
+ "description": "A formula name, as in: wget",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "homebrew",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "software",
+ "macos",
+ "read-only"
+ ],
+ "triggers": [
+ "[what's the] latest version of {formula:word} on homebrew",
+ "(brew|homebrew) (formula|version) {formula:word}"
+ ],
+ "version": 1
+ },
{ "description": "Where the International Space Station is right now: latitude, longitude, altitude and speed.", "hosts": [
@@ -480,6 +702,36 @@
], "version": 1 },
+ {
+ "description": "What time it is now in a place, and its time zone.",
+ "hosts": [
+ "geocoding-api.open-meteo.com",
+ "timeapi.io"
+ ],
+ "inputs": {
+ "place": {
+ "description": "A town or city name",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "local-time",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "time",
+ "read-only"
+ ],
+ "triggers": [
+ "what time is it in {place}",
+ "[the] (time|local time) in {place}"
+ ],
+ "version": 1
+ },
{ "description": "Error lines in the tail of a log file on this box.", "hosts": [],
@@ -545,6 +797,52 @@
], "version": 2 },
+ {
+ "description": "The public IP address requests from here come from, from ipify.",
+ "hosts": [
+ "api.ipify.org"
+ ],
+ "inputs": {},
+ "language": {},
+ "license": "MIT",
+ "name": "my-ip",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "network",
+ "read-only"
+ ],
+ "triggers": [
+ "what is my [public] ip [address]",
+ "my [public] ip [address]"
+ ],
+ "version": 1
+ },
+ {
+ "description": "The next rocket launch anywhere, when, and its status, from the Launch Library.",
+ "hosts": [
+ "ll.thespacedevs.com"
+ ],
+ "inputs": {},
+ "language": {},
+ "license": "MIT",
+ "name": "next-launch",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "space",
+ "read-only"
+ ],
+ "triggers": [
+ "[when is the] next (launch|rocket launch)",
+ "upcoming (launches|rocket launches)"
+ ],
+ "version": 1
+ },
{ "description": "The latest version of an npm package, and what it says it is.", "hosts": [
@@ -626,53 +924,123 @@
"version": 1 }, {
- "description": "The latest version of a Python package on PyPI, and what it says it is.",
+ "description": "A Pokémon's number, types, height and weight, from PokéAPI.",
"hosts": [
- "pypi.org"
+ "pokeapi.co"
], "inputs": {
- "package": {
- "description": "A project name, as in: requests",
+ "name": {
+ "description": "A Pokémon's name or number, as in: pikachu",
"required": true, "type": "str" } }, "language": {}, "license": "MIT",
- "name": "pypi-package",
+ "name": "pokemon",
"namespace": "pyrtan", "sources": [ "http" ], "tags": [
- "software",
- "python",
+ "fun",
+ "games",
"read-only" ], "triggers": [
- "[what's the] latest version of {package:word} on pypi",
- "latest {package:word} on pypi",
- "(pypi|pip) (package|version) {package:word}"
+ "pokemon {name:word}",
+ "what type is {name:word}",
+ "tell me about the pokemon {name:word}"
], "version": 1 }, {
- "description": "echo for remind",
- "hosts": [],
+ "description": "The place a postal code belongs to, from Zippopotam.us (US ZIP codes, or a code in another country).",
+ "hosts": [
+ "api.zippopotam.us"
+ ],
"inputs": {
- "at": {
+ "code": {
+ "description": "A postal code, as in: 90210",
+ "required": true,
"type": "str" },
- "wait": {
- "type": "int"
- },
- "when": {
+ "country": {
+ "default": "us",
+ "description": "A two-letter country code, as in: US, DE, GB",
"type": "str" } }, "language": {},
- "license": "",
- "name": "remind",
+ "license": "MIT",
+ "name": "postal-code",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "geography",
+ "read-only"
+ ],
+ "trigger_inputs": {
+ "where is zip [code] {code:word}": {
+ "country": "us"
+ }
+ },
+ "triggers": [
+ "where is zip [code] {code:word}",
+ "[where is] (postcode|postal code) {code:word} in {country:word}"
+ ],
+ "version": 1
+ },
+ {
+ "description": "The latest version of a Python package on PyPI, and what it says it is.",
+ "hosts": [
+ "pypi.org"
+ ],
+ "inputs": {
+ "package": {
+ "description": "A project name, as in: requests",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "pypi-package",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "software",
+ "python",
+ "read-only"
+ ],
+ "triggers": [
+ "[what's the] latest version of {package:word} on pypi",
+ "latest {package:word} on pypi",
+ "(pypi|pip) (package|version) {package:word}"
+ ],
+ "version": 1
+ },
+ {
+ "description": "echo for remind",
+ "hosts": [],
+ "inputs": {
+ "at": {
+ "type": "str"
+ },
+ "wait": {
+ "type": "int"
+ },
+ "when": {
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "",
+ "name": "remind",
"sources": [], "tags": [], "triggers": [
@@ -710,6 +1078,35 @@
], "version": 2 },
+ {
+ "description": "Words that rhyme with a word, from Datamuse.",
+ "hosts": [
+ "api.datamuse.com"
+ ],
+ "inputs": {
+ "word": {
+ "description": "One English word",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "rhymes",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "language",
+ "read-only"
+ ],
+ "triggers": [
+ "what rhymes with {word:word}",
+ "(rhymes|words that rhyme) (for|with) {word:word}"
+ ],
+ "version": 1
+ },
{ "description": "The temperature a room's sensor last published, over MQTT.", "hosts": [
@@ -827,6 +1224,30 @@
], "version": 1 },
+ {
+ "description": "The three latest spaceflight news articles, from the Spaceflight News API.",
+ "hosts": [
+ "api.spaceflightnewsapi.net"
+ ],
+ "inputs": {},
+ "language": {},
+ "license": "MIT",
+ "name": "space-news",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "space",
+ "news",
+ "read-only"
+ ],
+ "triggers": [
+ "[latest] (space|spaceflight) news",
+ "what's new in space"
+ ],
+ "version": 1
+ },
{ "description": "Today's sunrise and sunset for a place, in its local time, from Open-Meteo.", "hosts": [
@@ -925,6 +1346,67 @@
], "version": 1 },
+ {
+ "description": "The UV index for a place, now and today's highest, with its WHO band, from Open-Meteo.",
+ "hosts": [
+ "geocoding-api.open-meteo.com",
+ "api.open-meteo.com"
+ ],
+ "inputs": {
+ "place": {
+ "description": "A town or city name",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "uv-index",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "weather",
+ "health",
+ "read-only"
+ ],
+ "triggers": [
+ "[what's the] (uv|uv index) in {place}",
+ "do i need sunscreen in {place}"
+ ],
+ "version": 1
+ },
+ {
+ "description": "The Wayback Machine's latest copy of a site, if it has one.",
+ "hosts": [
+ "archive.org"
+ ],
+ "inputs": {
+ "site": {
+ "description": "A host name, as in: example.com",
+ "required": true,
+ "type": "str"
+ }
+ },
+ "language": {},
+ "license": "MIT",
+ "name": "wayback",
+ "namespace": "pyrtan",
+ "sources": [
+ "http"
+ ],
+ "tags": [
+ "web",
+ "read-only"
+ ],
+ "triggers": [
+ "is {site:host} archived",
+ "wayback [machine] {site:host}",
+ "[the] archived copy of {site:host}"
+ ],
+ "version": 1
+ },
{ "description": "Current weather for a place, from Open-Meteo.", "hosts": [
@@ -11207,112 +11689,3616 @@
}, "intent": "another word for qqqzzz", "tree": "synonyms"
- }
- ],
- "trees": {
- "air-quality": {
- "description": "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo.",
- "hosts": [
- "geocoding-api.open-meteo.com",
- "air-quality-api.open-meteo.com"
- ],
- "inputs": {
- "place": {
- "description": "A town or city name",
- "type": "str"
- }
- },
- "license": "MIT",
- "name": "air-quality",
- "namespace": "pyrtan",
- "nodes": {
- "air": {
- "extract": [
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "Oslo"
+ },
+ "output": {
+ "country": "Norway",
+ "date": "2026-10-29",
+ "high": 9.4,
+ "low": 5.9,
+ "place": "Oslo",
+ "rain_chance": 80,
+ "sky": "rain",
+ "unit": "°C"
+ },
+ "say": "Oslo, Norway, tomorrow (2026-10-29): rain, 5.9 to 9.4°C, 80% chance of rain.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
{
- "into": "aqi",
- "json": "json.current.european_aqi"
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "weather in Oslo tomorrow",
+ "inputs": {
+ "place": "Oslo"
+ },
+ "steps": [],
+ "trigger": "[what's the] weather (in|for) {place} tomorrow"
+ },
+ "summary": "'weather in Oslo tomorrow'; forecast via \"[what's the] weather (in|for) {place} tomorrow\" (29 literal chars)"
}, {
- "into": "pm25",
- "json": "json.current.pm2_5"
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "Norway",
+ "height": null,
+ "hit": {
+ "country": "Norway",
+ "latitude": 59.91,
+ "longitude": 10.75,
+ "name": "Oslo"
+ },
+ "lat": 59.91,
+ "lon": 10.75,
+ "name": "Oslo",
+ "zone": null
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Oslo&count=1&language=en&format=json -> 200; hit={'name': 'Oslo', 'country': 'Norway', 'latitude': 59.91, 'longitude': 10.75}, lat=59.91, lon=10.75, name=Oslo, country=Norway, zone=None, height=None"
}, {
- "into": "pm10",
- "json": "json.current.pm10"
- }
- ],
- "http": {
- "method": "GET",
- "url": "https://air-quality-api.open-meteo.com/v1/air-quality?latitude={{ state.lat }}&longitude={{ state.lon }}&current=european_aqi,pm2_5,pm10"
- },
- "into": "aq",
- "kind": "observe",
- "next": "band"
- },
- "band": {
- "branches": [
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "tomorrow",
+ "node": "found",
+ "observed": {
+ "country": "Norway",
+ "latitude": 59.91,
+ "longitude": 10.75,
+ "name": "Oslo"
+ },
+ "summary": "state.hit = {'name': 'Oslo', 'country': 'Norway', 'latitude': 59.91, 'longitude': 10.75}"
+ },
{
- "then": "good",
- "when": {
- "lte": 20,
- "type": "number"
- }
+ "depth": 0,
+ "kind": "observe",
+ "next": "have-day",
+ "node": "tomorrow",
+ "observed": {
+ "code": 61,
+ "day": "2026-10-29",
+ "high": 9.4,
+ "low": 5.9,
+ "rain": 80,
+ "unit": "°C"
+ },
+ "summary": "GET https://api.open-meteo.com/v1/forecast?latitude=59.91&longitude=10.75&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=2 -> 200; day=2026-10-29, code=61, high=9.4, low=5.9, rain=80, unit=°C"
+ },
+ {
+ "chosen": "type 'number'",
+ "depth": 0,
+ "kind": "decide",
+ "next": "sky",
+ "node": "have-day",
+ "observed": 9.4,
+ "summary": "state.high = 9.4"
+ },
+ {
+ "chosen": "between [51, 67]",
+ "depth": 0,
+ "kind": "decide",
+ "next": "rain",
+ "node": "sky",
+ "observed": 61,
+ "summary": "state.code = 61"
+ },
+ {
+ "depth": 0,
+ "kind": "set",
+ "next": "reply",
+ "node": "rain",
+ "observed": {
+ "sky": "rain"
+ },
+ "summary": "sky=rain"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Oslo, Norway, tomorrow (2026-10-29): rain, 5.9 to 9.4°C, 80% chance of rain."
+ }
+ ],
+ "tree": "forecast"
+ },
+ "tree": "forecast"
+ },
+ "inputs": {
+ "place": "Oslo"
+ },
+ "intent": "weather in Oslo tomorrow",
+ "tree": "forecast"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "Sydney"
+ },
+ "output": {
+ "band": "extreme",
+ "country": "Australia",
+ "place": "Sydney",
+ "uv_max": 11.2,
+ "uv_now": 6.3
+ },
+ "say": "Sydney, Australia: UV index 6.3 now, up to 11.2 today (extreme): stay out of the midday sun.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "uv index in Sydney",
+ "inputs": {
+ "place": "Sydney"
+ },
+ "steps": [],
+ "trigger": "[what's the] (uv|uv index) in {place}"
+ },
+ "summary": "'uv index in Sydney'; uv-index via \"[what's the] (uv|uv index) in {place}\" (20 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "Australia",
+ "height": 58.0,
+ "hit": {
+ "country": "Australia",
+ "elevation": 58.0,
+ "latitude": -33.86785,
+ "longitude": 151.20732,
+ "name": "Sydney",
+ "timezone": "Australia/Sydney"
+ },
+ "lat": -33.86785,
+ "lon": 151.20732,
+ "name": "Sydney",
+ "zone": "Australia/Sydney"
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Sydney&count=1&language=en&format=json -> 200; hit={'name': 'Sydney', 'country': 'Australia', 'latitude': -33.86785, 'longitude': …, lat=-33.86785, lon=151.20732, name=Sydney, country=Australia, zone=Australia/Sydney, height=58.0"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "uv",
+ "node": "found",
+ "observed": {
+ "country": "Australia",
+ "elevation": 58.0,
+ "latitude": -33.86785,
+ "longitude": 151.20732,
+ "name": "Sydney",
+ "timezone": "Australia/Sydney"
+ },
+ "summary": "state.hit = {'name': 'Sydney', 'country': 'Australia', 'latitude': -33.86785, 'longitude': …"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "band",
+ "node": "uv",
+ "observed": {
+ "max": 11.2,
+ "now": 6.3
+ },
+ "summary": "GET https://api.open-meteo.com/v1/forecast?latitude=-33.86785&longitude=151.20732&current=uv_index&daily=uv_index_max&timezone=auto&forecast_days=1 -> 200; now=6.3, max=11.2"
+ },
+ {
+ "chosen": "gte 11",
+ "depth": 0,
+ "kind": "decide",
+ "next": "extreme",
+ "node": "band",
+ "observed": 11.2,
+ "summary": "state.max = 11.2"
+ },
+ {
+ "depth": 0,
+ "kind": "set",
+ "next": "reply",
+ "node": "extreme",
+ "observed": {
+ "advice": "stay out of the midday sun",
+ "band": "extreme"
+ },
+ "summary": "band=extreme, advice=stay out of the midday sun"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Sydney, Australia: UV index 6.3 now, up to 11.2 today (extreme): stay out of the midday sun."
+ }
+ ],
+ "tree": "uv-index"
+ },
+ "tree": "uv-index"
+ },
+ "inputs": {
+ "place": "Sydney"
+ },
+ "intent": "uv index in Sydney",
+ "tree": "uv-index"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "La Paz"
+ },
+ "output": {
+ "country": "Mexico",
+ "date": "2026-10-27",
+ "place": "La Paz",
+ "time": "17:50",
+ "timezone": "America/Mazatlan",
+ "weekday": "Tuesday"
+ },
+ "say": "It is 17:50 on Tuesday 2026-10-27 in La Paz, Mexico (America/Mazatlan).",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "what time is it in La Paz",
+ "inputs": {
+ "place": "La Paz"
+ },
+ "steps": [],
+ "trigger": "what time is it in {place}"
+ },
+ "summary": "'what time is it in La Paz'; local-time via 'what time is it in {place}' (14 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "Mexico",
+ "height": 33.0,
+ "hit": {
+ "country": "Mexico",
+ "elevation": 33.0,
+ "latitude": 24.14231,
+ "longitude": -110.31316,
+ "name": "La Paz",
+ "timezone": "America/Mazatlan"
+ },
+ "lat": 24.14231,
+ "lon": -110.31316,
+ "name": "La Paz",
+ "zone": "America/Mazatlan"
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=La%20Paz&count=1&language=en&format=json -> 200; hit={'name': 'La Paz', 'country': 'Mexico', 'latitude': 24.14231, 'longitude': -110…, lat=24.14231, lon=-110.31316, name=La Paz, country=Mexico, zone=America/Mazatlan, height=33.0"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "clock",
+ "node": "found",
+ "observed": {
+ "country": "Mexico",
+ "elevation": 33.0,
+ "latitude": 24.14231,
+ "longitude": -110.31316,
+ "name": "La Paz",
+ "timezone": "America/Mazatlan"
+ },
+ "summary": "state.hit = {'name': 'La Paz', 'country': 'Mexico', 'latitude': 24.14231, 'longitude': -110…"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "clock",
+ "observed": {
+ "date": "2026-10-27",
+ "time": "17:50",
+ "weekday": "Tuesday"
+ },
+ "summary": "GET https://timeapi.io/api/time/current/zone?timeZone=America%2FMazatlan -> 200; time=17:50, weekday=Tuesday, date=2026-10-27"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "It is 17:50 on Tuesday 2026-10-27 in La Paz, Mexico (America/Mazatlan)."
+ }
+ ],
+ "tree": "local-time"
+ },
+ "tree": "local-time"
+ },
+ "inputs": {
+ "place": "La Paz"
+ },
+ "intent": "what time is it in La Paz",
+ "tree": "local-time"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "La Paz"
+ },
+ "output": {
+ "country": "Mexico",
+ "elevation_m": 33.0,
+ "place": "La Paz"
+ },
+ "say": "La Paz, Mexico is 33 m above sea level.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "how high is La Paz",
+ "inputs": {
+ "place": "La Paz"
+ },
+ "steps": [],
+ "trigger": "how high (is|above sea level is) {place}"
+ },
+ "summary": "'how high is La Paz'; elevation via 'how high (is|above sea level is) {place}' (24 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "Mexico",
+ "height": 33.0,
+ "hit": {
+ "country": "Mexico",
+ "elevation": 33.0,
+ "latitude": 24.14231,
+ "longitude": -110.31316,
+ "name": "La Paz",
+ "timezone": "America/Mazatlan"
+ },
+ "lat": 24.14231,
+ "lon": -110.31316,
+ "name": "La Paz",
+ "zone": "America/Mazatlan"
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=La%20Paz&count=1&language=en&format=json -> 200; hit={'name': 'La Paz', 'country': 'Mexico', 'latitude': 24.14231, 'longitude': -110…, lat=24.14231, lon=-110.31316, name=La Paz, country=Mexico, zone=America/Mazatlan, height=33.0"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-height",
+ "node": "found",
+ "observed": {
+ "country": "Mexico",
+ "elevation": 33.0,
+ "latitude": 24.14231,
+ "longitude": -110.31316,
+ "name": "La Paz",
+ "timezone": "America/Mazatlan"
+ },
+ "summary": "state.hit = {'name': 'La Paz', 'country': 'Mexico', 'latitude': 24.14231, 'longitude': -110…"
+ },
+ {
+ "chosen": "type 'number'",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "has-height",
+ "observed": 33.0,
+ "summary": "state.height = 33.0"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "La Paz, Mexico is 33 m above sea level."
+ }
+ ],
+ "tree": "elevation"
+ },
+ "tree": "elevation"
+ },
+ "inputs": {
+ "place": "La Paz"
+ },
+ "intent": "how high is La Paz",
+ "tree": "elevation"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "place": "Oslo"
+ },
+ "reason": "no-height",
+ "say": "Open-Meteo knows Oslo but not how high it is.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "geocode",
+ "node": "intent",
+ "observed": {
+ "cleaned": "how high is Oslo",
+ "inputs": {
+ "place": "Oslo"
+ },
+ "steps": [],
+ "trigger": "how high (is|above sea level is) {place}"
+ },
+ "summary": "'how high is Oslo'; elevation via 'how high (is|above sea level is) {place}' (24 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "found",
+ "node": "geocode",
+ "observed": {
+ "country": "Norway",
+ "height": null,
+ "hit": {
+ "country": "Norway",
+ "latitude": 59.91,
+ "longitude": 10.75,
+ "name": "Oslo"
+ },
+ "lat": 59.91,
+ "lon": 10.75,
+ "name": "Oslo",
+ "zone": null
+ },
+ "summary": "GET https://geocoding-api.open-meteo.com/v1/search?name=Oslo&count=1&language=en&format=json -> 200; hit={'name': 'Oslo', 'country': 'Norway', 'latitude': 59.91, 'longitude': 10.75}, lat=59.91, lon=10.75, name=Oslo, country=Norway, zone=None, height=None"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-height",
+ "node": "found",
+ "observed": {
+ "country": "Norway",
+ "latitude": 59.91,
+ "longitude": 10.75,
+ "name": "Oslo"
+ },
+ "summary": "state.hit = {'name': 'Oslo', 'country': 'Norway', 'latitude': 59.91, 'longitude': 10.75}"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-height",
+ "node": "has-height",
+ "summary": "state.height = <missing>"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-height",
+ "summary": "no-height: Open-Meteo knows Oslo but not how high it is."
+ }
+ ],
+ "tree": "elevation"
+ },
+ "tree": "elevation"
+ },
+ "inputs": {
+ "place": "Oslo"
+ },
+ "intent": "how high is Oslo",
+ "tree": "elevation"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "code": "90210",
+ "country": "us"
+ },
+ "output": {
+ "code": "90210",
+ "country": "United States",
+ "latitude": 34.0901,
+ "longitude": -118.4065,
+ "place": "Beverly Hills",
+ "region": "California"
+ },
+ "say": "90210 is Beverly Hills, California, United States.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "where is zip 90210",
+ "inputs": {
+ "code": "90210",
+ "country": "us"
+ },
+ "steps": [],
+ "trigger": "where is zip [code] {code:word}"
+ },
+ "summary": "'where is zip 90210'; postal-code via 'where is zip [code] {code:word}' (14 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "country_name": "United States",
+ "lat": "34.0901",
+ "lon": "-118.4065",
+ "place": "Beverly Hills",
+ "region": "California"
+ },
+ "summary": "GET https://api.zippopotam.us/us/90210 -> 200; place=Beverly Hills, region=California, country_name=United States, lat=34.0901, lon=-118.4065"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "90210 is Beverly Hills, California, United States."
+ }
+ ],
+ "tree": "postal-code"
+ },
+ "tree": "postal-code"
+ },
+ "inputs": {
+ "code": "90210",
+ "country": "us"
+ },
+ "intent": "where is zip 90210",
+ "tree": "postal-code"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "code": "SW1A",
+ "country": "GB"
+ },
+ "output": {
+ "code": "SW1A",
+ "country": "Great Britain",
+ "latitude": 51.4841,
+ "longitude": -0.1966,
+ "place": "Westminster Abbey",
+ "region": "England"
+ },
+ "say": "SW1A is Westminster Abbey, England, Great Britain.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "postcode SW1A in GB",
+ "inputs": {
+ "code": "SW1A",
+ "country": "GB"
+ },
+ "steps": [],
+ "trigger": "[where is] (postcode|postal code) {code:word} in {country:word}"
+ },
+ "summary": "'postcode SW1A in GB'; postal-code via '[where is] (postcode|postal code) {code:word} in {country:word}' (27 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "country_name": "Great Britain",
+ "lat": "51.4841",
+ "lon": "-0.1966",
+ "place": "Westminster Abbey",
+ "region": "England"
+ },
+ "summary": "GET https://api.zippopotam.us/gb/SW1A -> 200; place=Westminster Abbey, region=England, country_name=Great Britain, lat=51.4841, lon=-0.1966"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "SW1A is Westminster Abbey, England, Great Britain."
+ }
+ ],
+ "tree": "postal-code"
+ },
+ "tree": "postal-code"
+ },
+ "inputs": {
+ "code": "SW1A",
+ "country": "GB"
+ },
+ "intent": "postcode SW1A in GB",
+ "tree": "postal-code"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "code": "00000",
+ "country": "us"
+ },
+ "reason": "unknown-code",
+ "say": "Zippopotam.us knows no postal code 00000 in US.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "where is zip 00000",
+ "inputs": {
+ "code": "00000",
+ "country": "us"
+ },
+ "steps": [],
+ "trigger": "where is zip [code] {code:word}"
+ },
+ "summary": "'where is zip 00000'; postal-code via 'where is zip [code] {code:word}' (14 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "country_name": null,
+ "lat": null,
+ "lon": null,
+ "place": null,
+ "region": null
+ },
+ "summary": "GET https://api.zippopotam.us/us/00000 -> 404; place=None, region=None, country_name=None, lat=None, lon=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "unknown-code",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "unknown-code",
+ "summary": "unknown-code: Zippopotam.us knows no postal code 00000 in US."
+ }
+ ],
+ "tree": "postal-code"
+ },
+ "tree": "postal-code"
+ },
+ "inputs": {
+ "code": "00000",
+ "country": "us"
+ },
+ "intent": "where is zip 00000",
+ "tree": "postal-code"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "site": "example.com"
+ },
+ "output": {
+ "site": "example.com",
+ "timestamp": "20261027205234",
+ "url": "http://web.archive.org/web/20261027205234/https://example.com/"
+ },
+ "say": "The Wayback Machine has example.com. Its latest copy: http://web.archive.org/web/20261027205234/https://example.com/",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "is example.com archived",
+ "inputs": {
+ "site": "example.com"
+ },
+ "steps": [],
+ "trigger": "is {site:host} archived"
+ },
+ "summary": "'is example.com archived'; wayback via 'is {site:host} archived' (10 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "copy": "http://web.archive.org/web/20261027205234/https://example.com/",
+ "stamp": "20261027205234"
+ },
+ "summary": "GET https://archive.org/wayback/available?url=example.com -> 200; copy=http://web.archive.org/web/20261027205234/https://example.com/, stamp=20261027205234"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "any",
+ "observed": "http://web.archive.org/web/20261027205234/https://example.com/",
+ "summary": "state.copy = http://web.archive.org/web/20261027205234/https://example.com/"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "The Wayback Machine has example.com. Its latest copy: http://web.archive.org/web/20261027205234/https://example.com/"
+ }
+ ],
+ "tree": "wayback"
+ },
+ "tree": "wayback"
+ },
+ "inputs": {
+ "site": "example.com"
+ },
+ "intent": "is example.com archived",
+ "tree": "wayback"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "site": "nothing-here-qqzz.example"
+ },
+ "output": {
+ "site": "nothing-here-qqzz.example",
+ "url": ""
+ },
+ "say": "The Wayback Machine has no copy of nothing-here-qqzz.example.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "is nothing-here-qqzz.example archived",
+ "inputs": {
+ "site": "nothing-here-qqzz.example"
+ },
+ "steps": [],
+ "trigger": "is {site:host} archived"
+ },
+ "summary": "'is nothing-here-qqzz.example archived'; wayback via 'is {site:host} archived' (10 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "copy": null,
+ "stamp": null
+ },
+ "summary": "GET https://archive.org/wayback/available?url=nothing-here-qqzz.example -> 200; copy=None, stamp=None"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "none",
+ "node": "any",
+ "summary": "state.copy = <missing>"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "none",
+ "summary": "The Wayback Machine has no copy of nothing-here-qqzz.example."
+ }
+ ],
+ "tree": "wayback"
+ },
+ "tree": "wayback"
+ },
+ "inputs": {
+ "site": "nothing-here-qqzz.example"
+ },
+ "intent": "is nothing-here-qqzz.example archived",
+ "tree": "wayback"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "word": "cat"
+ },
+ "output": {
+ "rhymes": [
+ "flat",
+ "caveat",
+ "brat",
+ "pat",
+ "combat",
+ "slat",
+ "that",
+ "rat",
+ "diplomat",
+ "fat"
+ ],
+ "word": "cat"
+ },
+ "say": "Rhymes with cat: flat, caveat, brat, pat, combat, slat, that, rat, diplomat, fat.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "what rhymes with cat",
+ "inputs": {
+ "word": "cat"
+ },
+ "steps": [],
+ "trigger": "what rhymes with {word:word}"
+ },
+ "summary": "'what rhymes with cat'; rhymes via 'what rhymes with {word:word}' (14 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "words": [
+ "flat",
+ "caveat",
+ "brat",
+ "pat",
+ "combat",
+ "slat",
+ "that",
+ "rat",
+ "diplomat",
+ "fat"
+ ]
+ },
+ "summary": "GET https://api.datamuse.com/words?rel_rhy=cat&max=10 -> 200; words=['flat', 'caveat', 'brat', 'pat', 'combat', 'slat', 'that', 'rat', 'diplomat', …"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "any",
+ "observed": [
+ "flat",
+ "caveat",
+ "brat",
+ "pat",
+ "combat",
+ "slat",
+ "that",
+ "rat",
+ "diplomat",
+ "fat"
+ ],
+ "summary": "state.words = ['flat', 'caveat', 'brat', 'pat', 'combat', 'slat', 'that', 'rat', 'diplomat', …"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Rhymes with cat: flat, caveat, brat, pat, combat, slat, that, rat, diplomat, fat."
+ }
+ ],
+ "tree": "rhymes"
+ },
+ "tree": "rhymes"
+ },
+ "inputs": {
+ "word": "cat"
+ },
+ "intent": "what rhymes with cat",
+ "tree": "rhymes"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "word": "qqzzx"
+ },
+ "reason": "no-rhymes",
+ "say": "Datamuse knows nothing that rhymes with qqzzx.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "what rhymes with qqzzx",
+ "inputs": {
+ "word": "qqzzx"
+ },
+ "steps": [],
+ "trigger": "what rhymes with {word:word}"
+ },
+ "summary": "'what rhymes with qqzzx'; rhymes via 'what rhymes with {word:word}' (14 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "words": []
+ },
+ "summary": "GET https://api.datamuse.com/words?rel_rhy=qqzzx&max=10 -> 200; words=[]"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "empty True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "none",
+ "node": "any",
+ "observed": [],
+ "summary": "state.words = []"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "none",
+ "summary": "no-rhymes: Datamuse knows nothing that rhymes with qqzzx."
+ }
+ ],
+ "tree": "rhymes"
+ },
+ "tree": "rhymes"
+ },
+ "inputs": {
+ "word": "qqzzx"
+ },
+ "intent": "what rhymes with qqzzx",
+ "tree": "rhymes"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "repo": "astral-sh/uv"
+ },
+ "output": {
+ "archived": false,
+ "description": "An extremely fast Python package and project manager, written in Rust.",
+ "forks": 2210,
+ "language": "Rust",
+ "open_issues": 2004,
+ "repo": "astral-sh/uv",
+ "stars": 71234,
+ "url": "https://github.com/astral-sh/uv"
+ },
+ "say": "astral-sh/uv: An extremely fast Python package and project manager, written in Rust. (Rust) · 71234 stars, 2210 forks, 2004 open issues. https://github.com/astral-sh/uv",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "how many stars does astral-sh/uv have",
+ "inputs": {
+ "repo": "astral-sh/uv"
+ },
+ "steps": [],
+ "trigger": "how many stars does {repo:word} have"
+ },
+ "summary": "'how many stars does astral-sh/uv have'; github-repo via 'how many stars does {repo:word} have' (20 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": "An extremely fast Python package and project manager, written in Rust.",
+ "archived": false,
+ "forks": 2210,
+ "full": "astral-sh/uv",
+ "issues": 2004,
+ "language": "Rust",
+ "stars": 71234,
+ "url": "https://github.com/astral-sh/uv"
+ },
+ "summary": "GET https://api.github.com/repos/astral-sh/uv -> 200; full=astral-sh/uv, about=An extremely fast Python package and project manager, written in Rust., language=Rust, stars=71234, forks=2210, issues=2004, url=https://github.com/astral-sh/uv, archived=False"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "astral-sh/uv: An extremely fast Python package and project manager, written in Rust. (Rust) · 71234 stars, 2210 forks, 2004 open issues. https://github.com/astral-sh/uv"
+ }
+ ],
+ "tree": "github-repo"
+ },
+ "tree": "github-repo"
+ },
+ "inputs": {
+ "repo": "astral-sh/uv"
+ },
+ "intent": "how many stars does astral-sh/uv have",
+ "tree": "github-repo"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "repo": "b-i-t-8/nothing-here"
+ },
+ "reason": "no-repo",
+ "say": "GitHub has no public repository b-i-t-8/nothing-here.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "github repo b-i-t-8/nothing-here",
+ "inputs": {
+ "repo": "b-i-t-8/nothing-here"
+ },
+ "steps": [],
+ "trigger": "github (repo|repository) {repo:word}"
+ },
+ "summary": "'github repo b-i-t-8/nothing-here'; github-repo via 'github (repo|repository) {repo:word}' (20 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": null,
+ "archived": null,
+ "forks": null,
+ "full": null,
+ "issues": null,
+ "language": null,
+ "stars": null,
+ "url": null
+ },
+ "summary": "GET https://api.github.com/repos/b-i-t-8/nothing-here -> 404; full=None, about=None, language=None, stars=None, forks=None, issues=None, url=None, archived=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-repo",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-repo",
+ "summary": "no-repo: GitHub has no public repository b-i-t-8/nothing-here."
+ }
+ ],
+ "tree": "github-repo"
+ },
+ "tree": "github-repo"
+ },
+ "inputs": {
+ "repo": "b-i-t-8/nothing-here"
+ },
+ "intent": "github repo b-i-t-8/nothing-here",
+ "tree": "github-repo"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "module": "github.com/gorilla/mux"
+ },
+ "output": {
+ "module": "github.com/gorilla/mux",
+ "published": "2023-10-18",
+ "url": "https://pkg.go.dev/github.com/gorilla/mux",
+ "version": "v1.8.1"
+ },
+ "say": "github.com/gorilla/mux v1.8.1, published 2023-10-18. https://pkg.go.dev/github.com/gorilla/mux",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "latest version of github.com/gorilla/mux on go",
+ "inputs": {
+ "module": "github.com/gorilla/mux"
+ },
+ "steps": [],
+ "trigger": "[what's the] latest version of {module:word} on go"
+ },
+ "summary": "'latest version of github.com/gorilla/mux on go'; go-module via \"[what's the] latest version of {module:word} on go\" (28 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "day": "2023-10-18",
+ "version": "v1.8.1"
+ },
+ "summary": "GET https://proxy.golang.org/github.com/gorilla/mux/@latest -> 200; version=v1.8.1, day=2023-10-18"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "github.com/gorilla/mux v1.8.1, published 2023-10-18. https://pkg.go.dev/github.com/gorilla/mux"
+ }
+ ],
+ "tree": "go-module"
+ },
+ "tree": "go-module"
+ },
+ "inputs": {
+ "module": "github.com/gorilla/mux"
+ },
+ "intent": "latest version of github.com/gorilla/mux on go",
+ "tree": "go-module"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "module": "github.com/nope/nope-qq"
+ },
+ "reason": "no-module",
+ "say": "The Go module proxy has no module github.com/nope/nope-qq.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "go module github.com/nope/nope-qq",
+ "inputs": {
+ "module": "github.com/nope/nope-qq"
+ },
+ "steps": [],
+ "trigger": "go (module|version) {module:word}"
+ },
+ "summary": "'go module github.com/nope/nope-qq'; go-module via 'go (module|version) {module:word}' (15 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "day": null,
+ "version": null
+ },
+ "summary": "GET https://proxy.golang.org/github.com/nope/nope-qq/@latest -> 404; version=None, day=None"
+ },
+ {
+ "chosen": "in [404, 410]",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-module",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-module",
+ "summary": "no-module: The Go module proxy has no module github.com/nope/nope-qq."
+ }
+ ],
+ "tree": "go-module"
+ },
+ "tree": "go-module"
+ },
+ "inputs": {
+ "module": "github.com/nope/nope-qq"
+ },
+ "intent": "go module github.com/nope/nope-qq",
+ "tree": "go-module"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "formula": "wget"
+ },
+ "output": {
+ "description": "Internet file retriever",
+ "formula": "wget",
+ "homepage": "https://www.gnu.org/software/wget/",
+ "install": "brew install wget",
+ "version": "1.25.0"
+ },
+ "say": "wget 1.25.0 on Homebrew: Internet file retriever. https://www.gnu.org/software/wget/",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "brew formula wget",
+ "inputs": {
+ "formula": "wget"
+ },
+ "steps": [],
+ "trigger": "(brew|homebrew) (formula|version) {formula:word}"
+ },
+ "summary": "'brew formula wget'; homebrew via '(brew|homebrew) (formula|version) {formula:word}' (26 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": "Internet file retriever",
+ "home": "https://www.gnu.org/software/wget/",
+ "name": "wget",
+ "version": "1.25.0"
+ },
+ "summary": "GET https://formulae.brew.sh/api/formula/wget.json -> 200; name=wget, version=1.25.0, about=Internet file retriever, home=https://www.gnu.org/software/wget/"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "wget 1.25.0 on Homebrew: Internet file retriever. https://www.gnu.org/software/wget/"
+ }
+ ],
+ "tree": "homebrew"
+ },
+ "tree": "homebrew"
+ },
+ "inputs": {
+ "formula": "wget"
+ },
+ "intent": "brew formula wget",
+ "tree": "homebrew"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "formula": "nope-qqzz"
+ },
+ "reason": "no-formula",
+ "say": "Homebrew has no formula called nope-qqzz.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "brew formula nope-qqzz",
+ "inputs": {
+ "formula": "nope-qqzz"
+ },
+ "steps": [],
+ "trigger": "(brew|homebrew) (formula|version) {formula:word}"
+ },
+ "summary": "'brew formula nope-qqzz'; homebrew via '(brew|homebrew) (formula|version) {formula:word}' (26 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": null,
+ "home": null,
+ "name": null,
+ "version": null
+ },
+ "summary": "GET https://formulae.brew.sh/api/formula/nope-qqzz.json -> 404; name=None, version=None, about=None, home=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-formula",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-formula",
+ "summary": "no-formula: Homebrew has no formula called nope-qqzz."
+ }
+ ],
+ "tree": "homebrew"
+ },
+ "tree": "homebrew"
+ },
+ "inputs": {
+ "formula": "nope-qqzz"
+ },
+ "intent": "brew formula nope-qqzz",
+ "tree": "homebrew"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "name": "pikachu"
+ },
+ "output": {
+ "height_dm": 4,
+ "name": "pikachu",
+ "number": 25,
+ "types": [
+ "electric"
+ ],
+ "weight_hg": 60
+ },
+ "say": "#25 Pikachu, electric type.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "pokemon pikachu",
+ "inputs": {
+ "name": "pikachu"
+ },
+ "steps": [],
+ "trigger": "pokemon {name:word}"
+ },
+ "summary": "'pokemon pikachu'; pokemon via 'pokemon {name:word}' (7 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "dm": 4,
+ "hg": 60,
+ "id": 25,
+ "name": "pikachu",
+ "types": [
+ "electric"
+ ]
+ },
+ "summary": "GET https://pokeapi.co/api/v2/pokemon/pikachu -> 200; id=25, name=pikachu, types=['electric'], dm=4, hg=60"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "#25 Pikachu, electric type."
+ }
+ ],
+ "tree": "pokemon"
+ },
+ "tree": "pokemon"
+ },
+ "inputs": {
+ "name": "pikachu"
+ },
+ "intent": "pokemon pikachu",
+ "tree": "pokemon"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "name": "charizard"
+ },
+ "output": {
+ "height_dm": 17,
+ "name": "charizard",
+ "number": 6,
+ "types": [
+ "fire",
+ "flying"
+ ],
+ "weight_hg": 905
+ },
+ "say": "#6 Charizard, fire, flying type.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "what type is charizard",
+ "inputs": {
+ "name": "charizard"
+ },
+ "steps": [],
+ "trigger": "what type is {name:word}"
+ },
+ "summary": "'what type is charizard'; pokemon via 'what type is {name:word}' (10 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "dm": 17,
+ "hg": 905,
+ "id": 6,
+ "name": "charizard",
+ "types": [
+ "fire",
+ "flying"
+ ]
+ },
+ "summary": "GET https://pokeapi.co/api/v2/pokemon/charizard -> 200; id=6, name=charizard, types=['fire', 'flying'], dm=17, hg=905"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "#6 Charizard, fire, flying type."
+ }
+ ],
+ "tree": "pokemon"
+ },
+ "tree": "pokemon"
+ },
+ "inputs": {
+ "name": "charizard"
+ },
+ "intent": "what type is charizard",
+ "tree": "pokemon"
+ },
+ {
+ "expect": {
+ "inputs": {
+ "name": "notapokemonqq"
+ },
+ "reason": "no-pokemon",
+ "say": "PokéAPI knows no Pokémon called notapokemonqq.",
+ "status": "failed",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "pokemon notapokemonqq",
+ "inputs": {
+ "name": "notapokemonqq"
+ },
+ "steps": [],
+ "trigger": "pokemon {name:word}"
+ },
+ "summary": "'pokemon notapokemonqq'; pokemon via 'pokemon {name:word}' (7 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "dm": null,
+ "hg": null,
+ "id": null,
+ "name": null,
+ "types": []
+ },
+ "summary": "GET https://pokeapi.co/api/v2/pokemon/notapokemonqq -> 404; id=None, name=None, types=[], dm=None, hg=None"
+ },
+ {
+ "chosen": "eq 404",
+ "depth": 0,
+ "kind": "decide",
+ "next": "no-pokemon",
+ "node": "status",
+ "observed": 404,
+ "summary": "state.resp.status = 404"
+ },
+ {
+ "depth": 0,
+ "kind": "fail",
+ "next": null,
+ "node": "no-pokemon",
+ "summary": "no-pokemon: PokéAPI knows no Pokémon called notapokemonqq."
+ }
+ ],
+ "tree": "pokemon"
+ },
+ "tree": "pokemon"
+ },
+ "inputs": {
+ "name": "notapokemonqq"
+ },
+ "intent": "pokemon notapokemonqq",
+ "tree": "pokemon"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "ip": "203.0.113.7"
+ },
+ "say": "Your public IP address is 203.0.113.7.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "what is my ip",
+ "inputs": {},
+ "steps": [],
+ "trigger": "what is my [public] ip [address]"
+ },
+ "summary": "'what is my ip'; my-ip via 'what is my [public] ip [address]' (23 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "ip": "203.0.113.7"
+ },
+ "summary": "GET https://api.ipify.org?format=json -> 200; ip=203.0.113.7"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-ip",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "has-ip",
+ "observed": "203.0.113.7",
+ "summary": "state.ip = 203.0.113.7"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Your public IP address is 203.0.113.7."
+ }
+ ],
+ "tree": "my-ip"
+ },
+ "tree": "my-ip"
+ },
+ "inputs": {},
+ "intent": "what is my ip",
+ "tree": "my-ip"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "advice": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance.",
+ "id": 66
+ },
+ "say": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "give me some advice",
+ "inputs": {},
+ "steps": [],
+ "trigger": "give me [some] advice"
+ },
+ "summary": "'give me some advice'; advice via 'give me [some] advice' (16 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "advice": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance.",
+ "id": 66
+ },
+ "summary": "GET https://api.adviceslip.com/advice -> 200; advice=Take time once in a while to look up at the stars for at least 5 minutes, in or…, id=66"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-advice",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "has-advice",
+ "observed": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance.",
+ "summary": "state.advice = Take time once in a while to look up at the stars for at least 5 minutes, in or…"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Take time once in a while to look up at the stars for at least 5 minutes, in order to comprehend your cosmic significance."
+ }
+ ],
+ "tree": "advice"
+ },
+ "tree": "advice"
+ },
+ "inputs": {},
+ "intent": "give me some advice",
+ "tree": "advice"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "fact": "A cat has more bones than a human; humans have 206, and the cat - 230."
+ },
+ "say": "A cat has more bones than a human; humans have 206, and the cat - 230.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "tell me a cat fact",
+ "inputs": {},
+ "steps": [],
+ "trigger": "[tell me a] cat fact"
+ },
+ "summary": "'tell me a cat fact'; cat-fact via '[tell me a] cat fact' (14 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "fact": "A cat has more bones than a human; humans have 206, and the cat - 230."
+ },
+ "summary": "GET https://catfact.ninja/fact -> 200; fact=A cat has more bones than a human; humans have 206, and the cat - 230."
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "has-fact",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "has-fact",
+ "observed": "A cat has more bones than a human; humans have 206, and the cat - 230.",
+ "summary": "state.fact = A cat has more bones than a human; humans have 206, and the cat - 230."
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "A cat has more bones than a human; humans have 206, and the cat - 230."
+ }
+ ],
+ "tree": "cat-fact"
+ },
+ "tree": "cat-fact"
+ },
+ "inputs": {},
+ "intent": "tell me a cat fact",
+ "tree": "cat-fact"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "name": "Falcon 9 Block 5 | Starlink Group 15-18",
+ "net_utc": "2026-10-30T04:02:00Z",
+ "status": "Go for Launch",
+ "upcoming": 474
+ },
+ "say": "Next launch: Falcon 9 Block 5 | Starlink Group 15-18, 2026-10-30T04:02:00Z UTC (Go for Launch). 474 launches are scheduled.",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "when is the next launch",
+ "inputs": {},
+ "steps": [],
+ "trigger": "[when is the] next (launch|rocket launch)"
+ },
+ "summary": "'when is the next launch'; next-launch via '[when is the] next (launch|rocket launch)' (31 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "count": 474,
+ "name": "Falcon 9 Block 5 | Starlink Group 15-18",
+ "net": "2026-10-30T04:02:00Z",
+ "state": "Go for Launch"
+ },
+ "summary": "GET https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=1&mode=list&hide_recent_previous=true -> 200; name=Falcon 9 Block 5 | Starlink Group 15-18, net=2026-10-30T04:02:00Z, state=Go for Launch, count=474"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "exists True",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "any",
+ "observed": "Falcon 9 Block 5 | Starlink Group 15-18",
+ "summary": "state.name = Falcon 9 Block 5 | Starlink Group 15-18"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Next launch: Falcon 9 Block 5 | Starlink Group 15-18, 2026-10-30T04:02:00Z UTC (Go for Launch). 474 launches are scheduled."
+ }
+ ],
+ "tree": "next-launch"
+ },
+ "tree": "next-launch"
+ },
+ "inputs": {},
+ "intent": "when is the next launch",
+ "tree": "next-launch"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "stories": [
+ {
+ "published": "2026-10-27T20:21:30Z",
+ "site": "SpaceNews",
+ "title": "Ukrainian startup, GomSpace test satellite for future communications network",
+ "url": "https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"
+ },
+ {
+ "published": "2026-10-27T19:27:22Z",
+ "site": "NASA",
+ "title": "NASA Invites Media to Crew Completion of Yearlong Mars Simulation",
+ "url": "https://www.nasa.gov/news-release/nasa-invites-media-to-crew-completion-of-yearlong-mars-simulation/"
+ },
+ {
+ "published": "2026-10-27T17:58:57Z",
+ "site": "NASA",
+ "title": "NASA’s Neo Surveyor Sunshade",
+ "url": "https://www.nasa.gov/image-article/nasas-neo-surveyor-sunshade/"
+ }
+ ]
+ },
+ "say": "Latest in space: Ukrainian startup, GomSpace test satellite for future communications network (SpaceNews) https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "space news",
+ "inputs": {},
+ "steps": [],
+ "trigger": "[latest] (space|spaceflight) news"
+ },
+ "summary": "'space news'; space-news via '[latest] (space|spaceflight) news' (26 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "articles": [
+ {
+ "news_site": "SpaceNews",
+ "published_at": "2026-10-27T20:21:30Z",
+ "title": "Ukrainian startup, GomSpace test satellite for future communications network",
+ "url": "https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"
+ },
+ {
+ "news_site": "NASA",
+ "published_at": "2026-10-27T19:27:22Z",
+ "title": "NASA Invites Media to Crew Completion of Yearlong Mars Simulation",
+ "url": "https://www.nasa.gov/news-release/nasa-invites-media-to-crew-completion-of-yearlong-mars-simulation/"
+ },
+ {
+ "news_site": "NASA",
+ "published_at": "2026-10-27T17:58:57Z",
+ "title": "NASA’s Neo Surveyor Sunshade",
+ "url": "https://www.nasa.gov/image-article/nasas-neo-surveyor-sunshade/"
+ }
+ ],
+ "site": "SpaceNews",
+ "title": "Ukrainian startup, GomSpace test satellite for future communications network",
+ "url": "https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"
+ },
+ "summary": "GET https://api.spaceflightnewsapi.net/v4/articles/?limit=3 -> 200; articles=[{'title': 'Ukrainian startup, GomSpace test satellite for future communication…, title=Ukrainian startup, GomSpace test satellite for future communications network, site=SpaceNews, url=https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-comm…"
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "any",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "chosen": "else",
+ "depth": 0,
+ "kind": "decide",
+ "next": "each",
+ "node": "any",
+ "observed": [
+ {
+ "news_site": "SpaceNews",
+ "published_at": "2026-10-27T20:21:30Z",
+ "title": "Ukrainian startup, GomSpace test satellite for future communications network",
+ "url": "https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"
+ },
+ {
+ "news_site": "NASA",
+ "published_at": "2026-10-27T19:27:22Z",
+ "title": "NASA Invites Media to Crew Completion of Yearlong Mars Simulation",
+ "url": "https://www.nasa.gov/news-release/nasa-invites-media-to-crew-completion-of-yearlong-mars-simulation/"
+ },
+ {
+ "news_site": "NASA",
+ "published_at": "2026-10-27T17:58:57Z",
+ "title": "NASA’s Neo Surveyor Sunshade",
+ "url": "https://www.nasa.gov/image-article/nasas-neo-surveyor-sunshade/"
+ }
+ ],
+ "summary": "state.articles = [{'title': 'Ukrainian startup, GomSpace test satellite for future communication…"
+ },
+ {
+ "depth": 1,
+ "kind": "done",
+ "next": null,
+ "node": "one",
+ "summary": "done"
+ },
+ {
+ "depth": 1,
+ "kind": "done",
+ "next": null,
+ "node": "one",
+ "summary": "done"
+ },
+ {
+ "depth": 1,
+ "kind": "done",
+ "next": null,
+ "node": "one",
+ "summary": "done"
+ },
+ {
+ "depth": 0,
+ "kind": "each",
+ "next": "reply",
+ "node": "each",
+ "summary": "3 item(s)"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Latest in space: Ukrainian startup, GomSpace test satellite for future communications network (SpaceNews) https://spacenews.com/ukrainian-startup-gomspace-test-satellite-for-future-communications-network/"
+ }
+ ],
+ "tree": "space-news"
+ },
+ "tree": "space-news"
+ },
+ "inputs": {},
+ "intent": "space news",
+ "tree": "space-news"
+ },
+ {
+ "expect": {
+ "inputs": {},
+ "output": {
+ "date": "2026-10-28",
+ "explanation": "Sometimes it's night on the ground but day in the air.",
+ "media_type": "image",
+ "page": "https://apod.nasa.gov/apod/astropix.html",
+ "title": "Day and Night over the Clouds",
+ "url": "https://apod.nasa.gov/apod/image/2610/example_1024.jpg"
+ },
+ "say": "Astronomy Picture of the Day, 2026-10-28: Day and Night over the Clouds. https://apod.nasa.gov/apod/image/2610/example_1024.jpg",
+ "status": "done",
+ "trace": {
+ "gaps": [],
+ "steps": [
+ {
+ "depth": 0,
+ "kind": "intent",
+ "next": "fetch",
+ "node": "intent",
+ "observed": {
+ "cleaned": "astronomy picture of the day",
+ "inputs": {},
+ "steps": [],
+ "trigger": "astronomy picture of the day"
+ },
+ "summary": "'astronomy picture of the day'; apod via 'astronomy picture of the day' (24 literal chars)"
+ },
+ {
+ "depth": 0,
+ "kind": "observe",
+ "next": "status",
+ "node": "fetch",
+ "observed": {
+ "about": "Sometimes it's night on the ground but day in the air.",
+ "day": "2026-10-28",
+ "kind": "image",
+ "media": "https://apod.nasa.gov/apod/image/2610/example_1024.jpg",
+ "title": "Day and Night over the Clouds"
+ },
+ "summary": "GET https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY -> 200; title=Day and Night over the Clouds, day=2026-10-28, media=https://apod.nasa.gov/apod/image/2610/example_1024.jpg, kind=image, about=Sometimes it's night on the ground but day in the air."
+ },
+ {
+ "chosen": "eq 200",
+ "depth": 0,
+ "kind": "decide",
+ "next": "reply",
+ "node": "status",
+ "observed": 200,
+ "summary": "state.resp.status = 200"
+ },
+ {
+ "depth": 0,
+ "kind": "done",
+ "next": null,
+ "node": "reply",
+ "summary": "Astronomy Picture of the Day, 2026-10-28: Day and Night over the Clouds. https://apod.nasa.gov/apod/image/2610/example_1024.jpg"
+ }
+ ],
+ "tree": "apod"
+ },
+ "tree": "apod"
+ },
+ "inputs": {},
+ "intent": "astronomy picture of the day",
+ "tree": "apod"
+ }
+ ],
+ "trees": {
+ "advice": {
+ "description": "A random piece of advice, from the Advice Slip API.",
+ "hosts": [
+ "api.adviceslip.com"
+ ],
+ "license": "MIT",
+ "name": "advice",
+ "namespace": "pyrtan",
+ "nodes": {
+ "fetch": {
+ "extract": [
+ {
+ "into": "advice",
+ "json": "json.slip.advice"
+ },
+ {
+ "into": "id",
+ "json": "json.slip.id"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://api.adviceslip.com/advice"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "has-advice": {
+ "branches": [
+ {
+ "then": "reply",
+ "when": {
+ "exists": true
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "advice"
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "advice": "{{ state.advice }}",
+ "id": "{{ state.id }}"
+ },
+ "say": "{{ state.advice }}"
+ },
+ "status": {
+ "branches": [
+ {
+ "then": "has-advice",
+ "when": {
+ "eq": 200
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "resp.status"
+ },
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-status",
+ "say": "The Advice Slip API answered {{ state.resp.status }}."
+ }
+ },
+ "start": "fetch",
+ "tags": [
+ "fun",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "give me [some] advice"
+ },
+ {
+ "pattern": "[any] advice"
+ }
+ ],
+ "version": 1
+ },
+ "air-quality": {
+ "description": "Current air quality for a place: the European AQI, its band, and PM2.5 and PM10, from Open-Meteo.",
+ "hosts": [
+ "geocoding-api.open-meteo.com",
+ "air-quality-api.open-meteo.com"
+ ],
+ "inputs": {
+ "place": {
+ "description": "A town or city name",
+ "type": "str"
+ }
+ },
+ "license": "MIT",
+ "name": "air-quality",
+ "namespace": "pyrtan",
+ "nodes": {
+ "air": {
+ "extract": [
+ {
+ "into": "aqi",
+ "json": "json.current.european_aqi"
+ },
+ {
+ "into": "pm25",
+ "json": "json.current.pm2_5"
+ },
+ {
+ "into": "pm10",
+ "json": "json.current.pm10"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://air-quality-api.open-meteo.com/v1/air-quality?latitude={{ state.lat }}&longitude={{ state.lon }}&current=european_aqi,pm2_5,pm10"
+ },
+ "into": "aq",
+ "kind": "observe",
+ "next": "band"
+ },
+ "band": {
+ "branches": [
+ {
+ "then": "good",
+ "when": {
+ "lte": 20,
+ "type": "number"
+ }
}, { "then": "fair", "when": {
- "lte": 40
+ "lte": 40
+ }
+ },
+ {
+ "then": "moderate",
+ "when": {
+ "lte": 60
+ }
+ },
+ {
+ "then": "poor",
+ "when": {
+ "lte": 80
+ }
+ },
+ {
+ "then": "very-poor",
+ "when": {
+ "lte": 100
+ }
+ },
+ {
+ "then": "extremely-poor",
+ "when": {
+ "gt": 100
+ }
+ },
+ {
+ "else": "no-reading"
+ }
+ ],
+ "kind": "decide",
+ "on": "aqi"
+ },
+ "extremely-poor": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "band": "extremely poor"
+ }
+ },
+ "fair": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "band": "fair"
+ }
+ },
+ "found": {
+ "branches": [
+ {
+ "then": "air",
+ "when": {
+ "exists": true
+ }
+ },
+ {
+ "else": "unknown-place"
+ }
+ ],
+ "kind": "decide",
+ "on": "hit"
+ },
+ "geocode": {
+ "extract": [
+ {
+ "into": "hit",
+ "json": "json.results[0]"
+ },
+ {
+ "into": "lat",
+ "json": "json.results[0].latitude"
+ },
+ {
+ "into": "lon",
+ "json": "json.results[0].longitude"
+ },
+ {
+ "into": "name",
+ "json": "json.results[0].name"
+ },
+ {
+ "into": "country",
+ "json": "json.results[0].country"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json"
+ },
+ "into": "geo",
+ "kind": "observe",
+ "next": "found"
+ },
+ "good": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "band": "good"
+ }
+ },
+ "moderate": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "band": "moderate"
+ }
+ },
+ "no-reading": {
+ "kind": "fail",
+ "reason": "no-reading",
+ "say": "Open-Meteo found {{ state.name }} but gave no air quality reading (status {{ state.aq.status }})."
+ },
+ "poor": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "band": "poor"
+ }
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "aqi": "{{ state.aqi }}",
+ "band": "{{ state.band }}",
+ "country": "{{ state.country }}",
+ "place": "{{ state.name }}",
+ "pm10": "{{ state.pm10 }}",
+ "pm2_5": "{{ state.pm25 }}"
+ },
+ "say": "{{ state.name }}, {{ state.country }}: air quality {{ state.band }} (European AQI {{ state.aqi }}), PM2.5 {{ state.pm25 }} µg/m³, PM10 {{ state.pm10 }} µg/m³."
+ },
+ "unknown-place": {
+ "kind": "fail",
+ "reason": "unknown-place",
+ "say": "I could not find a place called {{ inputs.place }}."
+ },
+ "very-poor": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "band": "very poor"
+ }
+ }
+ },
+ "start": "geocode",
+ "tags": [
+ "weather",
+ "health",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "[what's the] air quality (in|for) {place}"
+ },
+ {
+ "pattern": "how is the air in {place}"
+ },
+ {
+ "pattern": "(aqi|pollution) in {place}"
+ }
+ ],
+ "version": 1
+ },
+ "apod": {
+ "description": "NASA's Astronomy Picture of the Day: its title, and the picture or video.",
+ "hosts": [
+ "api.nasa.gov"
+ ],
+ "license": "MIT",
+ "name": "apod",
+ "namespace": "pyrtan",
+ "nodes": {
+ "fetch": {
+ "extract": [
+ {
+ "into": "title",
+ "json": "json.title"
+ },
+ {
+ "into": "day",
+ "json": "json.date"
+ },
+ {
+ "into": "media",
+ "json": "json.url"
+ },
+ {
+ "into": "kind",
+ "json": "json.media_type"
+ },
+ {
+ "into": "about",
+ "json": "json.explanation"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "rate-limited": {
+ "kind": "fail",
+ "reason": "rate-limited",
+ "say": "NASA's demo key has used up its hour from this address; try again later, or see https://apod.nasa.gov/apod/astropix.html"
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "date": "{{ state.day }}",
+ "explanation": "{{ state.about }}",
+ "media_type": "{{ state.kind }}",
+ "page": "https://apod.nasa.gov/apod/astropix.html",
+ "title": "{{ state.title }}",
+ "url": "{{ state.media }}"
+ },
+ "say": "Astronomy Picture of the Day, {{ state.day }}: {{ state.title }}. {{ state.media }}"
+ },
+ "status": {
+ "branches": [
+ {
+ "then": "reply",
+ "when": {
+ "eq": 200
+ }
+ },
+ {
+ "then": "rate-limited",
+ "when": {
+ "eq": 429
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "resp.status"
+ },
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-status",
+ "say": "NASA's API answered {{ state.resp.status }}."
+ }
+ },
+ "start": "fetch",
+ "tags": [
+ "space",
+ "science",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "astronomy picture of the day"
+ },
+ {
+ "pattern": "[nasa] (apod|space picture) [of the day|today]"
+ }
+ ],
+ "version": 1
+ },
+ "books": {
+ "description": "A book from Open Library: the best match for a title or an author, who wrote it, and when it first came out.",
+ "hosts": [
+ "openlibrary.org"
+ ],
+ "inputs": {
+ "title": {
+ "description": "A title or an author, as in: The Left Hand of Darkness",
+ "type": "str"
+ }
+ },
+ "license": "MIT",
+ "name": "books",
+ "namespace": "pyrtan",
+ "nodes": {
+ "any": {
+ "branches": [
+ {
+ "then": "reply",
+ "when": {
+ "exists": true
+ }
+ },
+ {
+ "else": "no-book"
+ }
+ ],
+ "kind": "decide",
+ "on": "book"
+ },
+ "no-book": {
+ "kind": "fail",
+ "reason": "no-book",
+ "say": "Open Library has nothing matching {{ inputs.title }}."
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "author": "{{ state.author }}",
+ "first_published": "{{ state.year }}",
+ "matches": "{{ state.found }}",
+ "title": "{{ state.book }}",
+ "url": "https://openlibrary.org{{ state.key }}"
+ },
+ "say": "{{ state.book }}, by {{ state.author }} ({{ state.year }}). https://openlibrary.org{{ state.key }}"
+ },
+ "search": {
+ "extract": [
+ {
+ "into": "found",
+ "json": "json.numFound"
+ },
+ {
+ "into": "book",
+ "json": "json.docs[0].title"
+ },
+ {
+ "into": "author",
+ "json": "json.docs[0].author_name[0]"
+ },
+ {
+ "into": "year",
+ "json": "json.docs[0].first_publish_year"
+ },
+ {
+ "into": "key",
+ "json": "json.docs[0].key"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://openlibrary.org/search.json?q={{ inputs.title | url }}&limit=1&fields=title,author_name,first_publish_year,key"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "status": {
+ "branches": [
+ {
+ "then": "any",
+ "when": {
+ "eq": 200
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "resp.status"
+ },
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-status",
+ "say": "Open Library answered {{ state.resp.status }}."
+ }
+ },
+ "start": "search",
+ "tags": [
+ "reference",
+ "books",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "who wrote {title}"
+ },
+ {
+ "pattern": "find the book {title}"
+ },
+ {
+ "pattern": "books by {title}"
+ }
+ ],
+ "version": 1
+ },
+ "cat-fact": {
+ "description": "A random fact about cats, from catfact.ninja.",
+ "hosts": [
+ "catfact.ninja"
+ ],
+ "license": "MIT",
+ "name": "cat-fact",
+ "namespace": "pyrtan",
+ "nodes": {
+ "fetch": {
+ "extract": [
+ {
+ "into": "fact",
+ "json": "json.fact"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://catfact.ninja/fact"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "has-fact": {
+ "branches": [
+ {
+ "then": "reply",
+ "when": {
+ "exists": true
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "fact"
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "fact": "{{ state.fact }}"
+ },
+ "say": "{{ state.fact }}"
+ },
+ "status": {
+ "branches": [
+ {
+ "then": "has-fact",
+ "when": {
+ "eq": 200
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "resp.status"
+ },
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-status",
+ "say": "catfact.ninja answered {{ state.resp.status }}."
+ }
+ },
+ "start": "fetch",
+ "tags": [
+ "fun",
+ "animals",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "[tell me a] cat fact"
+ },
+ {
+ "pattern": "tell me something about cats"
+ }
+ ],
+ "version": 1
+ },
+ "dad-joke": {
+ "description": "A random dad joke, from icanhazdadjoke.com.",
+ "hosts": [
+ "icanhazdadjoke.com"
+ ],
+ "license": "MIT",
+ "name": "dad-joke",
+ "namespace": "pyrtan",
+ "nodes": {
+ "fetch": {
+ "extract": [
+ {
+ "into": "joke",
+ "json": "json.joke"
+ },
+ {
+ "into": "id",
+ "json": "json.id"
+ }
+ ],
+ "http": {
+ "headers": {
+ "Accept": "application/json"
+ },
+ "method": "GET",
+ "url": "https://icanhazdadjoke.com/"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "has-joke": {
+ "branches": [
+ {
+ "then": "reply",
+ "when": {
+ "exists": true
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "joke"
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "joke": "{{ state.joke }}",
+ "url": "https://icanhazdadjoke.com/j/{{ state.id }}"
+ },
+ "say": "{{ state.joke }}"
+ },
+ "status": {
+ "branches": [
+ {
+ "then": "has-joke",
+ "when": {
+ "eq": 200
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "resp.status"
+ },
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-status",
+ "say": "icanhazdadjoke.com answered {{ state.resp.status }}."
+ }
+ },
+ "start": "fetch",
+ "tags": [
+ "fun",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "tell me a [dad] joke"
+ },
+ {
+ "pattern": "[a] dad joke"
+ },
+ {
+ "pattern": "make me laugh"
+ }
+ ],
+ "version": 1
+ },
+ "define": {
+ "description": "What an English word means: its first definition, part of speech and pronunciation.",
+ "hosts": [
+ "api.dictionaryapi.dev"
+ ],
+ "inputs": {
+ "word": {
+ "description": "One English word",
+ "pattern": "^[A-Za-z][A-Za-z'\\-]*$",
+ "type": "str"
+ }
+ },
+ "license": "MIT",
+ "name": "define",
+ "namespace": "pyrtan",
+ "nodes": {
+ "fetch": {
+ "extract": [
+ {
+ "into": "word",
+ "json": "json[0].word"
+ },
+ {
+ "into": "phonetic",
+ "json": "json[0].phonetic"
+ },
+ {
+ "into": "part",
+ "json": "json[0].meanings[0].partOfSpeech"
+ },
+ {
+ "into": "definition",
+ "json": "json[0].meanings[0].definitions[0].definition"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://api.dictionaryapi.dev/api/v2/entries/en/{{ inputs.word | lower | url }}"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "has-definition": {
+ "branches": [
+ {
+ "then": "reply",
+ "when": {
+ "exists": true
+ }
+ },
+ {
+ "else": "unknown-word"
+ }
+ ],
+ "kind": "decide",
+ "on": "definition"
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "definition": "{{ state.definition }}",
+ "part_of_speech": "{{ state.part }}",
+ "phonetic": "{{ state.phonetic }}",
+ "word": "{{ state.word }}"
+ },
+ "say": "{{ state.word }} ({{ state.part }}): {{ state.definition }}"
+ },
+ "status": {
+ "branches": [
+ {
+ "then": "has-definition",
+ "when": {
+ "eq": 200
+ }
+ },
+ {
+ "then": "unknown-word",
+ "when": {
+ "eq": 404
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "resp.status"
+ },
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-status",
+ "say": "The dictionary answered {{ state.resp.status }}."
+ },
+ "unknown-word": {
+ "kind": "fail",
+ "reason": "unknown-word",
+ "say": "The dictionary has no entry for {{ inputs.word }}."
+ }
+ },
+ "start": "fetch",
+ "tags": [
+ "reference",
+ "language",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "define {word:word}"
+ },
+ {
+ "pattern": "what does {word:word} mean"
+ },
+ {
+ "pattern": "[the] (meaning|definition) of {word:word}"
+ }
+ ],
+ "version": 1
+ },
+ "disk-space": {
+ "commands": [
+ "df"
+ ],
+ "description": "How full a filesystem is, from df on the box this tree runs on.",
+ "inputs": {
+ "mount": {
+ "default": "/",
+ "description": "A mount point, or any path on the filesystem to measure",
+ "type": "str"
+ }
+ },
+ "license": "MIT",
+ "name": "disk-space",
+ "namespace": "pyrtan",
+ "nodes": {
+ "df": {
+ "extract": [
+ {
+ "group": 1,
+ "into": "percent",
+ "regex": "(\\d+)%"
+ },
+ {
+ "group": 1,
+ "into": "mounted",
+ "regex": "%\\s+(\\S+)\\s*$"
+ }
+ ],
+ "into": "df",
+ "kind": "observe",
+ "next": "ran",
+ "shell": {
+ "args": [
+ "-P",
+ "{{ inputs.mount }}"
+ ],
+ "command": "df"
+ }
+ },
+ "failed": {
+ "kind": "fail",
+ "reason": "df-failed",
+ "say": "df could not measure {{ inputs.mount }}: {{ state.df.stderr | strip }}"
+ },
+ "fine": {
+ "kind": "done",
+ "output": {
+ "mounted": "{{ state.mounted }}",
+ "percent": "{{ state.pct }}"
+ },
+ "say": "{{ state.mounted }} is {{ state.pct }}% full."
+ },
+ "full": {
+ "kind": "fail",
+ "reason": "disk-full",
+ "say": "{{ state.mounted }} is {{ state.pct }}% full."
+ },
+ "level": {
+ "branches": [
+ {
+ "then": "fine",
+ "when": {
+ "lt": 80
+ }
+ },
+ {
+ "then": "tight",
+ "when": {
+ "lt": 95
+ }
+ },
+ {
+ "else": "full"
+ }
+ ],
+ "kind": "decide",
+ "on": "pct"
+ },
+ "number": {
+ "kind": "set",
+ "next": "level",
+ "set": {
+ "pct": "{{ state.percent | int }}"
+ }
+ },
+ "ran": {
+ "branches": [
+ {
+ "then": "number",
+ "when": {
+ "eq": true
+ }
+ },
+ {
+ "else": "failed"
+ }
+ ],
+ "kind": "decide",
+ "on": "df.ok"
+ },
+ "tight": {
+ "kind": "done",
+ "output": {
+ "mounted": "{{ state.mounted }}",
+ "percent": "{{ state.pct }}"
+ },
+ "say": "{{ state.mounted }} is {{ state.pct }}% full; make room soon."
+ }
+ },
+ "start": "df",
+ "tags": [
+ "ops",
+ "box",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "[how] full is the disk"
+ },
+ {
+ "pattern": "disk space"
+ },
+ {
+ "pattern": "disk space on {mount:word}"
+ }
+ ],
+ "version": 2
+ },
+ "earthquakes": {
+ "description": "Magnitude 4.5+ earthquakes worldwide in the past day, from the USGS: how many, and the latest.",
+ "hosts": [
+ "earthquake.usgs.gov"
+ ],
+ "license": "MIT",
+ "name": "earthquakes",
+ "namespace": "pyrtan",
+ "nodes": {
+ "any": {
+ "branches": [
+ {
+ "then": "none",
+ "when": {
+ "eq": 0
+ }
+ },
+ {
+ "then": "reply",
+ "when": {
+ "gt": 0
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "count"
+ },
+ "fetch": {
+ "extract": [
+ {
+ "into": "count",
+ "json": "json.metadata.count"
+ },
+ {
+ "into": "mag",
+ "json": "json.features[0].properties.mag"
+ },
+ {
+ "into": "place",
+ "json": "json.features[0].properties.place"
+ },
+ {
+ "into": "tsunami",
+ "json": "json.features[0].properties.tsunami"
+ },
+ {
+ "into": "url",
+ "json": "json.features[0].properties.url"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "none": {
+ "kind": "done",
+ "output": {
+ "count": 0
+ },
+ "say": "No earthquakes of magnitude 4.5+ anywhere in the past day."
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "count": "{{ state.count }}",
+ "latest": {
+ "magnitude": "{{ state.mag }}",
+ "place": "{{ state.place }}",
+ "tsunami": "{{ state.tsunami }}",
+ "url": "{{ state.url }}"
+ }
+ },
+ "say": "{{ state.count }} earthquake(s) of magnitude 4.5+ in the past day. The latest: M{{ state.mag }}, {{ state.place }}. {{ state.url }}"
+ },
+ "status": {
+ "branches": [
+ {
+ "then": "any",
+ "when": {
+ "eq": 200
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "resp.status"
+ },
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-status",
+ "say": "The USGS feed answered {{ state.resp.status }}."
+ }
+ },
+ "start": "fetch",
+ "tags": [
+ "science",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "[any] [recent] earthquakes [today]"
+ },
+ {
+ "pattern": "[was there an] earthquake today"
+ },
+ {
+ "pattern": "latest earthquake"
+ }
+ ],
+ "version": 1
+ },
+ "elevation": {
+ "description": "How high a place is above sea level, from Open-Meteo's geocoder.",
+ "hosts": [
+ "geocoding-api.open-meteo.com"
+ ],
+ "inputs": {
+ "place": {
+ "description": "A town or city name",
+ "type": "str"
+ }
+ },
+ "license": "MIT",
+ "name": "elevation",
+ "namespace": "pyrtan",
+ "nodes": {
+ "found": {
+ "branches": [
+ {
+ "then": "has-height",
+ "when": {
+ "exists": true
} }, {
- "then": "moderate",
+ "else": "unknown-place"
+ }
+ ],
+ "kind": "decide",
+ "on": "hit"
+ },
+ "geocode": {
+ "extract": [
+ {
+ "into": "hit",
+ "json": "json.results[0]"
+ },
+ {
+ "into": "lat",
+ "json": "json.results[0].latitude"
+ },
+ {
+ "into": "lon",
+ "json": "json.results[0].longitude"
+ },
+ {
+ "into": "name",
+ "json": "json.results[0].name"
+ },
+ {
+ "into": "country",
+ "json": "json.results[0].country"
+ },
+ {
+ "into": "zone",
+ "json": "json.results[0].timezone"
+ },
+ {
+ "into": "height",
+ "json": "json.results[0].elevation"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json"
+ },
+ "into": "geo",
+ "kind": "observe",
+ "next": "found"
+ },
+ "has-height": {
+ "branches": [
+ {
+ "then": "reply",
"when": {
- "lte": 60
+ "type": "number"
} }, {
- "then": "poor",
+ "else": "no-height"
+ }
+ ],
+ "kind": "decide",
+ "on": "height"
+ },
+ "no-height": {
+ "kind": "fail",
+ "reason": "no-height",
+ "say": "Open-Meteo knows {{ state.name }} but not how high it is."
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "country": "{{ state.country }}",
+ "elevation_m": "{{ state.height }}",
+ "place": "{{ state.name }}"
+ },
+ "say": "{{ state.name }}, {{ state.country }} is {{ state.height | int }} m above sea level."
+ },
+ "unknown-place": {
+ "kind": "fail",
+ "reason": "unknown-place",
+ "say": "I could not find a place called {{ inputs.place }}."
+ }
+ },
+ "start": "geocode",
+ "tags": [
+ "geography",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "how high (is|above sea level is) {place}"
+ },
+ {
+ "pattern": "[the] (elevation|altitude) of {place}"
+ }
+ ],
+ "version": 1
+ },
+ "exchange-rate": {
+ "description": "An amount in another currency at today's European Central Bank reference rate, from Frankfurter.",
+ "hosts": [
+ "api.frankfurter.dev"
+ ],
+ "inputs": {
+ "amount": {
+ "default": "1",
+ "description": "How much of the first currency, as in: 100 or 2.5",
+ "pattern": "^[0-9]+(\\.[0-9]+)?$",
+ "type": "str"
+ },
+ "from": {
+ "description": "A three-letter currency code, as in: USD",
+ "pattern": "^[A-Za-z]{3}$",
+ "type": "str"
+ },
+ "to": {
+ "description": "A three-letter currency code, as in: EUR",
+ "pattern": "^[A-Za-z]{3}$",
+ "type": "str"
+ }
+ },
+ "license": "MIT",
+ "name": "exchange-rate",
+ "namespace": "pyrtan",
+ "nodes": {
+ "fetch": {
+ "extract": [
+ {
+ "into": "value",
+ "regex": "\"rates\":\\s*\\{\\s*\"[A-Z]{3}\":\\s*(-?[0-9.]+(?:[eE][-+]?[0-9]+)?)\\s*\\}"
+ },
+ {
+ "into": "day",
+ "json": "json.date"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://api.frankfurter.dev/v1/latest?base={{ inputs.from | upper }}&symbols={{ inputs.to | upper }}&amount={{ inputs.amount }}"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "has-rate": {
+ "branches": [
+ {
+ "then": "reply",
"when": {
- "lte": 80
+ "exists": true
} }, {
- "then": "very-poor",
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "value"
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "amount": "{{ inputs.amount | float }}",
+ "date": "{{ state.day }}",
+ "from": "{{ inputs.from | upper }}",
+ "to": "{{ inputs.to | upper }}",
+ "value": "{{ state.value | float }}"
+ },
+ "say": "{{ inputs.amount }} {{ inputs.from | upper }} is {{ state.value }} {{ inputs.to | upper }} at the ECB rate of {{ state.day }}."
+ },
+ "same-currency": {
+ "kind": "fail",
+ "reason": "same-currency",
+ "say": "{{ inputs.from | upper }} to {{ inputs.to | upper }} is the same currency."
+ },
+ "status": {
+ "branches": [
+ {
+ "then": "has-rate",
"when": {
- "lte": 100
+ "eq": 200
} }, {
- "then": "extremely-poor",
+ "then": "unknown-currency",
"when": {
- "gt": 100
+ "eq": 404
} }, {
- "else": "no-reading"
+ "then": "same-currency",
+ "when": {
+ "eq": 422
+ }
+ },
+ {
+ "else": "unexpected"
} ], "kind": "decide",
- "on": "aqi"
+ "on": "resp.status"
+ },
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-status",
+ "say": "Frankfurter answered {{ state.resp.status }}."
+ },
+ "unknown-currency": {
+ "kind": "fail",
+ "reason": "unknown-currency",
+ "say": "The ECB publishes no rate between {{ inputs.from | upper }} and {{ inputs.to | upper }}; it covers about thirty currencies, by their three-letter codes."
+ }
+ },
+ "start": "fetch",
+ "tags": [
+ "money",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "convert {amount:number} {from:word} (to|into|in) {to:word}"
+ },
+ {
+ "pattern": "how much is {amount:number} {from:word} in {to:word}"
+ },
+ {
+ "pattern": "[the] exchange rate (from|for) {from:word} to {to:word}"
+ },
+ {
+ "pattern": "{from:word} to {to:word} (rate|exchange rate)"
+ }
+ ],
+ "version": 1
+ },
+ "forecast": {
+ "description": "Tomorrow's weather for a place: sky, high and low, and chance of rain, from Open-Meteo.",
+ "hosts": [
+ "geocoding-api.open-meteo.com",
+ "api.open-meteo.com"
+ ],
+ "inputs": {
+ "place": {
+ "description": "A town or city name",
+ "type": "str"
+ }
+ },
+ "license": "MIT",
+ "name": "forecast",
+ "namespace": "pyrtan",
+ "nodes": {
+ "clear": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "sky": "clear"
+ }
},
- "extremely-poor": {
+ "cloudy": {
"kind": "set", "next": "reply", "set": {
- "band": "extremely poor"
+ "sky": "cloudy"
} },
- "fair": {
+ "fog": {
"kind": "set", "next": "reply", "set": {
- "band": "fair"
+ "sky": "foggy"
} }, "found": { "branches": [ {
- "then": "air",
+ "then": "tomorrow",
"when": { "exists": true }
@@ -11345,6 +15331,14 @@
{ "into": "country", "json": "json.results[0].country"
+ },
+ {
+ "into": "zone",
+ "json": "json.results[0].timezone"
+ },
+ {
+ "into": "height",
+ "json": "json.results[0].elevation"
} ], "http": {
@@ -11355,161 +15349,437 @@
"kind": "observe", "next": "found" },
- "good": {
+ "have-day": {
+ "branches": [
+ {
+ "then": "sky",
+ "when": {
+ "type": "number"
+ }
+ },
+ {
+ "else": "no-forecast"
+ }
+ ],
+ "kind": "decide",
+ "on": "high"
+ },
+ "no-forecast": {
+ "kind": "fail",
+ "reason": "no-forecast",
+ "say": "Open-Meteo found {{ state.name }} but gave no forecast for tomorrow (status {{ state.fc.status }})."
+ },
+ "rain": {
"kind": "set", "next": "reply", "set": {
- "band": "good"
+ "sky": "rain"
} },
- "moderate": {
+ "reply": {
+ "kind": "done",
+ "output": {
+ "country": "{{ state.country }}",
+ "date": "{{ state.day }}",
+ "high": "{{ state.high }}",
+ "low": "{{ state.low }}",
+ "place": "{{ state.name }}",
+ "rain_chance": "{{ state.rain }}",
+ "sky": "{{ state.sky }}",
+ "unit": "{{ state.unit }}"
+ },
+ "say": "{{ state.name }}, {{ state.country }}, tomorrow ({{ state.day }}): {{ state.sky }}, {{ state.low }} to {{ state.high }}{{ state.unit }}, {{ state.rain }}% chance of rain."
+ },
+ "sky": {
+ "branches": [
+ {
+ "then": "clear",
+ "when": {
+ "eq": 0
+ }
+ },
+ {
+ "then": "cloudy",
+ "when": {
+ "between": [
+ 1,
+ 3
+ ]
+ }
+ },
+ {
+ "then": "fog",
+ "when": {
+ "between": [
+ 45,
+ 48
+ ]
+ }
+ },
+ {
+ "then": "rain",
+ "when": {
+ "between": [
+ 51,
+ 67
+ ]
+ }
+ },
+ {
+ "then": "snow",
+ "when": {
+ "between": [
+ 71,
+ 77
+ ]
+ }
+ },
+ {
+ "then": "rain",
+ "when": {
+ "between": [
+ 80,
+ 82
+ ]
+ }
+ },
+ {
+ "then": "snow",
+ "when": {
+ "between": [
+ 85,
+ 86
+ ]
+ }
+ },
+ {
+ "then": "storm",
+ "when": {
+ "between": [
+ 95,
+ 99
+ ]
+ }
+ },
+ {
+ "else": "unknown-sky"
+ }
+ ],
+ "kind": "decide",
+ "on": "code"
+ },
+ "snow": {
"kind": "set", "next": "reply", "set": {
- "band": "moderate"
+ "sky": "snow"
} },
- "no-reading": {
+ "storm": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "sky": "storms"
+ }
+ },
+ "tomorrow": {
+ "extract": [
+ {
+ "into": "day",
+ "json": "json.daily.time[1]"
+ },
+ {
+ "into": "code",
+ "json": "json.daily.weather_code[1]"
+ },
+ {
+ "into": "high",
+ "json": "json.daily.temperature_2m_max[1]"
+ },
+ {
+ "into": "low",
+ "json": "json.daily.temperature_2m_min[1]"
+ },
+ {
+ "into": "rain",
+ "json": "json.daily.precipitation_probability_max[1]"
+ },
+ {
+ "into": "unit",
+ "json": "json.daily_units.temperature_2m_max"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=2"
+ },
+ "into": "fc",
+ "kind": "observe",
+ "next": "have-day"
+ },
+ "unknown-place": {
"kind": "fail",
- "reason": "no-reading",
- "say": "Open-Meteo found {{ state.name }} but gave no air quality reading (status {{ state.aq.status }})."
+ "reason": "unknown-place",
+ "say": "I could not find a place called {{ inputs.place }}."
},
- "poor": {
+ "unknown-sky": {
"kind": "set", "next": "reply", "set": {
- "band": "poor"
+ "sky": "weather code {{ state.code }}"
}
+ }
+ },
+ "start": "geocode",
+ "tags": [
+ "weather",
+ "read-only"
+ ],
+ "triggers": [
+ {
+ "pattern": "[what's the] weather (in|for) {place} tomorrow"
},
- "reply": {
- "kind": "done",
- "output": {
- "aqi": "{{ state.aqi }}",
- "band": "{{ state.band }}",
- "country": "{{ state.country }}",
- "place": "{{ state.name }}",
- "pm10": "{{ state.pm10 }}",
- "pm2_5": "{{ state.pm25 }}"
+ {
+ "pattern": "[what's the] weather tomorrow (in|for) {place}"
+ },
+ {
+ "pattern": "will it rain tomorrow in {place}"
+ }
+ ],
+ "version": 1
+ },
+ "github-release": {
+ "description": "The latest release of a GitHub repository.",
+ "hosts": [
+ "api.github.com"
+ ],
+ "inputs": {
+ "repo": {
+ "description": "owner/name",
+ "pattern": "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$",
+ "type": "str"
+ }
+ },
+ "license": "MIT",
+ "name": "github-release",
+ "namespace": "pyrtan",
+ "nodes": {
+ "fetch": {
+ "extract": [
+ {
+ "into": "tag",
+ "json": "json.tag_name"
+ },
+ {
+ "into": "name",
+ "json": "json.name"
+ },
+ {
+ "into": "published",
+ "json": "json.published_at"
+ },
+ {
+ "into": "url",
+ "json": "json.html_url"
+ },
+ {
+ "into": "prerelease",
+ "json": "json.prerelease"
+ }
+ ],
+ "http": {
+ "headers": {
+ "Accept": "application/vnd.github+json"
+ },
+ "method": "GET",
+ "url": "https://api.github.com/repos/{{ inputs.repo }}/releases/latest"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "none": {
+ "kind": "fail",
+ "reason": "no-release",
+ "say": "{{ inputs.repo }} has no releases, or does not exist."
+ },
+ "rate-limited": {
+ "kind": "fail",
+ "reason": "rate-limited",
+ "say": "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "name": "{{ state.name }}",
+ "prerelease": "{{ state.prerelease }}",
+ "published": "{{ state.published }}",
+ "repo": "{{ inputs.repo }}",
+ "tag": "{{ state.tag }}",
+ "url": "{{ state.url }}"
},
- "say": "{{ state.name }}, {{ state.country }}: air quality {{ state.band }} (European AQI {{ state.aqi }}), PM2.5 {{ state.pm25 }} µg/m³, PM10 {{ state.pm10 }} µg/m³."
+ "say": "{{ inputs.repo }}: {{ state.tag }} ({{ state.published }}) {{ state.url }}"
},
- "unknown-place": {
- "kind": "fail",
- "reason": "unknown-place",
- "say": "I could not find a place called {{ inputs.place }}."
+ "status": {
+ "branches": [
+ {
+ "then": "reply",
+ "when": {
+ "eq": 200
+ }
+ },
+ {
+ "then": "none",
+ "when": {
+ "eq": 404
+ }
+ },
+ {
+ "then": "rate-limited",
+ "when": {
+ "in": [
+ 403,
+ 429
+ ]
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "resp.status"
},
- "very-poor": {
- "kind": "set",
- "next": "reply",
- "set": {
- "band": "very poor"
- }
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-response",
+ "say": "GitHub answered {{ state.resp.status }}."
} },
- "start": "geocode",
+ "start": "fetch",
"tags": [
- "weather",
- "health",
+ "github",
"read-only" ], "triggers": [ {
- "pattern": "[what's the] air quality (in|for) {place}"
- },
- {
- "pattern": "how is the air in {place}"
+ "pattern": "[what's the] latest release of {repo:word}"
}, {
- "pattern": "(aqi|pollution) in {place}"
+ "pattern": "latest {repo:word} release"
} ],
- "version": 1
+ "version": 2
},
- "books": {
- "description": "A book from Open Library: the best match for a title or an author, who wrote it, and when it first came out.",
+ "github-repo": {
+ "description": "A GitHub repository at a glance: description, language, stars, forks, open issues.",
"hosts": [
- "openlibrary.org"
+ "api.github.com"
], "inputs": {
- "title": {
- "description": "A title or an author, as in: The Left Hand of Darkness",
+ "repo": {
+ "description": "owner/name, as in: astral-sh/uv",
+ "pattern": "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$",
"type": "str" } }, "license": "MIT",
- "name": "books",
+ "name": "github-repo",
"namespace": "pyrtan", "nodes": {
- "any": {
- "branches": [
+ "fetch": {
+ "extract": [
{
- "then": "reply",
- "when": {
- "exists": true
- }
+ "into": "full",
+ "json": "json.full_name"
}, {
- "else": "no-book"
- }
- ],
- "kind": "decide",
- "on": "book"
- },
- "no-book": {
- "kind": "fail",
- "reason": "no-book",
- "say": "Open Library has nothing matching {{ inputs.title }}."
- },
- "reply": {
- "kind": "done",
- "output": {
- "author": "{{ state.author }}",
- "first_published": "{{ state.year }}",
- "matches": "{{ state.found }}",
- "title": "{{ state.book }}",
- "url": "https://openlibrary.org{{ state.key }}"
- },
- "say": "{{ state.book }}, by {{ state.author }} ({{ state.year }}). https://openlibrary.org{{ state.key }}"
- },
- "search": {
- "extract": [
+ "into": "about",
+ "json": "json.description"
+ },
{
- "into": "found",
- "json": "json.numFound"
+ "into": "language",
+ "json": "json.language"
}, {
- "into": "book",
- "json": "json.docs[0].title"
+ "into": "stars",
+ "json": "json.stargazers_count"
}, {
- "into": "author",
- "json": "json.docs[0].author_name[0]"
+ "into": "forks",
+ "json": "json.forks_count"
}, {
- "into": "year",
- "json": "json.docs[0].first_publish_year"
+ "into": "issues",
+ "json": "json.open_issues_count"
}, {
- "into": "key",
- "json": "json.docs[0].key"
+ "into": "url",
+ "json": "json.html_url"
+ },
+ {
+ "into": "archived",
+ "json": "json.archived"
} ], "http": {
+ "headers": {
+ "Accept": "application/vnd.github+json"
+ },
"method": "GET",
- "url": "https://openlibrary.org/search.json?q={{ inputs.title | url }}&limit=1&fields=title,author_name,first_publish_year,key"
+ "url": "https://api.github.com/repos/{{ inputs.repo }}"
}, "into": "resp", "kind": "observe", "next": "status" },
+ "no-repo": {
+ "kind": "fail",
+ "reason": "no-repo",
+ "say": "GitHub has no public repository {{ inputs.repo }}."
+ },
+ "rate-limited": {
+ "kind": "fail",
+ "reason": "rate-limited",
+ "say": "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "archived": "{{ state.archived }}",
+ "description": "{{ state.about }}",
+ "forks": "{{ state.forks }}",
+ "language": "{{ state.language }}",
+ "open_issues": "{{ state.issues }}",
+ "repo": "{{ state.full }}",
+ "stars": "{{ state.stars }}",
+ "url": "{{ state.url }}"
+ },
+ "say": "{{ state.full }}: {{ state.about }} ({{ state.language }}) · {{ state.stars }} stars, {{ state.forks }} forks, {{ state.issues }} open issues. {{ state.url }}"
+ },
"status": { "branches": [ {
- "then": "any",
+ "then": "reply",
"when": { "eq": 200 } },
+ {
+ "then": "no-repo",
+ "when": {
+ "eq": 404
+ }
+ },
+ {
+ "then": "rate-limited",
+ "when": {
+ "in": [
+ 403,
+ 429
+ ]
+ }
+ },
{ "else": "unexpected" }
@@ -11520,90 +15790,96 @@
"unexpected": { "kind": "fail", "reason": "unexpected-status",
- "say": "Open Library answered {{ state.resp.status }}."
+ "say": "GitHub answered {{ state.resp.status }}."
} },
- "start": "search",
+ "start": "fetch",
"tags": [
- "reference",
- "books",
+ "github",
+ "software",
"read-only" ], "triggers": [ {
- "pattern": "who wrote {title}"
+ "pattern": "how many stars does {repo:word} have"
}, {
- "pattern": "find the book {title}"
+ "pattern": "github (repo|repository) {repo:word}"
}, {
- "pattern": "books by {title}"
+ "pattern": "tell me about the {repo:word} repo"
} ], "version": 1 },
- "dad-joke": {
- "description": "A random dad joke, from icanhazdadjoke.com.",
+ "go-module": {
+ "description": "The latest version of a Go module, and when it was published, from proxy.golang.org.",
"hosts": [
- "icanhazdadjoke.com"
+ "proxy.golang.org"
],
+ "inputs": {
+ "module": {
+ "description": "A module path, as in: github.com/gorilla/mux",
+ "pattern": "^[a-z0-9.\\-]+(/[A-Za-z0-9._~\\-]+)+$",
+ "type": "str"
+ }
+ },
"license": "MIT",
- "name": "dad-joke",
+ "name": "go-module",
"namespace": "pyrtan", "nodes": { "fetch": { "extract": [ {
- "into": "joke",
- "json": "json.joke"
+ "into": "version",
+ "json": "json.Version"
}, {
- "into": "id",
- "json": "json.id"
+ "from": "json.Time",
+ "into": "day",
+ "regex": "^(\\d{4}-\\d\\d-\\d\\d)"
} ], "http": {
- "headers": {
- "Accept": "application/json"
- },
"method": "GET",
- "url": "https://icanhazdadjoke.com/"
+ "url": "https://proxy.golang.org/{{ inputs.module | lower }}/@latest"
}, "into": "resp", "kind": "observe", "next": "status" },
- "has-joke": {
- "branches": [
- {
- "then": "reply",
- "when": {
- "exists": true
- }
- },
- {
- "else": "unexpected"
- }
- ],
- "kind": "decide",
- "on": "joke"
+ "no-module": {
+ "kind": "fail",
+ "reason": "no-module",
+ "say": "The Go module proxy has no module {{ inputs.module }}."
}, "reply": { "kind": "done", "output": {
- "joke": "{{ state.joke }}",
- "url": "https://icanhazdadjoke.com/j/{{ state.id }}"
+ "module": "{{ inputs.module }}",
+ "published": "{{ state.day }}",
+ "url": "https://pkg.go.dev/{{ inputs.module }}",
+ "version": "{{ state.version }}"
},
- "say": "{{ state.joke }}"
+ "say": "{{ inputs.module }} {{ state.version }}, published {{ state.day }}. https://pkg.go.dev/{{ inputs.module }}"
}, "status": { "branches": [ {
- "then": "has-joke",
+ "then": "reply",
"when": { "eq": 200 } },
+ {
+ "then": "no-module",
+ "when": {
+ "in": [
+ 404,
+ 410
+ ]
+ }
+ },
{ "else": "unexpected" }
@@ -11614,107 +15890,76 @@
"unexpected": { "kind": "fail", "reason": "unexpected-status",
- "say": "icanhazdadjoke.com answered {{ state.resp.status }}."
+ "say": "The Go module proxy answered {{ state.resp.status }}."
} }, "start": "fetch", "tags": [
- "fun",
+ "software",
+ "go",
"read-only" ], "triggers": [ {
- "pattern": "tell me a [dad] joke"
- },
- {
- "pattern": "[a] dad joke"
+ "pattern": "[what's the] latest version of {module:word} on go"
}, {
- "pattern": "make me laugh"
+ "pattern": "go (module|version) {module:word}"
} ], "version": 1 },
- "define": {
- "description": "What an English word means: its first definition, part of speech and pronunciation.",
+ "hacker-news": {
+ "description": "Top stories on Hacker News, from its public API.",
"hosts": [
- "api.dictionaryapi.dev"
+ "hacker-news.firebaseio.com"
], "inputs": {
- "word": {
- "description": "One English word",
- "pattern": "^[A-Za-z][A-Za-z'\\-]*$",
- "type": "str"
+ "count": {
+ "default": 1,
+ "description": "How many stories to return",
+ "max": 30,
+ "min": 1,
+ "type": "int"
} }, "license": "MIT",
- "name": "define",
+ "name": "hacker-news",
"namespace": "pyrtan", "nodes": {
- "fetch": {
- "extract": [
- {
- "into": "word",
- "json": "json[0].word"
- },
- {
- "into": "phonetic",
- "json": "json[0].phonetic"
- },
+ "any": {
+ "branches": [
{
- "into": "part",
- "json": "json[0].meanings[0].partOfSpeech"
+ "then": "nothing",
+ "when": {
+ "empty": true
+ }
}, {
- "into": "definition",
- "json": "json[0].meanings[0].definitions[0].definition"
- }
- ],
- "http": {
- "method": "GET",
- "url": "https://api.dictionaryapi.dev/api/v2/entries/en/{{ inputs.word | lower | url }}"
- },
- "into": "resp",
- "kind": "observe",
- "next": "status"
- },
- "has-definition": {
- "branches": [
- {
- "then": "reply",
+ "then": "stories",
"when": {
- "exists": true
+ "type": "list"
} }, {
- "else": "unknown-word"
+ "else": "unexpected"
} ], "kind": "decide",
- "on": "definition"
- },
- "reply": {
- "kind": "done",
- "output": {
- "definition": "{{ state.definition }}",
- "part_of_speech": "{{ state.part }}",
- "phonetic": "{{ state.phonetic }}",
- "word": "{{ state.word }}"
- },
- "say": "{{ state.word }} ({{ state.part }}): {{ state.definition }}"
+ "on": "ids"
},
- "status": {
+ "check": {
"branches": [ {
- "then": "has-definition",
+ "then": "any",
"when": { "eq": 200 } }, {
- "then": "unknown-word",
+ "then": "hn-down",
"when": {
- "eq": 404
+ "gte": 500
} }, {
@@ -11722,248 +15967,298 @@
} ], "kind": "decide",
- "on": "resp.status"
+ "on": "ids_resp.status"
},
- "unexpected": {
+ "fetch-ids": {
+ "extract": [
+ {
+ "into": "ids",
+ "json": "json"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://hacker-news.firebaseio.com/v0/topstories.json"
+ },
+ "into": "ids_resp",
+ "kind": "observe",
+ "next": "check"
+ },
+ "hn-down": {
"kind": "fail",
- "reason": "unexpected-status",
- "say": "The dictionary answered {{ state.resp.status }}."
+ "reason": "upstream-down",
+ "say": "Hacker News answered {{ state.ids_resp.status }}; try again later."
},
- "unknown-word": {
+ "nothing": {
"kind": "fail",
- "reason": "unknown-word",
- "say": "The dictionary has no entry for {{ inputs.word }}."
+ "reason": "no-stories",
+ "say": "Hacker News returned an empty front page."
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "stories": "{{ state.stories }}"
+ },
+ "say": "Top of Hacker News: {{ state.stories[0].title }} — {{ state.stories[0].url }}"
+ },
+ "stories": {
+ "into": "stories",
+ "kind": "each",
+ "limit": "{{ inputs.count }}",
+ "next": "reply",
+ "over": "ids",
+ "tree": "hacker-news-item",
+ "with": {
+ "id": "{{ item }}"
+ }
+ },
+ "unexpected": {
+ "kind": "fail",
+ "reason": "unexpected-response",
+ "say": "Hacker News answered {{ state.ids_resp.status }} with something this tree does not recognise."
} },
- "start": "fetch",
+ "start": "fetch-ids",
"tags": [
- "reference",
- "language",
+ "news",
"read-only" ], "triggers": [ {
- "pattern": "define {word:word}"
- },
- {
- "pattern": "what does {word:word} mean"
+ "inputs": {
+ "count": 1
+ },
+ "pattern": "[what's the] top story on hacker news"
}, {
- "pattern": "[the] (meaning|definition) of {word:word}"
+ "pattern": "top {count:int} stories on hacker news"
} ],
- "version": 1
+ "version": 2
},
- "disk-space": {
- "commands": [
- "df"
+ "hacker-news-item": {
+ "description": "One Hacker News item by id: title, url, score, author.",
+ "hosts": [
+ "hacker-news.firebaseio.com"
],
- "description": "How full a filesystem is, from df on the box this tree runs on.",
"inputs": {
- "mount": {
- "default": "/",
- "description": "A mount point, or any path on the filesystem to measure",
- "type": "str"
+ "id": {
+ "description": "The item id",
+ "min": 1,
+ "type": "int"
} }, "license": "MIT",
- "name": "disk-space",
+ "name": "hacker-news-item",
"namespace": "pyrtan", "nodes": {
- "df": {
+ "fetch": {
"extract": [ {
- "group": 1,
- "into": "percent",
- "regex": "(\\d+)%"
+ "into": "title",
+ "json": "json.title"
}, {
- "group": 1,
- "into": "mounted",
- "regex": "%\\s+(\\S+)\\s*$"
+ "into": "url",
+ "json": "json.url"
+ },
+ {
+ "into": "score",
+ "json": "json.score"
+ },
+ {
+ "into": "by",
+ "json": "json.by"
+ },
+ {
+ "into": "type",
+ "json": "json.type"
} ],
- "into": "df",
- "kind": "observe",
- "next": "ran",
- "shell": {
- "args": [
- "-P",
- "{{ inputs.mount }}"
- ],
- "command": "df"
- }
- },
- "failed": {
- "kind": "fail",
- "reason": "df-failed",
- "say": "df could not measure {{ inputs.mount }}: {{ state.df.stderr | strip }}"
- },
- "fine": {
- "kind": "done",
- "output": {
- "mounted": "{{ state.mounted }}",
- "percent": "{{ state.pct }}"
+ "http": {
+ "method": "GET",
+ "url": "https://hacker-news.firebaseio.com/v0/item/{{ inputs.id }}.json"
},
- "say": "{{ state.mounted }} is {{ state.pct }}% full."
- },
- "full": {
- "kind": "fail",
- "reason": "disk-full",
- "say": "{{ state.mounted }} is {{ state.pct }}% full."
+ "into": "resp",
+ "kind": "observe",
+ "next": "kind"
},
- "level": {
+ "has-url": {
"branches": [ {
- "then": "fine",
- "when": {
- "lt": 80
- }
- },
- {
- "then": "tight",
+ "then": "reply",
"when": {
- "lt": 95
+ "exists": true
} }, {
- "else": "full"
+ "else": "self-post"
} ], "kind": "decide",
- "on": "pct"
- },
- "number": {
- "kind": "set",
- "next": "level",
- "set": {
- "pct": "{{ state.percent | int }}"
- }
+ "on": "url"
},
- "ran": {
+ "kind": {
"branches": [ {
- "then": "number",
+ "then": "has-url",
"when": {
- "eq": true
+ "eq": "story"
} }, {
- "else": "failed"
+ "then": "has-url",
+ "when": {
+ "eq": "job"
+ }
+ },
+ {
+ "then": "missing",
+ "when": {
+ "exists": false
+ }
+ },
+ {
+ "else": "not-a-story"
} ], "kind": "decide",
- "on": "df.ok"
+ "on": "type"
},
- "tight": {
+ "missing": {
+ "kind": "fail",
+ "reason": "no-such-item",
+ "say": "There is no Hacker News item {{ inputs.id }}."
+ },
+ "not-a-story": {
+ "kind": "fail",
+ "reason": "not-a-story",
+ "say": "Item {{ inputs.id }} is a {{ state.type }}, not a story."
+ },
+ "reply": {
"kind": "done", "output": {
- "mounted": "{{ state.mounted }}",
- "percent": "{{ state.pct }}"
+ "by": "{{ state.by }}",
+ "id": "{{ inputs.id }}",
+ "score": "{{ state.score }}",
+ "title": "{{ state.title }}",
+ "url": "{{ state.url }}"
},
- "say": "{{ state.mounted }} is {{ state.pct }}% full; make room soon."
+ "say": "{{ state.title }} ({{ state.score }} points, by {{ state.by }}) {{ state.url }}"
+ },
+ "self-post": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "url": "https://news.ycombinator.com/item?id={{ inputs.id }}"
+ }
} },
- "start": "df",
+ "start": "fetch",
"tags": [
- "ops",
- "box",
+ "news",
"read-only" ], "triggers": [ {
- "pattern": "[how] full is the disk"
- },
- {
- "pattern": "disk space"
- },
- {
- "pattern": "disk space on {mount:word}"
+ "pattern": "hacker news item {id:int}"
} ], "version": 2 },
- "earthquakes": {
- "description": "Magnitude 4.5+ earthquakes worldwide in the past day, from the USGS: how many, and the latest.",
+ "hn-search": {
+ "description": "Hacker News stories about something, through HN Search: the top three matches.",
"hosts": [
- "earthquake.usgs.gov"
+ "hn.algolia.com"
],
+ "inputs": {
+ "query": {
+ "description": "Words to search story titles and text for",
+ "type": "str"
+ }
+ },
"license": "MIT",
- "name": "earthquakes",
+ "name": "hn-search",
"namespace": "pyrtan", "nodes": { "any": { "branches": [ {
- "then": "none",
- "when": {
- "eq": 0
- }
- },
- {
- "then": "reply",
+ "then": "nothing",
"when": {
- "gt": 0
+ "empty": true
} }, {
- "else": "unexpected"
+ "else": "each"
} ], "kind": "decide",
- "on": "count"
+ "on": "hits"
},
- "fetch": {
+ "each": {
+ "into": "stories",
+ "kind": "each",
+ "limit": 3,
+ "next": "reply",
+ "over": "hits",
+ "start": "one"
+ },
+ "nothing": {
+ "kind": "done",
+ "output": {
+ "query": "{{ inputs.query }}",
+ "stories": [],
+ "total": 0
+ },
+ "say": "No Hacker News stories match {{ inputs.query }}."
+ },
+ "one": {
+ "kind": "done",
+ "output": {
+ "by": "{{ item.author }}",
+ "discussion": "https://news.ycombinator.com/item?id={{ item.objectID }}",
+ "points": "{{ item.points }}",
+ "title": "{{ item.title }}",
+ "url": "{{ item.url }}"
+ }
+ },
+ "reply": {
+ "kind": "done",
+ "output": {
+ "query": "{{ inputs.query }}",
+ "stories": "{{ state.stories }}",
+ "total": "{{ state.total }}"
+ },
+ "say": "{{ state.total }} Hacker News stories match {{ inputs.query }}. Top: {{ state.first }} {{ state.first_url }}"
+ },
+ "search": {
"extract": [ {
- "into": "count",
- "json": "json.metadata.count"
- },
- {
- "into": "mag",
- "json": "json.features[0].properties.mag"
+ "into": "total",
+ "json": "json.nbHits"
}, {
- "into": "place",
- "json": "json.features[0].properties.place"
+ "into": "first",
+ "json": "json.hits[0].title"
}, {
- "into": "tsunami",
- "json": "json.features[0].properties.tsunami"
+ "into": "first_url",
+ "json": "json.hits[0].url"
}, {
- "into": "url",
- "json": "json.features[0].properties.url"
+ "into": "hits",
+ "json": "json.hits"
} ], "http": { "method": "GET",
- "url": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson"
+ "url": "https://hn.algolia.com/api/v1/search?query={{ inputs.query | url }}&tags=story&hitsPerPage=3"
}, "into": "resp", "kind": "observe", "next": "status" },
- "none": {
- "kind": "done",
- "output": {
- "count": 0
- },
- "say": "No earthquakes of magnitude 4.5+ anywhere in the past day."
- },
- "reply": {
- "kind": "done",
- "output": {
- "count": "{{ state.count }}",
- "latest": {
- "magnitude": "{{ state.mag }}",
- "place": "{{ state.place }}",
- "tsunami": "{{ state.tsunami }}",
- "url": "{{ state.url }}"
- }
- },
- "say": "{{ state.count }} earthquake(s) of magnitude 4.5+ in the past day. The latest: M{{ state.mag }}, {{ state.place }}. {{ state.url }}"
- },
"status": { "branches": [ {
@@ -11982,234 +16277,203 @@
"unexpected": { "kind": "fail", "reason": "unexpected-status",
- "say": "The USGS feed answered {{ state.resp.status }}."
+ "say": "HN Search answered {{ state.resp.status }}."
} },
- "start": "fetch",
+ "start": "search",
"tags": [
- "science",
+ "news",
+ "tech",
"read-only" ], "triggers": [ {
- "pattern": "[any] [recent] earthquakes [today]"
+ "pattern": "search hacker news for {query}"
}, {
- "pattern": "[was there an] earthquake today"
+ "pattern": "hacker news stories about {query}"
}, {
- "pattern": "latest earthquake"
+ "pattern": "what does hacker news say about {query}"
} ], "version": 1 },
- "exchange-rate": {
- "description": "An amount in another currency at today's European Central Bank reference rate, from Frankfurter.",
+ "holidays": {
+ "description": "The next public holiday in a country (by its two-letter code), from Nager.Date.",
"hosts": [
- "api.frankfurter.dev"
+ "date.nager.at"
], "inputs": {
- "amount": {
- "default": "1",
- "description": "How much of the first currency, as in: 100 or 2.5",
- "pattern": "^[0-9]+(\\.[0-9]+)?$",
- "type": "str"
- },
- "from": {
- "description": "A three-letter currency code, as in: USD",
- "pattern": "^[A-Za-z]{3}$",
- "type": "str"
- },
- "to": {
- "description": "A three-letter currency code, as in: EUR",
- "pattern": "^[A-Za-z]{3}$",
+ "country": {
+ "description": "A two-letter country code, as in: NO, US, DE",
+ "pattern": "^[A-Za-z]{2}$",
"type": "str" } }, "license": "MIT",
- "name": "exchange-rate",
+ "name": "holidays",
"namespace": "pyrtan", "nodes": {
+ "any": {
+ "branches": [
+ {
+ "then": "reply",
+ "when": {
+ "exists": true
+ }
+ },
+ {
+ "else": "none-listed"
+ }
+ ],
+ "kind": "decide",
+ "on": "date"
+ },
"fetch": { "extract": [ {
- "into": "value",
- "regex": "\"rates\":\\s*\\{\\s*\"[A-Z]{3}\":\\s*(-?[0-9.]+(?:[eE][-+]?[0-9]+)?)\\s*\\}"
+ "into": "date",
+ "json": "json[0].date"
}, {
- "into": "day",
- "json": "json.date"
+ "into": "name",
+ "json": "json[0].name"
+ },
+ {
+ "into": "local",
+ "json": "json[0].localName"
+ },
+ {
+ "into": "then",
+ "json": "json[1].name"
+ },
+ {
+ "into": "then_date",
+ "json": "json[1].date"
} ], "http": { "method": "GET",
- "url": "https://api.frankfurter.dev/v1/latest?base={{ inputs.from | upper }}&symbols={{ inputs.to | upper }}&amount={{ inputs.amount }}"
+ "url": "https://date.nager.at/api/v3/NextPublicHolidays/{{ inputs.country | upper }}"
}, "into": "resp", "kind": "observe", "next": "status" },
- "has-rate": {
- "branches": [
- {
- "then": "reply",
- "when": {
- "exists": true
- }
- },
- {
- "else": "unexpected"
- }
- ],
- "kind": "decide",
- "on": "value"
+ "none-listed": {
+ "kind": "fail",
+ "reason": "none-listed",
+ "say": "Nager.Date lists no coming public holidays for {{ inputs.country | upper }}."
}, "reply": { "kind": "done", "output": {
- "amount": "{{ inputs.amount | float }}",
- "date": "{{ state.day }}",
- "from": "{{ inputs.from | upper }}",
- "to": "{{ inputs.to | upper }}",
- "value": "{{ state.value | float }}"
+ "after": {
+ "date": "{{ state.then_date }}",
+ "name": "{{ state.then }}"
+ },
+ "country": "{{ inputs.country | upper }}",
+ "date": "{{ state.date }}",
+ "local_name": "{{ state.local }}",
+ "name": "{{ state.name }}"
},
- "say": "{{ inputs.amount }} {{ inputs.from | upper }} is {{ state.value }} {{ inputs.to | upper }} at the ECB rate of {{ state.day }}."
- },
- "same-currency": {
- "kind": "fail",
- "reason": "same-currency",
- "say": "{{ inputs.from | upper }} to {{ inputs.to | upper }} is the same currency."
+ "say": "The next public holiday in {{ inputs.country | upper }} is {{ state.name }} ({{ state.local }}) on {{ state.date }}."
}, "status": { "branches": [ {
- "then": "has-rate",
- "when": {
- "eq": 200
- }
- },
- {
- "then": "unknown-currency",
- "when": {
- "eq": 404
- }
- },
- {
- "then": "same-currency",
+ "then": "any",
"when": {
- "eq": 422
+ "eq": 200
} }, {
- "else": "unexpected"
+ "else": "unknown-country"
} ], "kind": "decide", "on": "resp.status" },
- "unexpected": {
- "kind": "fail",
- "reason": "unexpected-status",
- "say": "Frankfurter answered {{ state.resp.status }}."
- },
- "unknown-currency": {
+ "unknown-country": {
"kind": "fail",
- "reason": "unknown-currency",
- "say": "The ECB publishes no rate between {{ inputs.from | upper }} and {{ inputs.to | upper }}; it covers about thirty currencies, by their three-letter codes."
+ "reason": "unknown-country",
+ "say": "Nager.Date knows no country {{ inputs.country | upper }} (it answered {{ state.resp.status }}); use a two-letter code such as NO, US or DE."
} }, "start": "fetch", "tags": [
- "money",
+ "calendar",
"read-only" ], "triggers": [ {
- "pattern": "convert {amount:number} {from:word} (to|into|in) {to:word}"
- },
- {
- "pattern": "how much is {amount:number} {from:word} in {to:word}"
- },
- {
- "pattern": "[the] exchange rate (from|for) {from:word} to {to:word}"
+ "pattern": "[when is the] next [public|bank] holiday in {country:word}"
}, {
- "pattern": "{from:word} to {to:word} (rate|exchange rate)"
+ "pattern": "[public|bank] holidays in {country:word}"
} ], "version": 1 },
- "github-release": {
- "description": "The latest release of a GitHub repository.",
+ "homebrew": {
+ "description": "A Homebrew formula's current stable version and what it is, from formulae.brew.sh.",
"hosts": [
- "api.github.com"
+ "formulae.brew.sh"
], "inputs": {
- "repo": {
- "description": "owner/name",
- "pattern": "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$",
+ "formula": {
+ "description": "A formula name, as in: wget",
+ "pattern": "^[a-z0-9][a-z0-9@+._\\-]*$",
"type": "str" } }, "license": "MIT",
- "name": "github-release",
+ "name": "homebrew",
"namespace": "pyrtan", "nodes": { "fetch": { "extract": [
- {
- "into": "tag",
- "json": "json.tag_name"
- },
{ "into": "name", "json": "json.name" }, {
- "into": "published",
- "json": "json.published_at"
+ "into": "version",
+ "json": "json.versions.stable"
}, {
- "into": "url",
- "json": "json.html_url"
+ "into": "about",
+ "json": "json.desc"
}, {
- "into": "prerelease",
- "json": "json.prerelease"
+ "into": "home",
+ "json": "json.homepage"
} ], "http": {
- "headers": {
- "Accept": "application/vnd.github+json"
- },
"method": "GET",
- "url": "https://api.github.com/repos/{{ inputs.repo }}/releases/latest"
+ "url": "https://formulae.brew.sh/api/formula/{{ inputs.formula | lower | url }}.json"
}, "into": "resp", "kind": "observe", "next": "status" },
- "none": {
- "kind": "fail",
- "reason": "no-release",
- "say": "{{ inputs.repo }} has no releases, or does not exist."
- },
- "rate-limited": {
+ "no-formula": {
"kind": "fail",
- "reason": "rate-limited",
- "say": "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."
+ "reason": "no-formula",
+ "say": "Homebrew has no formula called {{ inputs.formula }}."
}, "reply": { "kind": "done", "output": {
- "name": "{{ state.name }}",
- "prerelease": "{{ state.prerelease }}",
- "published": "{{ state.published }}",
- "repo": "{{ inputs.repo }}",
- "tag": "{{ state.tag }}",
- "url": "{{ state.url }}"
+ "description": "{{ state.about }}",
+ "formula": "{{ state.name }}",
+ "homepage": "{{ state.home }}",
+ "install": "brew install {{ state.name }}",
+ "version": "{{ state.version }}"
},
- "say": "{{ inputs.repo }}: {{ state.tag }} ({{ state.published }}) {{ state.url }}"
+ "say": "{{ state.name }} {{ state.version }} on Homebrew: {{ state.about }}. {{ state.home }}"
}, "status": { "branches": [
@@ -12220,20 +16484,11 @@
} }, {
- "then": "none",
+ "then": "no-formula",
"when": { "eq": 404 } },
- {
- "then": "rate-limited",
- "when": {
- "in": [
- 403,
- 429
- ]
- }
- },
{ "else": "unexpected" }
@@ -12243,55 +16498,80 @@
}, "unexpected": { "kind": "fail",
- "reason": "unexpected-response",
- "say": "GitHub answered {{ state.resp.status }}."
+ "reason": "unexpected-status",
+ "say": "formulae.brew.sh answered {{ state.resp.status }}."
} }, "start": "fetch", "tags": [
- "github",
+ "software",
+ "macos",
"read-only" ], "triggers": [ {
- "pattern": "[what's the] latest release of {repo:word}"
+ "pattern": "[what's the] latest version of {formula:word} on homebrew"
}, {
- "pattern": "latest {repo:word} release"
+ "pattern": "(brew|homebrew) (formula|version) {formula:word}"
} ],
- "version": 2
+ "version": 1
},
- "hacker-news": {
- "description": "Top stories on Hacker News, from its public API.",
+ "iss": {
+ "description": "Where the International Space Station is right now: latitude, longitude, altitude and speed.",
"hosts": [
- "hacker-news.firebaseio.com"
+ "api.wheretheiss.at"
],
- "inputs": {
- "count": {
- "default": 1,
- "description": "How many stories to return",
- "max": 30,
- "min": 1,
- "type": "int"
- }
- },
"license": "MIT",
- "name": "hacker-news",
+ "name": "iss",
"namespace": "pyrtan", "nodes": {
- "any": {
- "branches": [
+ "fetch": {
+ "extract": [
{
- "then": "nothing",
- "when": {
- "empty": true
- }
+ "into": "lat",
+ "json": "json.latitude"
}, {
- "then": "stories",
+ "into": "lon",
+ "json": "json.longitude"
+ },
+ {
+ "into": "alt",
+ "json": "json.altitude"
+ },
+ {
+ "into": "speed",
+ "json": "json.velocity"
+ },
+ {
+ "into": "light",
+ "json": "json.visibility"
+ },
+ {
+ "into": "lat2",
+ "regex": "\"latitude\":\\s*(-?\\d+(?:\\.\\d{1,2})?)"
+ },
+ {
+ "into": "lon2",
+ "regex": "\"longitude\":\\s*(-?\\d+(?:\\.\\d{1,2})?)"
+ }
+ ],
+ "http": {
+ "method": "GET",
+ "url": "https://api.wheretheiss.at/v1/satellites/25544"
+ },
+ "into": "resp",
+ "kind": "observe",
+ "next": "status"
+ },
+ "have-position": {
+ "branches": [
+ {
+ "then": "light",
"when": {
- "type": "list"
+ "type": "number"
} }, {
@@ -12299,595 +16579,613 @@
} ], "kind": "decide",
- "on": "ids"
+ "on": "lat"
},
- "check": {
+ "in-shadow": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "where": "in the Earth's shadow"
+ }
+ },
+ "in-sun": {
+ "kind": "set",
+ "next": "reply",
+ "set": {
+ "where": "in sunlight"
+ }
+ },
+ "light": {
"branches": [ {
- "then": "any",
+ "then": "in-sun",
"when": {
- "eq": 200
+ "eq": "daylight"
} }, {
- "then": "hn-down",
+ "then": "in-shadow",
"when": {
- "gte": 500
+ "eq": "eclipsed"
} }, {
- "else": "unexpected"
+ "else": "in-sun"
} ], "kind": "decide",
- "on": "ids_resp.status"
- },
- "fetch-ids": {
- "extract": [
- {
- "into": "ids",
- "json": "json"
- }
- ],
- "http": {
- "method": "GET",
- "url": "https://hacker-news.firebaseio.com/v0/topstories.json"
- },
- "into": "ids_resp",
- "kind": "observe",
- "next": "check"
- },
- "hn-down": {
- "kind": "fail",
- "reason": "upstream-down",
- "say": "Hacker News answered {{ state.ids_resp.status }}; try again later."
+ "on": "light"
},
- "nothing": {
+ "rate-limited": {
"kind": "fail",
- "reason": "no-stories",
- "say": "Hacker News returned an empty front page."
+ "reason": "rate-limited",
+ "say": "wheretheiss.at is asking for fewer requests (429); try again in a moment."
}, "reply": { "kind": "done", "output": {
- "stories": "{{ state.stories }}"
+ "altitude_km": "{{ state.alt }}",
+ "latitude": "{{ state.lat }}",
+ "longitude": "{{ state.lon }}",
+ "map": "https://www.openstreetmap.org/?mlat={{ state.lat }}&mlon={{ state.lon }}&zoom=3",
+ "speed_kmh": "{{ state.speed }}",
+ "visibility": "{{ state.light }}"
},
- "say": "Top of Hacker News: {{ state.stories[0].title }} — {{ state.stories[0].url }}"
+ "say": "The ISS is over {{ state.lat2 }}, {{ state.lon2 }}, {{ state.alt | int }} km up, moving at {{ state.speed | int }} km/h, {{ state.where }}."
},
- "stories": {
- "into": "stories",
- "kind": "each",
- "limit": "{{ inputs.count }}",
- "next": "reply",
- "over": "ids",
- "tree": "hacker-news-item",
- "with": {
- "id": "{{ item }}"
- }
+ "status": {
+ "branches": [
+ {
+ "then": "have-position",
+ "when": {
+ "eq": 200
+ }
+ },
+ {
+ "then": "rate-limited",
+ "when": {
+ "eq": 429
+ }
+ },
+ {
+ "else": "unexpected"
+ }
+ ],
+ "kind": "decide",
+ "on": "resp.status"
}, "unexpected": { "kind": "fail",
- "reason": "unexpected-response",
- "say": "Hacker News answered {{ state.ids_resp.status }} with something this tree does not recognise."
+ "reason": "unexpected-status",
+ "say": "wheretheiss.at answered {{ state.resp.status }}."
} },
- "start": "fetch-ids",
+ "start": "fetch",
"tags": [
- "news",
+ "science",
+ "space",
"read-only" ], "triggers": [ {
- "inputs": {
- "count": 1
- },
- "pattern": "[what's the] top story on hacker news"
+ "pattern": "where is the (iss|space station) [now]"
}, {
- "pattern": "top {count:int} stories on hacker news"
+ "pattern": "(iss|space station) (position|location)"
} ],
- "version": 2
+ "version": 1
},
- "hacker-news-item": {
- "description": "One Hacker News item by id: title, url, score, author.",
+ "local-time": {
+ "description": "What time it is now in a place, and its time zone.",
"hosts": [
- "hacker-news.firebaseio.com"
+ "geocoding-api.open-meteo.com",
+ "timeapi.io"
], "inputs": {
- "id": {
- "description": "The item id",
- "min": 1,
- "type": "int"
+ "place": {
+ "description": "A town or city name",
+ "type": "str"
} }, "license": "MIT",
- "name": "hacker-news-item",
+ "name": "local-time",
"namespace": "pyrtan", "nodes": {
- "fetch": {
+ "clock": {
"extract": [ {
- "into": "title",
- "json": "json.title"
- },
- {
- "into": "url",
- "json": "json.url"
- },
- {
- "into": "score",
- "json": "json.score"
+ "into": "time",
+ "json": "json.time"
}, {
- "into": "by",
- "json": "json.by"
+ "into": "weekday",
+ "json": "json.dayOfWeek"
}, {
- "into": "type",
- "json": "json.type"
+ "from": "json.dateTime",
+ "into": "date",
+ "regex": "^(\\d{4}-\\d\\d-\\d\\d)"
} ], "http": { "method": "GET",
- "url": "https://hacker-news.firebaseio.com/v0/item/{{ inputs.id }}.json"
+ "url": "https://timeapi.io/api/time/current/zone?timeZone={{ state.zone | url }}"
}, "into": "resp", "kind": "observe",
- "next": "kind"
+ "next": "status"
},
- "has-url": {
+ "found": {
"branches": [ {
- "then": "reply",
+ "then": "clock",
"when": { "exists": true } }, {
- "else": "self-post"
+ "else": "unknown-place"
} ], "kind": "decide",
- "on": "url"
+ "on": "hit"
},
- "kind": {
- "branches": [
+ "geocode": {
+ "extract": [
{
- "then": "has-url",
- "when": {
- "eq": "story"
- }
+ "into": "hit",
+ "json": "json.results[0]"
}, {
- "then": "has-url",
- "when": {
- "eq": "job"
- }
+ "into": "lat",
+ "json": "json.results[0].latitude"
}, {
- "then": "missing",
- "when": {
- "exists": false
- }
+ "into": "lon",
+ "json": "json.results[0].longitude"
}, {
- "else": "not-a-story"
+ "into": "name",
+ "json": "json.results[0].name"
+ },
+ {
+ "into": "country",
+ "json": "json.results[0].country"
+ },
+ {
+ "into": "zone",
+ "json": "json.results[0].timezone"
+ },
+ {
+ "into": "height",
+ "json": "json.results[0].elevation"
} ],
- "kind": "decide",
- "on": "type"
- },
- "missing": {
- "kind": "fail",
- "reason": "no-such-item",
- "say": "There is no Hacker News item {{ inputs.id }}."
+ "http": {
+ "method": "GET",
+ "url": "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json"
+ },
+ "into": "geo",
+ "kind": "observe",
+ "next": "found"
},
- "not-a-story": {
+ "no-clock": {
"kind": "fail",
- "reason": "not-a-story",
- "say": "Item {{ inputs.id }} is a {{ state.type }}, not a story."
+ "reason": "no-clock",
+ "say": "timeapi.io gave no time for {{ state.zone }} (status {{ state.resp.status }})."
}, "reply": { "kind": "done", "output": {
- "by": "{{ state.by }}",
- "id": "{{ inputs.id }}",
- "score": "{{ state.score }}",
- "title": "{{ state.title }}",
- "url": "{{ state.url }}"
+ "country": "{{ state.country }}",
+ "date": "{{ state.date }}",
+ "place": "{{ state.name }}",
+ "time": "{{ state.time }}",
+ "timezone": "{{ state.zone }}",
+ "weekday": "{{ state.weekday }}"
},
- "say": "{{ state.title }} ({{ state.score }} points, by {{ state.by }}) {{ state.url }}"
+ "say": "It is {{ state.time }} on {{ state.weekday }} {{ state.date }} in {{ state.name }}, {{ state.country }} ({{ state.zone }})."
},
- "self-post": {
- "kind": "set",
- "next": "reply",
- "set": {
- "url": "https://news.ycombi

The diff is cut here; the rest is in the clone.