Default Tools — Global¶
The 22 tools in default-tool-specs-network.json call public global HTTPS endpoints — most of them anonymous, all of them outside Korea. Categories span code (GitHub), encyclopedia (Wikipedia), forum (Hacker News, Stack Overflow, Reddit), finance (CoinGecko, exchangerate.host), geo (ipapi.co, restcountries, Nominatim, sunrise-sunset, USGS), weather (Open-Meteo), and government data (Nager.Date public holidays).
None of them need an API key — they live entirely off the providers' anonymous rate-limit tiers. Tool actions execute with the default sandbox networkMode: strict, so every fetch goes through the SSRF four-layer guard regardless of whether the destination is a literal IP or a DNS host.
The grouping below mirrors the tags axis you can filter by inside the Tool MCP Server Setting drawer.
Browse the 22 global APIs¶
All run with networkMode: strict (SSRF four-layer guard) at sandbox L0. Tag chips: github · search · finance · geo · weather.
Fetches public metadata for a GitHub repository (no authentication needed; subject to GitHub's 60 requests/hour anonymous rate limit).
Params owner · repo
Env —
More detail
Returns: { fullName, description, stars, forks, openIssues, language, license, defaultBranch, lastPush, topics, homepage, htmlUrl }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
owner |
STRING |
✓ | GitHub user or org login (e.g. 'spring-projects') |
repo |
STRING |
✓ | Repository name (e.g. 'spring-ai') |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Fetches public metadata for a GitHub repository.
*
* No authentication needed; GitHub allows 60 requests per hour from
* an anonymous client (rate-limit headers are returned in the response).
*
* On 404, returns { found: false, owner, repo } instead of throwing.
*/
const url = 'https://api.github.com/repos/'
+ encodeURIComponent(owner) + '/' + encodeURIComponent(repo);
const resp = await fetch(url, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'spring-ai-playground',
},
maxLength: 1_000_000,
});
if (resp.status === 404) return { found: false, owner, repo };
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const data = resp.json();
return {
fullName: data.full_name,
description: data.description,
stars: data.stargazers_count,
forks: data.forks_count,
openIssues: data.open_issues_count,
language: data.language,
license: data.license && data.license.spdx_id,
defaultBranch: data.default_branch,
lastPush: data.pushed_at,
topics: data.topics || [],
homepage: data.homepage,
htmlUrl: data.html_url,
};
Looks up a Wikipedia page summary by title. No authentication required. Uses the public REST API at en.wikipedia.org/api/rest_v1/page/summary.
Params title · lang
Env —
More detail
Returns: { title, description, extract (plain-text summary), thumbnail, pageUrl }. If the title is not found, returns { found: false, title }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
title |
STRING |
✓ | Article title (case-insensitive, spaces ok) |
lang |
STRING |
Language code (e.g. 'en', 'ko', default 'en') |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Looks up a Wikipedia article summary by title.
*
* Uses the public REST API: https://{lang}.wikipedia.org/api/rest_v1/page/summary/{title}
* No auth, generous rate limits, JSON response.
*
* `lang` is the wiki subdomain — 'en', 'ko', 'ja', etc. Defaults to 'en'.
*/
if (title == null || title === '') throw new Error('title required');
const language = (lang && lang !== '') ? lang : 'en';
const url = 'https://' + language + '.wikipedia.org/api/rest_v1/page/summary/'
+ encodeURIComponent(String(title).replace(/ /g, '_'));
const resp = await fetch(url, {
headers: { 'Accept': 'application/json', 'User-Agent': 'spring-ai-playground' },
maxLength: 1_000_000,
});
if (resp.status === 404) return { found: false, title };
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const data = resp.json();
return {
title: data.title,
description: data.description,
extract: data.extract,
thumbnail: data.thumbnail && data.thumbnail.source,
pageUrl: data.content_urls && data.content_urls.desktop && data.content_urls.desktop.page,
};
Searches Hacker News stories via the public Algolia HN Search API (no auth needed).
Params query · hits · tag
Env —
More detail
Returns up to hits results, each as: { id, title, url, points, author, commentCount, createdAt, hnLink }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
query |
STRING |
✓ | Search query string |
hits |
INTEGER |
Max results to return (1-20, default 5) | |
tag |
STRING |
HN tag filter: story | comment | poll | etc (optional) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Searches Hacker News via the Algolia HN Search API.
*
* Endpoint: https://hn.algolia.com/api/v1/search?query=...&hitsPerPage=...&tags=...
*
* Available tags: story, comment, poll, pollopt, show_hn, ask_hn, front_page.
* No authentication, public rate limit.
*/
if (query == null || query === '') throw new Error('query required');
const n = (Number.isInteger(hits) && hits > 0 && hits <= 20) ? hits : 5;
const params = new URLSearchParams();
params.set('query', String(query));
params.set('hitsPerPage', String(n));
if (tag && tag !== '') params.set('tags', String(tag));
const resp = await fetch('https://hn.algolia.com/api/v1/search?' + params.toString(), {
maxLength: 2_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const data = resp.json();
return (data.hits || []).map(h => ({
id: h.objectID,
title: h.title || h.story_title,
url: h.url || h.story_url,
points: h.points,
author: h.author,
commentCount: h.num_comments,
createdAt: h.created_at,
hnLink: 'https://news.ycombinator.com/item?id=' + h.objectID,
}));
Searches Stack Overflow questions via the public Stack Exchange API (anonymous, capped at 300 requests / IP / day).
Params query · pageSize · sort · tags
Env —
More detail
Returns up to pageSize results sorted by sort (relevance | activity | votes | creation), each as: { id, title, score, answerCount, isAnswered, tags, link, createdAt, owner }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
query |
STRING |
✓ | Search text (intitle) |
pageSize |
INTEGER |
Max results (1-30, default 5) | |
sort |
STRING |
relevance | activity | votes | creation | |
tags |
STRING |
Semicolon-separated tag filter (e.g. 'java;spring') |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Searches Stack Overflow questions via the Stack Exchange API.
*
* Endpoint: https://api.stackexchange.com/2.3/search/advanced
*
* Anonymous quota: 300 requests / IP / day. Returns paginated results
* (this tool returns the first page only — adjust `pageSize` up to 30).
*/
if (query == null || query === '') throw new Error('query required');
const n = (Number.isInteger(pageSize) && pageSize > 0 && pageSize <= 30) ? pageSize : 5;
const orderBy = (sort === 'activity' || sort === 'votes' || sort === 'creation') ? sort : 'relevance';
const params = new URLSearchParams();
params.set('order', 'desc');
params.set('sort', orderBy);
params.set('q', String(query));
params.set('site', 'stackoverflow');
params.set('pagesize', String(n));
if (tags && tags !== '') params.set('tagged', String(tags));
const resp = await fetch('https://api.stackexchange.com/2.3/search/advanced?' + params.toString(), {
headers: { 'Accept': 'application/json' },
maxLength: 2_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const data = resp.json();
return (data.items || []).map(q => ({
id: q.question_id,
title: q.title,
score: q.score,
answerCount: q.answer_count,
isAnswered: q.is_answered,
tags: q.tags || [],
link: q.link,
createdAt: q.creation_date,
owner: q.owner && q.owner.display_name,
}));
Fetches public profile information for a GitHub user or organisation (no auth — 60 req/h anonymous).
Params login
Env —
More detail
Returns: { login, type, name, company, blog, location, bio, publicRepos, publicGists, followers, following, createdAt, htmlUrl, avatarUrl }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
login |
STRING |
✓ | GitHub user or org login |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Fetches GitHub public user / organisation profile.
* Endpoint: GET https://api.github.com/users/{login}
*/
if (login == null || login === '') throw new Error('login required');
const resp = await fetch('https://api.github.com/users/' + encodeURIComponent(login), {
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'spring-ai-playground' },
maxLength: 1_000_000,
});
if (resp.status === 404) return { found: false, login };
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const d = resp.json();
return {
login: d.login,
type: d.type,
name: d.name,
company: d.company,
blog: d.blog,
location: d.location,
bio: d.bio,
publicRepos: d.public_repos,
publicGists: d.public_gists,
followers: d.followers,
following: d.following,
createdAt: d.created_at,
htmlUrl: d.html_url,
avatarUrl: d.avatar_url,
};
Lists issues on a public GitHub repository (no auth). Excludes pull requests by default. Anonymous quota 60 req/h.
Params owner · repo · state · perPage · page
Env —
More detail
Returns up to perPage issues, each as: { number, title, state, author, labels, commentCount, createdAt, updatedAt, htmlUrl }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
owner |
STRING |
✓ | Repo owner |
repo |
STRING |
✓ | Repo name |
state |
STRING |
open | closed | all | |
perPage |
INTEGER |
Max issues per page (1-100, default 10) | |
page |
INTEGER |
Page number (1-based, default 1) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Lists issues on a public GitHub repository.
* Endpoint: GET https://api.github.com/repos/{owner}/{repo}/issues
*
* Pull requests are filtered out client-side (the API includes them in /issues
* by default and they're indistinguishable except for the `pull_request` key).
*/
if (owner == null || owner === '') throw new Error('owner required');
if (repo == null || repo === '') throw new Error('repo required');
const n = (Number.isInteger(perPage) && perPage > 0 && perPage <= 100) ? perPage : 10;
const pg = (Number.isInteger(page) && page > 0) ? page : 1;
const st = (state === 'closed' || state === 'all') ? state : 'open';
const url = 'https://api.github.com/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo)
+ '/issues?state=' + st + '&per_page=' + n + '&page=' + pg;
const resp = await fetch(url, {
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'spring-ai-playground' },
maxLength: 5_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const issues = resp.json();
return (issues || []).filter(i => !i.pull_request).map(i => ({
number: i.number,
title: i.title,
state: i.state,
author: i.user && i.user.login,
labels: (i.labels || []).map(l => typeof l === 'string' ? l : l.name),
commentCount: i.comments,
createdAt: i.created_at,
updatedAt: i.updated_at,
htmlUrl: i.html_url,
}));
Lists releases on a public GitHub repository (no auth).
Params owner · repo · perPage
Env —
More detail
Returns: [{ tag, name, draft, prerelease, publishedAt, htmlUrl, body }].
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
owner |
STRING |
✓ | Repo owner |
repo |
STRING |
✓ | Repo name |
perPage |
INTEGER |
Max releases (1-30, default 5) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Lists releases on a public GitHub repository.
* Endpoint: GET https://api.github.com/repos/{owner}/{repo}/releases
*/
if (owner == null || owner === '') throw new Error('owner required');
if (repo == null || repo === '') throw new Error('repo required');
const n = (Number.isInteger(perPage) && perPage > 0 && perPage <= 30) ? perPage : 5;
const url = 'https://api.github.com/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo)
+ '/releases?per_page=' + n;
const resp = await fetch(url, {
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'spring-ai-playground' },
maxLength: 5_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
return (resp.json() || []).map(r => ({
tag: r.tag_name,
name: r.name,
draft: r.draft,
prerelease: r.prerelease,
publishedAt: r.published_at,
htmlUrl: r.html_url,
body: r.body,
}));
Fetches the latest non-draft, non-prerelease release of a public GitHub repository (no auth).
Params owner · repo
Env —
More detail
Returns: { tag, name, publishedAt, htmlUrl, body, assets: [{ name, downloadUrl, size }] }. 404 → { found: false, owner, repo }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
owner |
STRING |
✓ | Repo owner |
repo |
STRING |
✓ | Repo name |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Fetches the latest stable release of a public GitHub repository.
* Endpoint: GET https://api.github.com/repos/{owner}/{repo}/releases/latest
*/
if (owner == null || owner === '') throw new Error('owner required');
if (repo == null || repo === '') throw new Error('repo required');
const url = 'https://api.github.com/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo)
+ '/releases/latest';
const resp = await fetch(url, {
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'spring-ai-playground' },
maxLength: 2_000_000,
});
if (resp.status === 404) return { found: false, owner, repo };
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const r = resp.json();
return {
tag: r.tag_name,
name: r.name,
publishedAt: r.published_at,
htmlUrl: r.html_url,
body: r.body,
assets: (r.assets || []).map(a => ({
name: a.name, downloadUrl: a.browser_download_url, size: a.size,
})),
};
Fetches the raw text content of a file from a public GitHub repository (no auth).
Params owner · repo · path · ref
Env —
More detail
For directories this returns a listing instead: [{ name, type, path }]. Files over ~1 MB are not supported by this endpoint and return success=false.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
owner |
STRING |
✓ | Repo owner |
repo |
STRING |
✓ | Repo name |
path |
STRING |
✓ | Path inside the repo (e.g. 'README.adoc') |
ref |
STRING |
Branch / tag / commit SHA (default: repo's default branch) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Fetches a file (or directory listing) from a public GitHub repository.
* Endpoint: GET https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={ref}
*
* File response: base64-encoded content + sha + size. We decode to UTF-8 text.
* Directory response: array of entries; we return a slimmed listing.
*/
if (owner == null || owner === '') throw new Error('owner required');
if (repo == null || repo === '') throw new Error('repo required');
if (path == null || path === '') throw new Error('path required');
const refQs = (ref && ref !== '') ? ('?ref=' + encodeURIComponent(ref)) : '';
const segments = String(path).split('/').filter(Boolean).map(encodeURIComponent).join('/');
const url = 'https://api.github.com/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo)
+ '/contents/' + segments + refQs;
const resp = await fetch(url, {
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'spring-ai-playground' },
maxLength: 5_000_000,
});
if (resp.status === 404) return { found: false, owner, repo, path };
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const data = resp.json();
if (Array.isArray(data)) {
// Directory listing
return data.map(e => ({ name: e.name, type: e.type, path: e.path, size: e.size }));
}
// File — decode base64 content. GitHub wraps lines every 60 chars; strip whitespace first.
if (data.encoding !== 'base64' || data.type !== 'file') {
return { found: true, type: data.type, name: data.name, path: data.path, size: data.size,
note: 'unsupported content; use a smaller file or different endpoint' };
}
const b64 = String(data.content || '').replace(/\s+/g, '');
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { name: data.name, path: data.path, size: data.size, sha: data.sha,
encoding: 'utf-8', content: text };
Searches public GitHub repositories by query (no auth — anonymous limit 10 requests/minute).
Params query · sort · perPage
Env —
More detail
Returns up to perPage results: [{ fullName, description, stars, forks, language, htmlUrl, updatedAt }].
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
query |
STRING |
✓ | GitHub search query (e.g. 'spring-ai language:java') |
sort |
STRING |
stars | forks | updated | best-match (default) | |
perPage |
INTEGER |
Max results (1-30, default 5) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Searches public GitHub repositories.
* Endpoint: GET https://api.github.com/search/repositories?q=...&sort=...&per_page=...
*
* Anonymous rate limit: 10 requests / minute / IP.
*/
if (query == null || query === '') throw new Error('query required');
const n = (Number.isInteger(perPage) && perPage > 0 && perPage <= 30) ? perPage : 5;
const sortValid = (sort === 'stars' || sort === 'forks' || sort === 'updated');
const sortQs = sortValid ? ('&sort=' + sort + '&order=desc') : '';
const url = 'https://api.github.com/search/repositories?q=' + encodeURIComponent(query)
+ '&per_page=' + n + sortQs;
const resp = await fetch(url, {
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'spring-ai-playground' },
maxLength: 5_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const data = resp.json();
return (data.items || []).map(r => ({
fullName: r.full_name,
description: r.description,
stars: r.stargazers_count,
forks: r.forks_count,
language: r.language,
htmlUrl: r.html_url,
updatedAt: r.updated_at,
}));
Lists top contributors to a public GitHub repository (no auth).
Params owner · repo · perPage
Env —
More detail
Returns: [{ login, contributions, htmlUrl, avatarUrl }] sorted by commit count desc.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
owner |
STRING |
✓ | Repo owner |
repo |
STRING |
✓ | Repo name |
perPage |
INTEGER |
Max contributors (1-100, default 10) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Lists top contributors to a public GitHub repository.
* Endpoint: GET https://api.github.com/repos/{owner}/{repo}/contributors
*/
if (owner == null || owner === '') throw new Error('owner required');
if (repo == null || repo === '') throw new Error('repo required');
const n = (Number.isInteger(perPage) && perPage > 0 && perPage <= 100) ? perPage : 10;
const url = 'https://api.github.com/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo)
+ '/contributors?per_page=' + n;
const resp = await fetch(url, {
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'spring-ai-playground' },
maxLength: 2_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
return (resp.json() || []).map(c => ({
login: c.login,
contributions: c.contributions,
htmlUrl: c.html_url,
avatarUrl: c.avatar_url,
}));
Fetches current crypto prices from CoinGecko's public Simple Price API (no auth, generous rate limit). Pass coin ids like 'bitcoin,ethereum' and currency ids like 'usd,krw'.
Params ids · currencies
Env —
More detail
Returns: {
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
ids |
STRING |
✓ | Comma-separated CoinGecko coin ids |
currencies |
STRING |
Comma-separated target currencies (e.g. usd, krw) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* CoinGecko public Simple Price endpoint.
*
* GET https://api.coingecko.com/api/v3/simple/price?ids={ids}&vs_currencies={currencies}
*
* No authentication required for the public tier (~10-30 req/min/IP).
* Returns a flat map: {bitcoin:{usd:62000, krw:84000000}, ethereum:{usd:...}}.
*/
if (ids == null || ids === '') throw new Error('ids required');
const vs = (currencies && currencies !== '') ? currencies : 'usd';
const url = 'https://api.coingecko.com/api/v3/simple/price'
+ '?ids=' + encodeURIComponent(ids)
+ '&vs_currencies=' + encodeURIComponent(vs);
const resp = await fetch(url, {
headers: { 'Accept': 'application/json', 'User-Agent': 'spring-ai-playground' },
maxLength: 500_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
return resp.json();
Converts between fiat currencies using exchangerate.host (no key, no rate limit listed).
Params from · to · amount
Env —
More detail
Returns: { from, to, amount, rate, result, date }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
from |
STRING |
✓ | Source currency code (ISO 4217, e.g. USD) |
to |
STRING |
✓ | Target currency code (ISO 4217, e.g. KRW) |
amount |
NUMBER |
Amount in the source currency (default 1) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Currency conversion via exchangerate.host public API.
*
* GET https://api.exchangerate.host/convert?from=USD&to=KRW&amount=100
*
* Returns the canonical ECB-derived rate plus the converted amount.
*/
if (from == null || from === '') throw new Error('from required');
if (to == null || to === '') throw new Error('to required');
const amt = (amount == null || amount === '') ? 1 : Number(amount);
if (!Number.isFinite(amt)) throw new Error('amount must be a finite number');
const url = 'https://api.exchangerate.host/convert'
+ '?from=' + encodeURIComponent(from)
+ '&to=' + encodeURIComponent(to)
+ '&amount=' + amt;
const resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
maxLength: 200_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const d = resp.json();
return {
from: d.query && d.query.from,
to: d.query && d.query.to,
amount: d.query && d.query.amount,
rate: d.info && d.info.rate,
result: d.result,
date: d.date,
};
Returns geolocation and ASN info for an IP address (or the caller's IP if ip is omitted) via ipapi.co (no auth, 1000 req/day).
Params ip
Env —
More detail
Returns: { ip, city, region, country, countryName, latitude, longitude, timezone, asn, org, isp }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
ip |
STRING |
IPv4 / IPv6 address (omit for caller's own IP) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* IP geolocation lookup via ipapi.co (1000 free req/day, no key).
*
* GET https://ipapi.co/{ip}/json/
*
* If `ip` is omitted, ipapi resolves the caller's IP — useful for sanity-checking
* what the playground's outbound IP looks like to the rest of the internet.
*/
const path = (ip && ip !== '') ? (encodeURIComponent(ip) + '/json/') : 'json/';
const resp = await fetch('https://ipapi.co/' + path, {
headers: { 'Accept': 'application/json', 'User-Agent': 'spring-ai-playground' },
maxLength: 500_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const d = resp.json();
return {
ip: d.ip,
city: d.city,
region: d.region,
country: d.country_code,
countryName: d.country_name,
latitude: d.latitude,
longitude: d.longitude,
timezone: d.timezone,
asn: d.asn,
org: d.org,
isp: d.org,
};
Fetches country information from restcountries.com (no auth) by partial or full name.
Params name
Env —
More detail
Returns an array of matches, each: { name, officialName, capital, region, subregion, population, area, languages, currencies, callingCode, flagEmoji, latlng }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
name |
STRING |
✓ | Country name (partial match — e.g. 'korea', 'germany') |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Country metadata from restcountries.com (no key).
*
* GET https://restcountries.com/v3.1/name/{name}
*
* Partial matches are supported (e.g. "korea" returns both Koreas).
* The API returns extremely chatty objects — we project to the most useful fields.
*/
if (name == null || name === '') throw new Error('name required');
const resp = await fetch('https://restcountries.com/v3.1/name/' + encodeURIComponent(name), {
headers: { 'Accept': 'application/json' },
maxLength: 5_000_000,
});
if (resp.status === 404) return [];
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
return (resp.json() || []).map(c => ({
name: c.name && c.name.common,
officialName: c.name && c.name.official,
capital: c.capital,
region: c.region,
subregion: c.subregion,
population: c.population,
area: c.area,
languages: c.languages ? Object.values(c.languages) : [],
currencies: c.currencies ? Object.keys(c.currencies) : [],
callingCode: (c.idd && c.idd.root)
? c.idd.root + ((c.idd.suffixes && c.idd.suffixes[0]) || '')
: null,
flagEmoji: c.flag,
latlng: c.latlng,
}));
Searches arXiv preprints via the public Atom-feed API (no auth). Results are parsed from XML.
Params query · max · sortBy
Env —
More detail
Returns up to max entries, each: { id, title, summary, authors, published, updated, primaryCategory, pdfUrl, abstractUrl }.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
query |
STRING |
✓ | Search query (arXiv search_query syntax, e.g. 'all:transformer') |
max |
INTEGER |
Max results (1-50, default 5) | |
sortBy |
STRING |
relevance | lastUpdatedDate | submittedDate |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* arXiv search via the public Atom-feed API.
*
* GET http://export.arxiv.org/api/query?search_query={query}&max_results={n}
*
* The response is XML (Atom). We parse it via safety.parser.xml and project
* each <entry> to a compact object.
*/
if (query == null || query === '') throw new Error('query required');
const n = (Number.isInteger(max) && max > 0 && max <= 50) ? max : 5;
const so = (sortBy === 'lastUpdatedDate' || sortBy === 'submittedDate') ? sortBy : 'relevance';
const url = 'http://export.arxiv.org/api/query'
+ '?search_query=' + encodeURIComponent(query)
+ '&max_results=' + n
+ '&sortBy=' + so + '&sortOrder=descending';
const resp = await fetch(url, {
headers: { 'Accept': 'application/atom+xml' },
maxLength: 5_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const root = safety.parser.xml(resp.text());
// `root.children` is a list of {tag, attrs, text, children}. We pick <entry> nodes.
function pickChild(node, tag) {
for (const c of node.children || []) if (c.tag === tag) return c;
return null;
}
function pickAllChildren(node, tag) {
const out = [];
for (const c of node.children || []) if (c.tag === tag) out.push(c);
return out;
}
const entries = pickAllChildren(root, 'entry');
return entries.map(e => {
const idNode = pickChild(e, 'id');
const titleNode = pickChild(e, 'title');
const summaryNode = pickChild(e, 'summary');
const pubNode = pickChild(e, 'published');
const updNode = pickChild(e, 'updated');
const catNode = pickChild(e, 'primary_category') || pickChild(e, 'category');
const links = pickAllChildren(e, 'link');
const authors = pickAllChildren(e, 'author').map(a => {
const n = pickChild(a, 'name'); return n ? n.text : null;
}).filter(Boolean);
let pdfUrl = null, abs = null;
for (const l of links) {
const attrs = l.attrs || {};
if (attrs.title === 'pdf') pdfUrl = attrs.href;
if (attrs.rel === 'alternate') abs = attrs.href;
}
return {
id: idNode && idNode.text,
title: titleNode && titleNode.text.replace(/\s+/g, ' ').trim(),
summary: summaryNode && summaryNode.text.replace(/\s+/g, ' ').trim(),
authors,
published: pubNode && pubNode.text,
updated: updNode && updNode.text,
primaryCategory: catNode && catNode.attrs && catNode.attrs.term,
pdfUrl,
abstractUrl: abs,
};
});
Returns public holidays for a given country and year via Nager.Date (no auth).
Params year · countryCode
Env —
More detail
Returns: [{ date, localName, name, fixed, global, types }]. Country codes are 2-letter ISO 3166-1 alpha-2 (KR / US / JP / GB / DE / FR / IT / ES / CN / ...).
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
year |
INTEGER |
✓ | Calendar year (e.g. 2026) |
countryCode |
STRING |
2-letter ISO country code (default 'KR') |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Public holidays via Nager.Date (no auth, free).
*
* GET https://date.nager.at/api/v3/PublicHolidays/{year}/{countryCode}
*
* Supports 100+ countries. ISO 3166-1 alpha-2 country codes.
*/
if (year == null) throw new Error('year required');
const y = Number(year);
if (!Number.isInteger(y) || y < 1900 || y > 2100) throw new Error('year out of range');
const cc = (countryCode && countryCode !== '') ? String(countryCode).toUpperCase() : 'KR';
const url = 'https://date.nager.at/api/v3/PublicHolidays/' + y + '/' + encodeURIComponent(cc);
const resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
maxLength: 1_000_000,
});
if (resp.status === 404) return [];
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
return (resp.json() || []).map(h => ({
date: h.date,
localName: h.localName,
name: h.name,
fixed: h.fixed,
global: h.global,
types: h.types || [],
}));
Searches a public subreddit via Reddit's JSON API (no auth, but rate-limited and User-Agent required).
Params subreddit · query · limit · sort
Env —
More detail
Returns up to limit posts: [{ title, author, score, numComments, createdUtc, subreddit, permalink, url, selftext }]. Subreddit can be 'all' for site-wide search.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
subreddit |
STRING |
✓ | Subreddit name (without /r/, or 'all') |
query |
STRING |
✓ | Search query string |
limit |
INTEGER |
Max posts (1-25, default 5) | |
sort |
STRING |
relevance | hot | top | new | comments |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Reddit JSON search endpoint.
*
* GET https://www.reddit.com/r/{subreddit}/search.json?q=...&restrict_sr=1&limit=...
*
* No authentication needed for read access. Reddit BANS empty / generic User-Agent
* strings — we pass a descriptive one. Rate limit: ~60 req/min/IP.
*/
if (subreddit == null || subreddit === '') throw new Error('subreddit required');
if (query == null || query === '') throw new Error('query required');
const n = (Number.isInteger(limit) && limit > 0 && limit <= 25) ? limit : 5;
const sortValid = (sort === 'hot' || sort === 'top' || sort === 'new' || sort === 'comments');
const s = sortValid ? sort : 'relevance';
const sub = String(subreddit).replace(/^r\//, '');
const restrict = (sub.toLowerCase() === 'all') ? '' : '&restrict_sr=1';
const url = 'https://www.reddit.com/r/' + encodeURIComponent(sub) + '/search.json'
+ '?q=' + encodeURIComponent(query)
+ restrict
+ '&limit=' + n + '&sort=' + s;
const resp = await fetch(url, {
headers: { 'Accept': 'application/json',
'User-Agent': 'spring-ai-playground/0.2 (by /u/local-tool)' },
maxLength: 5_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const data = resp.json();
const children = (data.data && data.data.children) || [];
return children.map(c => {
const d = c.data || {};
return {
title: d.title,
author: d.author,
score: d.score,
numComments: d.num_comments,
createdUtc: d.created_utc,
subreddit: d.subreddit,
permalink: 'https://www.reddit.com' + d.permalink,
url: d.url,
selftext: d.selftext,
};
});
Fetches a multi-day weather forecast from Open-Meteo (no auth, 10k req/day for non-commercial). Open-Meteo serves official ECMWF/GFS/ICON model output — far richer than wttr.in but requires lat/lon (use geocodeAddress first if you only have a city name).
Params latitude · longitude · days · timezone
Env —
More detail
Returns: { latitude, longitude, timezone, daily: { time, temperatureMax, temperatureMin, precipitationSum, windSpeedMax }, hourly: { time[24], temperature[24], precipitationProbability[24] } } (first 24 h trimmed).
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
latitude |
NUMBER |
✓ | Latitude (e.g. 37.5665 for Seoul) |
longitude |
NUMBER |
✓ | Longitude (e.g. 126.9780 for Seoul) |
days |
INTEGER |
Forecast days (1-16, default 3) | |
timezone |
STRING |
IANA tz (default 'auto') |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Open-Meteo multi-day forecast.
*
* GET https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&forecast_days=..
* &daily=temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max
* &hourly=temperature_2m,precipitation_probability
* &timezone=...
*
* Lat/lon ranges: latitude ∈ [-90, 90], longitude ∈ [-180, 180].
* The hourly arrays are trimmed to the first 24 h to keep payloads small.
*/
if (latitude == null) throw new Error('latitude required');
if (longitude == null) throw new Error('longitude required');
const lat = Number(latitude), lon = Number(longitude);
if (!Number.isFinite(lat) || lat < -90 || lat > 90) throw new Error('latitude out of range');
if (!Number.isFinite(lon) || lon < -180 || lon > 180) throw new Error('longitude out of range');
const d = (Number.isInteger(days) && days > 0 && days <= 16) ? days : 3;
const tz = (timezone && timezone !== '') ? timezone : 'auto';
const url = 'https://api.open-meteo.com/v1/forecast'
+ '?latitude=' + lat + '&longitude=' + lon
+ '&forecast_days=' + d + '&timezone=' + encodeURIComponent(tz)
+ '&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max'
+ '&hourly=temperature_2m,precipitation_probability';
const resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
maxLength: 5_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const j = resp.json();
const daily = j.daily || {};
const hourly = j.hourly || {};
function trim(arr) { return Array.isArray(arr) ? arr.slice(0, 24) : []; }
return {
latitude: j.latitude,
longitude: j.longitude,
timezone: j.timezone,
daily: {
time: daily.time,
temperatureMax: daily.temperature_2m_max,
temperatureMin: daily.temperature_2m_min,
precipitationSum: daily.precipitation_sum,
windSpeedMax: daily.wind_speed_10m_max,
},
hourly: {
time: trim(hourly.time),
temperature: trim(hourly.temperature_2m),
precipitationProbability: trim(hourly.precipitation_probability),
},
};
Forward-geocodes a free-form address to coordinates via OpenStreetMap Nominatim (no key). Nominatim's usage policy requires a descriptive User-Agent and at most 1 req/s — we set both.
Params address · limit
Env —
More detail
Returns up to limit matches: [{ displayName, latitude, longitude, country, city, type, importance }].
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
address |
STRING |
✓ | Address / place text (e.g. 'Seoul, South Korea' or 'Eiffel Tower, Paris') |
limit |
INTEGER |
Max matches (1-10, default 3) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Forward geocoding via OpenStreetMap Nominatim.
*
* GET https://nominatim.openstreetmap.org/search?q={address}&format=json&limit={n}&addressdetails=1
*
* Honour OSM's policy: descriptive User-Agent, polite cadence (1 req/s).
*/
if (address == null || address === '') throw new Error('address required');
const n = (Number.isInteger(limit) && limit > 0 && limit <= 10) ? limit : 3;
const url = 'https://nominatim.openstreetmap.org/search'
+ '?q=' + encodeURIComponent(address)
+ '&format=json&limit=' + n + '&addressdetails=1';
const resp = await fetch(url, {
headers: { 'Accept': 'application/json',
'User-Agent': 'spring-ai-playground/0.2 (contact: github.com/spring-projects/spring-ai-community)' },
maxLength: 5_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
return (resp.json() || []).map(r => ({
displayName: r.display_name,
latitude: r.lat ? Number(r.lat) : null,
longitude: r.lon ? Number(r.lon) : null,
country: r.address && r.address.country,
city: r.address && (r.address.city || r.address.town || r.address.village),
type: r.type,
importance: r.importance,
}));
Returns sunrise / sunset / twilight times for a given lat-lon and date via sunrise-sunset.org (no auth).
Params latitude · longitude · date · timezone
Env —
More detail
Returns: { sunrise, sunset, solarNoon, dayLength, civilTwilightBegin, civilTwilightEnd } as ISO strings in the requested timezone.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
latitude |
NUMBER |
✓ | Latitude in decimal degrees |
longitude |
NUMBER |
✓ | Longitude in decimal degrees |
date |
STRING |
ISO date (YYYY-MM-DD), defaults to today | |
timezone |
STRING |
IANA tz for the response (default 'UTC') |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* Sunrise / sunset times for a given location and date.
*
* GET https://api.sunrise-sunset.org/json?lat=..&lng=..&date=..&formatted=0
*
* Returns ISO timestamps (formatted=0 prevents the API from returning local
* strings). We convert them into the requested IANA timezone.
*/
if (latitude == null || longitude == null) throw new Error('latitude/longitude required');
const lat = Number(latitude), lon = Number(longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) throw new Error('latitude/longitude must be numeric');
const d = (date && date !== '') ? date : 'today';
const tz = (timezone && timezone !== '') ? timezone : 'UTC';
const url = 'https://api.sunrise-sunset.org/json'
+ '?lat=' + lat + '&lng=' + lon
+ '&date=' + encodeURIComponent(d)
+ '&formatted=0';
const resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
maxLength: 200_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const j = resp.json();
if (j.status !== 'OK') return { success: false, status: j.status };
function inTz(iso) {
if (!iso) return null;
const dt = new Date(iso);
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
});
const parts = {};
for (const p of fmt.formatToParts(dt)) parts[p.type] = p.value;
return parts.year + '-' + parts.month + '-' + parts.day
+ 'T' + (parts.hour === '24' ? '00' : parts.hour) + ':' + parts.minute + ':' + parts.second;
}
const r = j.results || {};
return {
sunrise: inTz(r.sunrise),
sunset: inTz(r.sunset),
solarNoon: inTz(r.solar_noon),
dayLength: r.day_length,
civilTwilightBegin: inTz(r.civil_twilight_begin),
civilTwilightEnd: inTz(r.civil_twilight_end),
timezone: tz,
};
Fetches recent earthquakes from the USGS public catalog (no auth).
Params minMagnitude · lookbackHours · limit
Env —
More detail
Returns up to limit events: [{ time, place, magnitude, type, latitude, longitude, depthKm, url, tsunami }] filtered by minimum magnitude and the past lookbackHours hours.
Parameters
| Param | Type | Req | Description |
|---|---|---|---|
minMagnitude |
NUMBER |
Minimum magnitude (default 4.5) | |
lookbackHours |
INTEGER |
Hours to look back (1-720, default 24) | |
limit |
INTEGER |
Max events (1-100, default 20) |
Sandbox — Runs at sandbox L0 baseline — no filesystem, default-strict network (SSRF-defended).
JS source
/**
* USGS Earthquake API (FDSN web service).
*
* GET https://earthquake.usgs.gov/fdsnws/event/1/query?
* format=geojson&starttime=...&minmagnitude=...&limit=...
*
* `time` field is epoch millis — we convert to ISO. Depth is in km already.
*/
const mm = (minMagnitude == null || minMagnitude === '') ? 4.5 : Number(minMagnitude);
const lh = (Number.isInteger(lookbackHours) && lookbackHours > 0 && lookbackHours <= 720) ? lookbackHours : 24;
const lim = (Number.isInteger(limit) && limit > 0 && limit <= 100) ? limit : 20;
if (!Number.isFinite(mm)) throw new Error('minMagnitude must be numeric');
const start = new Date(Date.now() - lh * 3_600_000).toISOString();
const url = 'https://earthquake.usgs.gov/fdsnws/event/1/query'
+ '?format=geojson&starttime=' + encodeURIComponent(start)
+ '&minmagnitude=' + mm
+ '&limit=' + lim
+ '&orderby=time';
const resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
maxLength: 5_000_000,
});
if (!resp.ok) return { success: false, status: resp.status, message: resp.text() };
const data = resp.json();
return (data.features || []).map(f => {
const p = f.properties || {};
const g = f.geometry && f.geometry.coordinates;
return {
time: p.time ? new Date(p.time).toISOString() : null,
place: p.place,
magnitude: p.mag,
type: p.magType,
latitude: g && g[1],
longitude: g && g[0],
depthKm: g && g[2],
url: p.url,
tsunami: p.tsunami === 1,
};
});
Composition patterns (anonymous-API chains)¶
All 22 run anonymously off rate-limit tiers, so they are the cheapest tools to chain — no credential plumbing, just URL composition:
- City → coordinates → forecast —
geocodeAddress(address)(Nominatim) →getOpenMeteoForecast(lat, lon, days)so the agent can answer "is it raining tomorrow in city?" without hard-coding lat/lon. - Release radar —
getGithubLatestRelease(owner, repo)→ runopenaiResponseGeneratorover the.bodyto get a three-sentence release-notes digest. - Knowledge cross-check —
searchWikipedia(title)+searchHackerNews(query)+searchStackOverflow(query)in parallel; the agent reconciles the three views. - IP triage —
getIpInfo(ip)→ branch oncountry_code/org→ callgetRecentEarthquakes(lat, lon)orgetOpenMeteoForecast(lat, lon)for the same coordinates. - arXiv → summary —
searchArxiv(query)→ top-N abstracts as prompt fragments →openaiResponseGeneratorfor a literature snapshot.
Tutorial 8: Default Tool Recipes walks the first two patterns (geo-anchored weather + release radar) end-to-end.
Keys & secrets¶
None. All 22 endpoints are anonymous. Rate limits are the real constraint:
| Provider | Anonymous quota |
|---|---|
| GitHub (8 tools) | 60 req/h (searchGithubRepos further capped at 10 req/min) |
| Stack Exchange | 300 req / IP / day |
| ipapi.co | 1 000 req / day |
| Open-Meteo | 10 000 req / day (non-commercial) |
| Nominatim (OpenStreetMap) | 1 req / s + descriptive User-Agent required (both honoured by the helper) |
| Wikipedia / HN Algolia / arXiv / restcountries / Nager.Date / USGS / exchangerate.host / CoinGecko / sunrise-sunset | generous (no published per-day cap) |
If you need higher quotas you can fork a tool and add a vendor key — the same ${ENV_VAR} static-variable mechanism the Examples tools use.
→ Tool Studio: SSRF four-layer guard — the network policy these tools run under. → Index — overview of all 86 default tools and the five reference pages.