(^· ·^)⟆ gatto · an AI assistant that runs on your own PC

Write an extension

An extension is a C# script file that gatto compiles when it starts. It gives gatto a new tool: something it can decide to call, the same way it decides to read a file or run a command. There is no SDK, no build step and no manifest to fill in.


Where extensions live

Only in your home folder:

~\.gatto\extensions\my_tool.csx
~\.gatto\extensions\my_tool\main.csx     <-- a folder works too, one level deep

gatto never loads an extension from the project you have open. That is the whole security posture in one sentence: opening someone else's repository cannot hand gatto a new tool, because gatto is not looking there. What it can do is decided by what is in your folder, before you open anything.

Extensions are discovered at launch. Add a file, restart gatto, and the tool is there.

This page describes version 5 of the host API, the one gatto 0.5.4 carries. A new gatto build recompiles every extension the first time it launches.


The shape of a tool

One call registers one tool. The host object is bound as Gatto:

Gatto.Register(
    "tool_name",
    "What the model reads when it decides whether to call this.",
    """{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}""",
    async (args, ctx, ct) => new ToolResult("the text the model sees", Gloss: "one short line"));

Four arguments, and each has a job:

  • the name: lowercase, with underscores. This is what the model calls.
  • the description: written for the model, not for a human reading docs. It is the only thing the model has when it decides whether this tool is the right one. Say what it does and when to reach for it.
  • the schema: a JSON Schema object describing the arguments, as a string. C# raw string literals ("""…""") let you paste JSON without escaping every quote. It is parsed once, at registration, so a malformed schema fails at launch rather than on the first call.
  • the body: an async delegate taking the parsed arguments, a context, and a cancellation token. Return a ToolResult.

What comes back

new ToolResult(string Text, bool IsError = false, string? Gloss = null)

Text is what the model reads. IsError marks a failure the model should react to rather than treat as data. Gloss is the one short line a person sees in the transcript; keep it to a few words, because it is a status line, not the result.

Validation failures should throw. gatto catches the exception and hands the model the message as an error result, so throw new Exception("query must not be empty") does the right thing and you never build the error envelope yourself.


A worked example

A small tool that fetches a URL and hands the model the text of the page. It is a simplified cousin of the web_fetch that ships with gatto.

// page_text.csx -- fetch a URL and return its text.
using System.Net.Http;
using System.Text.RegularExpressions;
using Gatto.Core.Tools;

var http = new HttpClient();
// Always set a Timeout explicitly. The default is 100 seconds and it will not be
// what you meant; if you want the caller's token to be the only deadline, say so.
http.Timeout = Timeout.InfiniteTimeSpan;

Gatto.Register(
    "page_text",
    "Fetch a web page and return its visible text. Use when the user gives you a URL.",
    """{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}""",
    async (args, ctx, ct) =>
    {
        var url = args.GetProperty("url").GetString() ?? "";
        if (!url.StartsWith("http://") && !url.StartsWith("https://"))
            throw new Exception("url must start with http:// or https://");

        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        cts.CancelAfter(TimeSpan.FromSeconds(30));
        var html = await http.GetStringAsync(url, cts.Token);

        var text = Regex.Replace(html, "<script.*?</script>|<style.*?</style>", " ",
                                 RegexOptions.Singleline | RegexOptions.IgnoreCase);
        text = Regex.Replace(text, "<[^>]+>", " ");
        text = System.Net.WebUtility.HtmlDecode(text);
        text = Regex.Replace(text, @"\s+", " ").Trim();
        if (text.Length > 20000) text = text.Substring(0, 20000) + " ... [truncated]";

        return new ToolResult(text, Gloss: $"{text.Length} chars from {new Uri(url).Host}");
    });

Save that as ~\.gatto\extensions\page_text.csx, restart gatto, and ask it what is on a page.

Two things the example is showing you on purpose

  • The timeout is explicit. An HttpClient with a default timeout will cut your call off at 100 seconds no matter what deadline the caller thought it had. Set it, then own the real deadline with a linked token.
  • Fetched text is not trusted. Whatever comes back is someone else's writing, and the model will read it as if you handed it over. Strip it, bound it, and do not let it in unbounded.

Hooks

Besides tools, an extension can watch what the session does:

Gatto.On("tool_call", async payload => { /* runs before a tool executes */ });

Four events: tool_call, tool_result, message_end, session_summary. One of them is different in a way worth knowing: a tool_call handler that throws blocks the tool: that is how a hook says no. The other three fail open, so a broken handler cannot take the session down with it. tool_result handlers are observe-only.

Gatto.Log("...") writes to gatto's extension log, which is where to put anything you want to read afterwards.


An endpoint

An extension can also give gatto somewhere new to talk to a model. The endpoint is picked with -e, like one written in gatto.json, and default_endpoint can name it:

// my_cloud.csx -- an endpoint gatto can talk to, picked with -e my_cloud.
using Gatto.Core.Client;

Gatto.Endpoint(
    "my_cloud",
    "https://api.example.com",
    context: 200000,
    models: new[] { "big-model", "small-model" },
    headers: async ct => new Dictionary<string, string>
    {
        ["Authorization"] = "Bearer " + Environment.GetEnvironmentVariable("MY_CLOUD_KEY"),
    },
    quota: usage => usage.TryGetProperty("requests_left", out var left)
        ? new QuotaReading(left.GetInt64(), "requests")
        : null);
  • the name: lowercase letters, digits and underscores, as with a tool.
  • the address: an absolute https URL, or http on loopback. Leave out the /v1, gatto adds it.
  • context: the window in tokens, the budget gatto keeps the conversation inside.
  • models: what /model lists on this endpoint. The first is the one a launch takes when nothing else names one.
  • headers: runs before each request and returns the headers to send. Read a key from the environment here rather than writing it into the file.
  • quota: reads the usage object of each reply and returns what is left on a limit, or null. The footer shows it.
  • thinking: a JSON object with one request fragment per effort level, the same shape as thinking on an endpoint in gatto.json.

Everything but the name and the address is optional. The checks run when the extension loads, so a bad address or a repeated model name stops it there and not on the first request.


One line of standing guidance

A tool's description says how to call it. It is read when the model is already choosing between tools it has noticed. Some rules have to fire earlier than that: this is a moment to stop and ask is not advice about an argument, it is advice about a moment, and a description is the wrong place for it.

Gatto.Policy("When a choice is the user's to make, stop and ask with ask_user instead of choosing for them.");

The line goes into the session's opening instructions, once, when the extension loads. It is bound to the tool, not merely to its own file: if the extension goes, the rule goes with it in the same stroke, and there is no state where one exists without the other. A rule you write yourself, in a notes file or a role, has no such tether. Remove the tool it describes and the sentence stays behind, still telling the model to reach for something that is no longer there.

  • One line per extension. A second call is an error and the extension does not load.
  • One line, literally: no line breaks, and at most 200 characters. It is a rule, not a paragraph, and it is in every request of the session.
  • It is not required. Most extensions want none: if the sentence would still be true with the tool uninstalled, it belongs somewhere else.

Every loaded extension's line is shown under /tools, beneath the tools it came with, so a user who wonders where a behaviour came from can see the sentence and the file that brought it.

An extension may declare a line and register no tool at all. That is a valid script: standing guidance with no capability attached.


Testing it

  • A compile error is loud. If the script does not compile, gatto reports the diagnostics at launch and carries on without it; it never half-loads.
  • Ask gatto to call it. The fastest check is a real session: start gatto and ask for the thing your tool does. If the model never reaches for it, the description is the problem, not the code.
  • Check the gloss in the transcript. If the one-line summary does not tell you what happened, it will not tell you when something has gone wrong either.
  • Write it with gatto. Ask gatto to write an extension for itself, then read what it produced before you drop it in.

Getting it onto this site

The extensions listed on the extensions page were each read line by line by a person before they went up. If you have written something you think belongs there, open a pull request against the gatto-extensions repository and say what it does and why it needs the access it asks for.

Nothing stops you installing your own without asking anyone: it is your folder. The review is what the listing on this site means, not what running an extension requires.

GGitHub XX BBluesky MMastodon RReddit Windows 10 / 11 GPL-3.0
(^· ·^)⟆   zzz

nothing here yet: a game goes in this space

Close gatto.computer
(^· ·^)⟆

Are you sure you want to close this window?

It's now safe to close
this tab.
…or come back.