Extensions
An extension is a small C# script that gives gatto a new tool. These are the ones that ship with it — each read line by line by a person before it went up.
How they work
Each extension is one plain text file that lives in your own home folder, under
~\.gatto\extensions\. gatto compiles them when it starts. There is no
package manager, no dependency tree, and nothing to keep updated.
gatto only ever loads them from your folder — never from the project you have open. That is the part worth understanding: opening someone else's code cannot hand gatto a new tool, because gatto is not looking there. What gatto is able to do is decided by you, on your machine, before you open anything.
To install one, save its file into that folder keeping the name it has here, and restart gatto. That is the whole thing. A one-command install is coming.
⚠ Keep the filename. gatto will also load an extension from a folder of its own, but the copy that ships inside gatto is checked by its exact content — and that check includes where the file sits. A renamed or re-nested copy still runs, but gatto treats it as a stranger's script rather than as its own, and it quietly gets less access. Save it flat, under the name shown.
What ships
Every row expands to its source. Comments about gatto's own internals are trimmed out of what is shown here — they are notes for whoever maintains gatto, not for anyone reading to learn how an extension is put together — so what you see is the code, not the commentary.
⚠ To install one, download the file; do not copy the text off this page. A bundled extension is trusted by its exact contents, and a copy that differs by so much as a comment is treated as an ordinary script — it still works, it just quietly gets less access. Each row links to the real file.
▸ask_userStops and asks you 1-4 multiple-choice questions when gatto needs a decision it cannot make itself.bundled
- host API
Gatto.Register · Gatto.Repl.Term.UnicodeWidth- install
- Download it and save it as
~\.gatto\extensions\ask_user.csx
Comments about gatto's own internals are trimmed here so the code is readable. The file on GitHub is the one to install.
// ask_user — bundled with gatto (vetted: registers read-class, so it never permission-prompts).
// Editing this file drops it to prompting until reverted — your copy is your code.
using System.Text;
using System.Text.Json;
using Gatto.Core.Tools;
using Cells = Gatto.Repl.Term.UnicodeWidth;
Func<string, int, string> cap = (s, max) =>
{
if (Cells.Of(s) <= max) return s;
var sb = new StringBuilder();
var w = 0;
foreach (var rune in s.EnumerateRunes())
{
var rw = Cells.OfRune(rune);
if (w + rw > max - 1) break; // one cell reserved for the ellipsis
sb.Append(rune.ToString());
w += rw;
}
return sb.Append('…').ToString();
};
Gatto.Register(
"ask_user",
"Ask the user 1-4 multiple-choice questions (2-4 options each; user can always type a free-text answer). Use for decisions you cannot make yourself. Each option is a plain string, or an object {\"label\":string,\"description\"?:string,\"recommended\"?:bool} — description stays one short line; if you have a recommendation, mark exactly one option recommended and list it first.",
"""
{"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"question":{"type":"string"},"header":{"type":"string"},"options":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"recommended":{"type":"boolean"}},"required":["label"]}]}},"multi_select":{"type":"boolean"}},"required":["question","header","options"]}}},"required":["questions"]}
""",
async (args, ctx, ct) =>
{
if (!args.TryGetProperty("questions", out var qsEl) || qsEl.ValueKind != JsonValueKind.Array)
throw new ArgumentException("missing required parameter: questions");
var questions = new List<AskQuestion>();
foreach (var q in qsEl.EnumerateArray())
{
if (q.ValueKind != JsonValueKind.Object || !q.TryGetProperty("question", out var qu) || qu.ValueKind != JsonValueKind.String)
throw new ArgumentException("missing required parameter: question");
var question = qu.GetString()!;
if (q.ValueKind != JsonValueKind.Object || !q.TryGetProperty("header", out var hd) || hd.ValueKind != JsonValueKind.String)
throw new ArgumentException("missing required parameter: header");
var header = hd.GetString()!;
if (header.Length is 0 or > 32)
throw new ArgumentException($"header '{header}' must be 1-32 chars");
if (!q.TryGetProperty("options", out var opts) || opts.ValueKind != JsonValueKind.Array)
throw new ArgumentException("each question needs options");
var options = new List<AskOption>();
foreach (var o in opts.EnumerateArray())
{
if (o.ValueKind == JsonValueKind.String)
{
options.Add(new AskOption(o.GetString()!));
continue;
}
if (o.ValueKind == JsonValueKind.Object)
{
if (!o.TryGetProperty("label", out var lb) || lb.ValueKind != JsonValueKind.String)
throw new ArgumentException($"question '{header}' has an option with no label");
var description = o.TryGetProperty("description", out var de) && de.ValueKind == JsonValueKind.String
? de.GetString() : null;
var recommended = o.TryGetProperty("recommended", out var rc) && rc.ValueKind == JsonValueKind.True;
options.Add(new AskOption(lb.GetString()!, description, recommended));
continue;
}
throw new ArgumentException($"question '{header}' has a non-string option");
}
if (options.Count is < 2 or > 4)
throw new ArgumentException($"question '{header}' needs 2-4 options, got {options.Count}");
if (options.Select(o => o.Label).Distinct().Count() != options.Count)
throw new ArgumentException($"question '{header}' has duplicate options");
var multi = q.TryGetProperty("multi_select", out var m) && m.ValueKind == JsonValueKind.True;
questions.Add(new AskQuestion(question, header, options, multi));
}
if (questions.Count is < 1 or > 4)
throw new ArgumentException($"ask_user takes 1-4 questions, got {questions.Count}");
var answers = await Gatto.Ui.AskAsync(questions, ct);
var gloss = string.Join(" · ", answers.Select(a =>
{
var picked = string.Join(", ", a.Selected);
return a.Header + ": " + (picked.Length == 0 ? "skipped" : cap(picked, 40));
}));
gloss = cap(gloss, 120);
return new ToolResult(JsonSerializer.Serialize(
answers.Select(a => new { header = a.Header, selected = a.Selected })), Gloss: gloss);
},
readClass: true);
read from gatto-extensions@5c7d483 · view on GitHub
▸web_searchSearches the web and fetches pages as readable text. Providers are configured in gatto.json.bundled
- install
- Download it and save it as
~\.gatto\extensions\web_search.csx
Comments about gatto's own internals are trimmed here so the code is readable. The file on GitHub is the one to install.
// web_search + web_fetch — bundled with gatto. Shell-class (permission-gated per project); the
// web tools always prompt, by design. Editing this file only changes behavior, not that posture.
using System.Text.RegularExpressions;
using Gatto.Core.Tools;
var rxTimeout = TimeSpan.FromSeconds(2);
var anchorRx = new Regex(@"<a[^>]+rel=[""']nofollow[""'][^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
var snippetRx = new Regex(@"<td[^>]*class=[""']result-snippet[""'][^>]*>(.*?)</td>", RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
var tagRx = new Regex(@"<[^>]+>", RegexOptions.Singleline, rxTimeout);
var numEntRx = new Regex(@"&#x?[0-9a-fA-F]+;", RegexOptions.IgnoreCase, rxTimeout);
Func<string, string> decode = s =>
{
s = numEntRx.Replace(s, m =>
{
var v = m.Value; // "'" or "'"
var inner = v.Substring(2, v.Length - 3);
try
{
int code = inner.Length > 0 && (inner[0] == 'x' || inner[0] == 'X')
? Convert.ToInt32(inner.Substring(1), 16)
: int.Parse(inner);
return char.ConvertFromUtf32(code);
}
catch { return v; } // undecodable ⇒ leave verbatim, never throw
});
s = s.Replace("<", "<").Replace(">", ">").Replace(""", "\"").Replace("'", "'");
return s.Replace("&", "&");
};
Func<string, string> strip = s => tagRx.Replace(s, "");
Func<string, string> clean = s => decode(strip(s)).Trim();
Func<string, string> unwrap = href =>
{
if (href.StartsWith("//duckduckgo.com/l/?uddg="))
{
var m = Regex.Match(href, @"[?&]uddg=([^&]+)", RegexOptions.None, rxTimeout);
if (m.Success) return Uri.UnescapeDataString(m.Groups[1].Value);
}
return href;
};
var searchCfg = Gatto.Config.Section("search");
var chain = new List<string> { "ddg" };
string? tavilyKey = null;
string? searxngUrl = null;
if (searchCfg is { } cfg)
{
if (cfg.TryGetProperty("providers", out var pv) && pv.ValueKind == JsonValueKind.Array)
{
chain.Clear();
foreach (var p in pv.EnumerateArray()) chain.Add(p.GetString()!);
}
if (cfg.TryGetProperty("tavily", out var tv) && tv.ValueKind == JsonValueKind.Object
&& tv.TryGetProperty("apiKey", out var tk) && tk.ValueKind == JsonValueKind.String)
tavilyKey = tk.GetString();
if (cfg.TryGetProperty("searxng", out var sx) && sx.ValueKind == JsonValueKind.Object
&& sx.TryGetProperty("url", out var su) && su.ValueKind == JsonValueKind.String)
searxngUrl = su.GetString()!.TrimEnd('/');
}
Func<string, string> capSnippet = s => s.Length <= 400 ? s : s.Substring(0, 400) + "…";
Func<string, int, List<(string Title, string Url, string Snippet)>> parseJsonResults = (json, count) =>
{
var results = new List<(string, string, string)>();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("results", out var arr) && arr.ValueKind == JsonValueKind.Array)
foreach (var r in arr.EnumerateArray())
{
if (results.Count >= count) break;
var title = r.TryGetProperty("title", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString()! : "";
var url = r.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String ? u.GetString()! : "";
var snip = r.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? capSnippet(c.GetString()!) : "";
if (url != "") results.Add((title, url, snip));
}
return results;
};
Func<string, int, CancellationToken, Task<List<(string Title, string Url, string Snippet)>>> ddg =
async (query, count, ct) =>
{
var res = await Gatto.Http.FetchAsync(
"https://lite.duckduckgo.com/lite/?q=" + Uri.EscapeDataString(query), ct);
var html = res.Text;
if (html.Contains("duckduckgo.com/anomaly.js") || html.Contains("anomaly-modal"))
throw new InvalidOperationException("bot-check challenge (this network is rate-flagged, often for a while)");
var snippets = snippetRx.Matches(html);
var results = new List<(string, string, string)>();
foreach (Match a in anchorRx.Matches(html))
{
if (results.Count >= count) break;
var url = unwrap(a.Groups[1].Value);
var title = clean(a.Groups[2].Value);
var snippet = "";
foreach (Match s in snippets)
if (s.Index > a.Index) { snippet = clean(s.Groups[1].Value); break; }
results.Add((title, url, snippet));
}
return results;
};
Func<string, int, CancellationToken, Task<List<(string Title, string Url, string Snippet)>>> tavily =
async (query, count, ct) =>
{
var body = JsonSerializer.Serialize(new { query = query, max_results = count, include_answer = false });
var res = await Gatto.Http.FetchAsync(
"https://api.tavily.com/search",
new FetchOptions(
Headers: new Dictionary<string, string> { ["Authorization"] = "Bearer " + tavilyKey },
PostJson: body),
ct);
return parseJsonResults(res.Text, count);
};
Func<string, int, CancellationToken, Task<List<(string Title, string Url, string Snippet)>>> searxng =
async (query, count, ct) =>
{
var res = await Gatto.Http.FetchAsync(
searxngUrl + "/search?q=" + Uri.EscapeDataString(query) + "&format=json", ct);
return parseJsonResults(res.Text, count);
};
var providers = new Dictionary<string, Func<string, int, CancellationToken, Task<List<(string Title, string Url, string Snippet)>>>>
{
["ddg"] = ddg,
["tavily"] = tavily,
["searxng"] = searxng,
};
Gatto.Register(
"web_search",
"Search the web. Returns a numbered list of results (title, URL, snippet). Follow up with web_fetch to read a promising page.",
"""
{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"count":{"type":"integer","description":"How many results to return (default 5, max 10)."}},"required":["query"]}
""",
async (args, ctx, ct) =>
{
if (!args.TryGetProperty("query", out var qEl) || qEl.ValueKind != JsonValueKind.String)
throw new ArgumentException("missing required parameter: query");
var query = qEl.GetString();
var count = 5;
if (args.TryGetProperty("count", out var cEl) && cEl.ValueKind == JsonValueKind.Number && cEl.TryGetInt32(out var n))
count = n;
if (count < 1) count = 1;
if (count > 10) count = 10; // clamp, never error
var failures = new List<string>();
var threw = false;
foreach (var name in chain)
{
List<(string Title, string Url, string Snippet)> results;
try { results = await providers[name](query, count, ct); }
catch (Exception ex)
{
threw = true;
var hint = name == "tavily" && ex.Message.Contains("HTTP 401") ? " (check search.tavily.apiKey)" : "";
failures.Add($"{name}: {ex.Message}{hint}");
continue;
}
if (results.Count == 0) { failures.Add($"{name}: no results returned"); continue; }
var blocks = new List<string>();
foreach (var (title, url, snippet) in results)
blocks.Add($"{blocks.Count + 1}. {title}\n {url}\n {snippet}");
var text = string.Join("\n", blocks);
Gatto.Ledger.RecordFetch(query, text, searchOnly: true);
return new ToolResult(text, Gloss: $"{blocks.Count} result{(blocks.Count == 1 ? "" : "s")} · {name}");
}
if (!threw)
return new ToolResult($"no results for '{query}'");
throw new InvalidOperationException(
"web_search failed — " + string.Join("; ", failures)
+ ". Try web_fetch on a site you already know, or configure another provider in gatto.json (search.providers).");
});
Gatto.Register(
"web_fetch",
"Fetch a URL and return its readable text (HTML converted to text; links become [text](url)). Use after web_search to read a promising page.",
"""
{"type":"object","properties":{"url":{"type":"string","description":"The URL to fetch."}},"required":["url"]}
""",
async (args, ctx, ct) =>
{
if (!args.TryGetProperty("url", out var uEl) || uEl.ValueKind != JsonValueKind.String)
throw new ArgumentException("missing required parameter: url");
var url = uEl.GetString();
var res = await Gatto.Http.FetchAsync(url, ct); // ledger recording comes free from the guarded fetch
var s = res.Text;
s = Regex.Replace(s, @"<script[^>]*>.*?</script>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
s = Regex.Replace(s, @"<style[^>]*>.*?</style>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
s = Regex.Replace(s, @"<a[^>]+href=""([^""]*)""[^>]*>(.*?)</a>",
m => "[" + m.Groups[2].Value + "](" + m.Groups[1].Value + ")",
RegexOptions.Singleline | RegexOptions.IgnoreCase, rxTimeout);
s = strip(s);
s = decode(s);
s = s.Replace("\r\n", "\n").Replace('\r', '\n');
s = Regex.Replace(s, @"\n{3,}", "\n\n", RegexOptions.None, rxTimeout);
s = s.Trim();
const int cap = 64 * 1024; // 64 KB of UTF-16 chars
var truncated = s.Length > cap;
if (truncated)
s = s.Substring(0, cap) + "\n[truncated at 64 KB]";
var kb = Math.Max(1, (s.Length + 1023) / 1024);
return new ToolResult(s, Gloss: $"{kb} KB text{(truncated ? " (truncated)" : "")}");
});
read from gatto-extensions@5c7d483 · view on GitHub
Writing your own
The host API is small: one call registers a tool, and gatto handles the rest. Write an extension documents it with a worked example, and there is a plain-text version if you would rather hand the whole thing to gatto and ask it to build you something.
Other sources
gatto will read extension sources the way a package manager does. The shelf above is the source that ships turned on. You will be able to add someone else's repository yourself, and everything in it becomes installable — with the plain warning that nobody here has read it. Coming soon.