Help us shape the future of Go! Take the 2026 Go Developer Survey

tomte

package module
v0.1.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 21 Imported by: 0

README

Tomte

test Go Reference

Small agents that do one job, in Go.

A tomte is the farm spirit that quietly does the chores at night. A Tomte agent is a Go program that does one task, on a schedule or when asked, and compiles to one small binary: about 6 MB, and about 11 MB of memory while it waits. Dozens fit on a small machine.

  • Safe to leave alone. Every run has a step, time and dollar limit, and a run that fails or stops says so through the agent's channel.
  • Just Go. Tools, jobs and commands are Go functions, and the configuration is Go code: no YAML, no plugins, no dependencies.
  • One command to deploy. tomte deploy box ./agent builds it, uploads it over ssh and runs it as a sandboxed systemd service. Nothing else runs on the machine.

Status: early. Its author runs agents with it, but the API may change before v1.0.

package main

import (
	"context"
	"os"

	"github.com/berkaycubuk/tomte"
	"github.com/berkaycubuk/tomte/channels/telegram"
)

type args struct {
	Days int `json:"days" jsonschema:"minimum age in days"`
}

func findOverdue(ctx context.Context, in args) ([]Invoice, error) { … }

func main() {
	tomte.New("invoice-chaser",
		tomte.Instructions("You chase unpaid invoices. …"),
		tomte.Model("https://openrouter.ai/api/v1", "z-ai/glm-4.6", os.Getenv("MODEL_KEY")),
		tomte.Tool("find_overdue", "List unpaid invoices older than N days", findOverdue),
		tomte.Cron("0 9 * * 1-5", "Chase invoices overdue by 14 days or more."),
		tomte.SendTo(telegram.New(os.Getenv("TELEGRAM_TOKEN"), os.Getenv("TELEGRAM_CHAT"))),
		tomte.Price(0.60, 2.20),
		tomte.MaxCost(0.50, 5.00),
	).Run()
}
./invoice-chaser run "chase the invoices from March"   # one task, now
./invoice-chaser                                       # serve: run on the schedules
./invoice-chaser info                                  # what it is, as JSON

Tomte uses only Go's standard library. See docs/design.md for what it is and isn't, and examples/ for agents to start from.

An agent

Model. tomte.Model(baseURL, model, key) takes any OpenAI-compatible chat completions API: OpenRouter, z.ai, Groq, OpenAI, a local Ollama (http://localhost:11434/v1). Failed calls (network, 429, 5xx) are retried.

Instructions. tomte.Instructions(text) is the system prompt: what the job is and how to do it. Keeping it in instructions.md and embedding it with //go:embed reads well.

Tools. tomte.Tool(name, description, fn) with fn func(context.Context, In) (Out, error). In is a struct; its fields are the tool's arguments, named by their json tag, required unless omitempty, described by their jsonschema tag. Out goes back to the model as JSON, or as-is when it's a string. Errors and panics go back to the model too, so it can try something else. Results over 32 KB are cut.

Schedules. tomte.Cron(spec, task), as many as you like. A spec is five cron fields in the machine's time zone (*/15 9-17 * * mon-fri), a shortcut (@hourly, @daily, @weekly, @monthly, @yearly) or @every 30m. A scheduled run that has nothing worth saying replies NOTHING, and nothing is sent. If a schedule's last run is still going, its next turn is skipped.

Jobs. tomte.Job(spec, name, fn) with fn func(context.Context) (string, error) runs Go code on a schedule, with no model, for chores that need no judgment. What it returns is sent through the channel unless it's empty or NOTHING, an error is sent as a failure, and its runs are in the run log like the model's. ./agent run <name> runs one now. An agent with only jobs needs no tomte.Model. examples/ssh-watch is one.

Channel. tomte.SendTo(channel) sets where results and failures go; it's stdout if not given, and with several channels each result goes to all of them. channels/telegram sends to a Telegram chat and channels/telekinesis to telekinesis, a web app with push notifications by Tomte's author. A channel is two methods, Name() and Send(ctx, Message), so writing your own is short. A Message with To set goes to that recipient (a Telegram chat id) instead of the channel's own, so a job can write to many people. A Message can carry Files, like a report as a PDF: channels/telegram sends each as a document after the text, and a channel that takes text only, like stdout, names the files it left out.

Commands. tomte.Command(name, description, fn) with fn func(context.Context, tomte.Request) (string, error) lets people write to the agent while it's serving: /report example.com runs the report command with Request.Args set to example.com and Request.From to the sender, and what fn returns goes back to them. Commands are Go code, not the model, so writing to the agent can't spend anything. Anything else, /help and /start included unless you declare them, gets the list of commands. tomte.HiddenCommand(name, description, fn) is one left out of that list, for the owner's commands on a public bot; anyone can still send it, so fn checks who's asking. tomte.Owner(from) names the sender who sees the hidden ones listed after the rest. An error is logged and the sender only hears it didn't work. Commands are in the run log, with the channel as their trigger, and an agent with only commands needs no model and no schedule.

Commands need a channel people can write to; channels/telegram is one. It takes commands from its own chat only, unless it's made public:

bot := telegram.New(os.Getenv("TELEGRAM_TOKEN"), "").Public() // anyone who finds the bot
tomte.New("site-check",
	tomte.Command("report", "Check a site now", report),
	tomte.SendTo(bot),
).Run()

Only one program can read a bot's messages, so an agent with commands needs a bot of its own, with no webhook set. A public bot's commands decide for themselves, from Request.From, what a stranger may do.

Limits. Nobody watches a scheduled run, so:

  • tomte.MaxSteps(n): model calls per run, 20 if not given.
  • tomte.Timeout(d): how long a run, job or command may take, 10 minutes if not given; 0 means none. A run that reaches it stops waiting, even for a tool that ignores its context.
  • tomte.Price(in, out): the model's price in dollars per million tokens.
  • tomte.MaxCost(perRun, perDay): dollar limits; 0 means none. A run stops after the call that reaches its limit; a run doesn't start once the day's runs have spent the daily one.

A run that fails or stops at a limit says so through the channel, and run exits with 1.

Run log. Each run adds one JSON line to <name>.jsonl, in $STATE_DIRECTORY or the working directory (tomte.Log(path) to change it): when, what started it, the task, tools called, tokens, cost, and the result or error. The daily limit counts from it. tomte.StateDir() is that directory, for an agent's own files.

tail -n 5 hn-digest.jsonl | jq '{start, trigger, tools, cost, result, error}'

Deploying

An agent is one binary and an env file, run by systemd. The tomte command does the copying over ssh. It needs Go 1.25 or newer where you build, on its latest patch release since the agents are built with it, and on the machine Linux with systemd 247 or newer (Debian 11, Ubuntu 22.04 or later) and an ssh user who may use sudo.

go install github.com/berkaycubuk/tomte/cmd/tomte@latest

tomte setup box                                   # once per machine
tomte deploy -env hn-digest.env box ./examples/hn-digest
tomte ls box
AGENT       STATE        MEMORY   NEXT RUN    LAST RUN               TODAY
hn-digest   active 3h    9.8M     Mon 08:00   2h ago, sent           1 run, $0.004 of $0.25

box is anything ssh takes: a host, user@host or an alias from ~/.ssh/config. Nothing runs on the machine but the agents.

tomte setup box asks for your sudo password once and installs:

  • tomte@.service, the unit every agent runs with. Each agent runs as its own throwaway user (DynamicUser), can write only its state directory (/var/lib/tomte/<name>/, where the run log is), has no capabilities, devices or kernel settings, and only the system calls a service needs, and is capped at 128 MB of memory and half a CPU. An agent built with tomte.ReadsJournal() also joins the systemd-journal group, to read logs like sshd's; it's the only access an agent can ask for.
  • tomte.slice, which all agents run in, so together they can't take more than -memory (1G) and -cpu (100%, one core) from whatever else runs on the machine.
  • /usr/local/libexec/tomte-helper, a short script that does what needs root, and a sudoers rule that lets your ssh user run it, and only it, without a password. It takes only agent names, and touches only tomte@<name> (and its drop-ins), /srv/tomte/<name>/ and /var/lib/tomte/<name>/.

Run it again to upgrade those or change the limits; agents are left alone.

tomte deploy box ./package builds the agent for the machine's architecture, uploads it, asks it its name (./agent info), puts it at /srv/tomte/<name>/<name> and (re)starts tomte@<name>. The binary it replaces is kept as <name>.prev. -env file uploads the agent's variables to /srv/tomte/<name>/env; without it the env file on the machine is kept:

MODEL_URL=https://openrouter.ai/api/v1
MODEL=z-ai/glm-4.6
MODEL_KEY=…
TK_TOKEN=tk_…
TZ=Europe/Istanbul

Runs in progress get 60 s to finish when an agent restarts.

Day to day. Flags go anywhere on the line.

tomte logs gandalf hn-digest            # the last 50 lines of its log; -f to follow, -n for more
tomte runs gandalf hn-digest            # its last 10 runs: when, trigger, cost, outcome; -json for jq
tomte run gandalf hn-digest "send the digest"   # one task now, waiting for it
tomte env gandalf hn-digest             # its variables, values hidden; -values to show them
tomte env -edit gandalf hn-digest       # change them in $EDITOR, then restart it
tomte env -set hn-digest.env gandalf hn-digest  # or replace them from a file
tomte rollback gandalf hn-digest        # back to the binary before the last deploy; again to undo
tomte rm gandalf hn-digest              # stop it and delete it; asks first, keeps its run log
tomte rm -purge -y gandalf hn-digest    # run log too, without asking

tomte run runs the agent as the service would, as its own user, tomte-<name>, and inside the same limits. Its reply goes through its channel, and the run is in the run log and tomte runs like any other.

What's trusted with what

  • The ssh user can deploy, configure, run and remove agents through tomte-helper, and nothing else as root. Since it deploys their code, it can do whatever an agent may do, reading the journal included with tomte.ReadsJournal().
  • An agent runs as its own user, in the sandbox above. It can reach the network, and write only its state directory.
  • Env files hold an agent's secrets in plain text, readable by root and given only to that agent.
  • People writing to a bot reach only its commands, which are Go code. A Telegram bot takes them from its own chat, which for a group means everyone in it; a Public() bot takes them from anyone. Nothing limits how often they write, and a command decides from Request.From what a sender may do, so a command that fetches a URL it's given fetches it for a stranger too.

See SECURITY.md to report a vulnerability.

License

MIT. See LICENSE.

Documentation

Overview

Package tomte builds small agents that do one job. An agent is a Go program: it declares its instructions, model, tools and channel, and compiles to one binary. See docs/design.md.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrCostLimit  = errors.New("cost limit reached")
	ErrDailyLimit = errors.New("daily cost limit reached")
)
View Source
var ErrStepLimit = errors.New("step limit reached")

ErrStepLimit is returned when a run uses all its steps without replying.

View Source
var ErrTimeLimit = errors.New("time limit reached")

ErrTimeLimit is returned when a run, job or command takes longer than the agent's Timeout.

Functions

func StateDir

func StateDir() string

StateDir is the directory the agent may keep files in: its own one, /var/lib/tomte/<name>, when deployed with the tomte command, and the working directory ("") otherwise.

Types

type Agent

type Agent struct {
	// contains filtered or unexported fields
}

Agent is one agent: its declaration, from New, and its run log. Run is its main; Do runs it on one task from Go code. An Agent is safe to use from several goroutines.

func New

func New(name string, opts ...Option) *Agent

New declares an agent. Mistakes in the options are reported when it runs, so the declaration reads as one expression.

Example

An agent that chases unpaid invoices every weekday morning and tells a Telegram chat what it did.

package main

import (
	"context"
	"os"

	"github.com/berkaycubuk/tomte"
	"github.com/berkaycubuk/tomte/channels/telegram"
)

type overdueArgs struct {
	Days int `json:"days" jsonschema:"minimum age in days"`
}

type invoice struct {
	Number   string  `json:"number"`
	Customer string  `json:"customer"`
	Amount   float64 `json:"amount"`
}

func findOverdue(ctx context.Context, in overdueArgs) ([]invoice, error) {
	return []invoice{{"2026-041", "Acme", 1200}}, nil
}

func main() {
	tomte.New("invoice-chaser",
		tomte.Instructions("You chase unpaid invoices. List the overdue ones and say who to remind."),
		tomte.Model("https://openrouter.ai/api/v1", "z-ai/glm-4.6", os.Getenv("MODEL_KEY")),
		tomte.Tool("find_overdue", "List unpaid invoices older than N days", findOverdue),
		tomte.Cron("0 9 * * 1-5", "Chase invoices overdue by 14 days or more."),
		tomte.SendTo(telegram.New(os.Getenv("TELEGRAM_TOKEN"), os.Getenv("TELEGRAM_CHAT"))),
		tomte.Price(0.60, 2.20),
		tomte.MaxCost(0.50, 5.00),
	).Run()
}

func (*Agent) Do

func (a *Agent) Do(ctx context.Context, task string) (Result, error)

Do runs the agent on one task, logs the run and returns its reply. It doesn't send the reply anywhere; Run does that.

func (*Agent) Run

func (a *Agent) Run()

Run is the agent's main: it reads the command line, does what it says and exits.

type Channel

type Channel interface {
	Name() string
	Send(ctx context.Context, m Message) error
}

Channel is where an agent's results go, given with SendTo. Name says what it is in logs; Send delivers one message, and should give up when ctx is done.

func Stdout

func Stdout() Channel

Stdout prints results; it's the default channel.

type File added in v0.1.1

type File struct {
	Name string
	Data []byte
}

File is a document sent with a message, like a report as a PDF. Name is what the recipient sees it as, "report.pdf".

type Incoming

type Incoming struct {
	From string
	Text string
}

Incoming is a message someone wrote to the agent. From is the sender in the channel's own terms; a Message To them answers it.

type Message

type Message struct {
	Agent string
	Text  string
	To    string
	Files []File
}

Message is what an agent sends: markdown text, from the named agent. To is who gets it, in the channel's own terms (a Telegram chat id); a channel sends a message without one to its usual recipient. Files go with the text, after it; a channel that can't carry files says in the text which ones it left out.

func (Message) TextOnly added in v0.1.1

func (m Message) TextOnly() string

TextOnly is m's text with a line for each file the channel can't send, so the recipient knows something is missing: what a text-only channel sends.

type Option

type Option func(*Agent)

Option is part of an agent's declaration, given to New: Model, Tool, Cron and the rest.

func Command

func Command(name, description string, fn func(context.Context, Request) (string, error)) Option

Command answers "/name …" sent to the agent through its channel, while it's serving. What fn returns goes back to the sender unless it's empty or NOTHING. An error is logged, and the sender only hears it didn't work: errors are written for whoever runs the agent, not for strangers.

Example

A command answers "/echo hello" written to the bot, with Go code.

package main

import (
	"context"
	"os"
	"strings"

	"github.com/berkaycubuk/tomte"
	"github.com/berkaycubuk/tomte/channels/telegram"
)

func main() {
	echo := func(ctx context.Context, r tomte.Request) (string, error) {
		return strings.ToUpper(r.Args), nil
	}
	tomte.New("echo",
		tomte.Command("echo", "Say it back, louder", echo),
		tomte.SendTo(telegram.New(os.Getenv("TELEGRAM_TOKEN"), os.Getenv("TELEGRAM_CHAT"))),
	).Run()
}

func Cron

func Cron(spec, task string) Option

Cron runs task on a schedule while the agent is serving. An agent can have several.

func HiddenCommand added in v0.1.2

func HiddenCommand(name, description string, fn func(context.Context, Request) (string, error)) Option

HiddenCommand is a Command left out of the list of commands, for one only the agent's owner uses, like /approve on a public bot. The Owner still sees it listed. Anyone can send it, so fn checks who's asking.

func Instructions

func Instructions(s string) Option

Instructions is the agent's system prompt: who it is and what its job is.

func Job

func Job(spec, name string, fn func(context.Context) (string, error)) Option

Job runs fn on a schedule while the agent is serving, and from the command line with `run <name>`. What fn returns is sent through the channel unless it's empty or NOTHING; an error is sent as a failure.

Example

A job is Go code on a schedule, with no model. It replies NOTHING when there's nothing to say, and nothing is sent.

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/berkaycubuk/tomte"
	"github.com/berkaycubuk/tomte/channels/telegram"
)

func main() {
	diskCheck := func(ctx context.Context) (string, error) {
		free := 42 // percent, from syscall.Statfs in a real agent
		if free > 10 {
			return "NOTHING", nil
		}
		return fmt.Sprintf("Disk is %d%% free.", free), nil
	}
	tomte.New("disk-watch",
		tomte.Job("@hourly", "disk", diskCheck),
		tomte.SendTo(telegram.New(os.Getenv("TELEGRAM_TOKEN"), os.Getenv("TELEGRAM_CHAT"))),
	).Run()
}

func Log

func Log(path string) Option

Log sets the file the run log is appended to. It's <name>.jsonl in $STATE_DIRECTORY (set by systemd's StateDirectory=) or else in the working directory.

func MaxCost

func MaxCost(perRun, perDay float64) Option

MaxCost limits what the agent spends, in dollars, per run and per day (local time). Zero means no limit. It needs Price.

func MaxSteps

func MaxSteps(n int) Option

MaxSteps caps the model calls in one run; it's 20 if not given.

func Model

func Model(baseURL, name, key string) Option

Model sets the OpenAI-compatible API the agent thinks with, for example Model("https://openrouter.ai/api/v1", "z-ai/glm-4.6", os.Getenv("MODEL_KEY")).

func Owner added in v0.1.3

func Owner(from string) Option

Owner is the sender, in the channel's terms, who sees hidden commands in the list of commands, so they don't have to remember them.

func Price

func Price(in, out float64) Option

Price is what the model costs, in dollars per million input and output tokens, as the provider lists it. Runs are priced only when it's given.

func ReadsJournal

func ReadsJournal() Option

ReadsJournal lets the agent read the system journal, sshd's and sudo's lines included, when it's deployed with the tomte command: its user joins the systemd-journal group. Nothing else changes.

func SendTo

func SendTo(cs ...Channel) Option

SendTo sets where the agent's results go; it's Stdout if not given. With several channels each result goes to all of them.

func Timeout added in v0.1.1

func Timeout(d time.Duration) Option

Timeout limits how long one run, job or command may take; it's 10 minutes if not given, and 0 means no limit.

func Tool

func Tool[In, Out any](name, description string, fn func(context.Context, In) (Out, error)) Option

Tool lets the model call fn. In must be a struct; its fields are the tool's arguments (see schema.go for the tags).

Example

A tool's input is a struct: its fields are the arguments the model fills in, required unless omitempty.

package main

import (
	"context"
	"fmt"

	"github.com/berkaycubuk/tomte"
)

func main() {
	type args struct {
		City  string `json:"city" jsonschema:"the city to look up"`
		Units string `json:"units,omitempty" jsonschema:"metric or imperial; metric if not given"`
	}
	weather := func(ctx context.Context, in args) (string, error) {
		return fmt.Sprintf("12°C and raining in %s", in.City), nil
	}
	_ = tomte.Tool("weather", "The weather in a city now", weather)
}

type Receiver

type Receiver interface {
	Channel
	Receive(ctx context.Context, handle func(Incoming)) error
}

Receiver is a channel people can write to. Receive calls handle with each message until ctx is done, and returns then, or earlier when receiving can't work at all. handle may wait while the agent is busy.

type Request

type Request struct {
	From string // the sender, in the channel's terms: a Telegram chat id
	Args string // what followed the command's name, trimmed
}

Request is a command someone sent.

type Result

type Result struct {
	Text      string   // the model's final reply
	Steps     int      // model calls
	ToolCalls []string // tool names, in the order they were called
	Usage     Usage
	Cost      float64 // dollars, 0 without a Price
}

Result is what one run did.

type Usage

type Usage struct {
	PromptTokens     int `json:"prompt_tokens"`
	CompletionTokens int `json:"completion_tokens"`
}

Usage is the tokens one or more model calls took.

Directories

Path Synopsis
channels
telegram
Package telegram sends an agent's results to a Telegram chat through a bot.
Package telegram sends an agent's results to a Telegram chat through a bot.
telekinesis
Package telekinesis sends an agent's results to a person through telekinesis (https://telekinesis.berkay.tools), which shows them in its web app with a push notification.
Package telekinesis sends an agent's results to a person through telekinesis (https://telekinesis.berkay.tools), which shows them in its web app with a push notification.
cmd
tomte command
Command tomte deploys and watches Tomte agents on a machine over ssh.
Command tomte deploys and watches Tomte agents on a machine over ssh.
examples
dice command
Dice is the smallest useful tomte: it rolls dice when asked.
Dice is the smallest useful tomte: it rolls dice when asked.
hn-digest command
HN digest sends a short morning digest of the Hacker News stories that match your interests to telekinesis.
HN digest sends a short morning digest of the Hacker News stories that match your interests to telekinesis.
ssh-watch command
SSH watch tells you on Telegram when someone logs in to this machine over ssh as a user, from an address or with a key it hasn't seen before.
SSH watch tells you on Telegram when someone logs in to this machine over ssh as a user, from an address or with a key it hasn't seen before.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL