A read through agent-tincan's Go CLI: typed errors mapped to exit codes, best-effort network calls capped at 5 seconds, and config sniffing that reads before it parses.
· 8 min read

Agent Tincan in Go: Cobra Exit Codes, Context Deadlines, and MCP Config Discovery


cmd/tincan/main.go in agent-tincan is about twenty lines long, and the whole file hinges on one call: cli.ExitStatus(err), which returns an exit code and a silent flag. That second return value lets a command report a non-zero outcome after it has already written the complete result, without making main print another line.

Agent Tincan lets AI agents on a Tailscale tailnet ask each other to do things. One agent asks, another replies, and a relay sits in the middle. The source is worth reading because one Go binary plays several roles, talks HTTP to other instances of itself, shells out to other tools, and parses config files written by applications it does not control.

One binary, two roles, one root context

The package comment in cmd/tincan/main.go says the same binary runs the relay and the per-agent client. Version lives in a package-level var Version = "0.0.1-dev" that the release build overwrites at link time with -ldflags, and main copies it into cli.Version before anything else runs.

The root command goes out through cli.Root().ExecuteContext(context.Background()). Small line, real consequences. ExecuteContext stores the context on the command tree so handlers can retrieve it with cmd.Context(). Tincan does that in important paths: r.Join(cmd.Context(), args[0]) in the join command, r.FetchAttachment(cmd.Context(), args[0]) in internal/cli/attachment.go, and runDoctor(cmd.Context(), exe, configs) in internal/cli/doctor.go.

That gives cancellation one entry point for handlers that propagate the command context. Replacing the root context.Background() with signal.NotifyContext would make those requests interruptible, but it would not automatically cover every request: connect starts its relay-info refresh from a new background context, and a few shutdown paths create their own contexts deliberately. Those paths need a separate audit. When adding a local deadline, derive it from cmd.Context() so the operation observes both Ctrl-C and the shorter deadline. If the Cobra command tree is new to you, our guide to generating a CLI application with Cobra covers the scaffolding.

Mapping errors to exit codes without scattering os.Exit

internal/cli/root.go defines an ExitError with a code, a Silent flag, and an optional wrapped error. ExitStatus finds that type even after another layer wraps it; ordinary errors fall back to exit code 1 and remain printable.

type ExitError struct {
	Code   int
	Silent bool
	Err    error
}

func (e *ExitError) Error() string {
	if e.Err != nil {
		return e.Err.Error()
	}
	return fmt.Sprintf("exit status %d", e.Code)
}

func (e *ExitError) Unwrap() error { return e.Err }

The JSON paths show why the second return value matters. printResultJSON writes a complete machine-readable result, then returns &ExitError{Code: code, Silent: true} for pending or failed outcomes. The process gets a useful non-zero status without appending a human error line that would corrupt or duplicate the JSON output.

Here it is stripped down to something you can paste into your own tool:

package main

import (
	"errors"
	"fmt"
	"os"
)

type exitError struct {
	code   int
	silent bool
	err    error
}

func (e *exitError) Error() string {
	if e.err != nil {
		return e.err.Error()
	}
	return fmt.Sprintf("exit status %d", e.code)
}

func (e *exitError) Unwrap() error { return e.err }

func exitStatus(err error) (code int, silent bool) {
	if err == nil {
		return 0, true
	}
	var e *exitError
	if errors.As(err, &e) {
		return e.code, e.silent
	}
	return 1, false
}

func main() {
	if err := run(); err != nil {
		code, silent := exitStatus(err)
		if !silent {
			fmt.Fprintln(os.Stderr, "tool:", err)
		}
		os.Exit(code)
	}
}

func run() error {
	fmt.Println(`{"outcome":"pending"}`)
	return &exitError{code: 2, silent: true}
}

os.Exit skips deferred functions. Keeping it in one place at the top of main means code deeper in the call stack cannot unexpectedly skip a flush or file close. It also makes tests simpler: call the command function, inspect the returned error with errors.As, and check the derived status without shelling out to a subprocess.

Same instinct drives the output handling. internal/cli/attachment.go writes to cmd.OutOrStdout() and uses cmd.Printf, never fmt.Println. Hand it a bytes.Buffer in a test and you can assert on the exact bytes a user would see.

Best-effort network calls need a hard deadline

The connect helper in internal/cli/agent.go loads the saved config, builds a relay client, and if the config is short on relay details, goes and asks:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
client.LearnRelayKey(ctx, r)
cancel()

No error check. That’s deliberate: LearnRelayKey itself returns no error and quietly leaves the saved relay metadata stale when the refresh fails, so a later connect can try again. The comments in the file explain the 5-second bound too: a relay that accepts the connection and then stops answering must not hold up join, rejoin, or the startup of a long-running service. The HTTP request is created with the context, so its deadline cancels the request.

There’s a subtler call buried here. join and rejoin refresh eagerly through learnRelayInfo rather than leaning on connect to do it lazily, because a box that only ever runs history serve or web serve never runs another command that would trigger the lazy path. Lazy caching goes stale on precisely the hosts you least want it to.

The reusable shape:

// refreshWithin gives a context-aware, best-effort refresh a deadline.
func refreshWithin(ctx context.Context, d time.Duration, refresh func(context.Context) error) {
	ctx, cancel := context.WithTimeout(ctx, d)
	defer cancel()
	// Error deliberately dropped: the next call tries again.
	_ = refresh(ctx)
}

A context deadline is cooperative: the refresh function still has to observe the context. If you drop an error on the floor, write down why. Your reviewer and your linter both need to know it was a choice.

Invalidate derived config when its source changes

setRelay in internal/cli/agent.go is six lines that preserve an important consistency property:

func setRelay(cfg *client.Config, url string) {
	if strings.TrimRight(url, "/") != strings.TrimRight(cfg.Relay, "/") {
		cfg.RelayKey, cfg.RelayURLs, cfg.RelayInfoAt = "", nil, time.Time{}
	}
	cfg.Relay = url
}

The key, the cached address list, and the refresh timestamp all belong to the old relay. Point the config at a new URL, have the follow-up learn step fail, and without this you’re left holding a new address paired with the previous relay’s key. So the derived fields die the instant the source of truth moves, not on the next successful refresh. That ordering is the whole point.

Two smaller things are worth copying. The comparison normalizes trailing slashes with strings.TrimRight on both sides, so http://relay and http://relay/ do not look like a relay change. The reset also writes the zero value of each type in one multiple assignment, time.Time{} included; the later age check treats that zero timestamp as needing a refresh.

Anonymous structs keep one-off JSON response shapes local

internal/cli/connect.go and internal/cli/doctor.go both reach the relay through the same generic r.Raw(ctx, method, path, body, &out), and both declare their response type inline at the call site. The doctor’s /v1/whoami check declares a struct with name and relay_key; admin connect declares one with code and url.

That’s the right call when a shape has one consumer. Nothing to name, nothing to export, nothing that drifts out of sync across packages. Standard library version:

func getJSON(ctx context.Context, c *http.Client, url string, out any) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	resp, err := c.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("%s: %s", url, resp.Status)
	}
	return json.NewDecoder(resp.Body).Decode(out)
}

// Caller side: the shape lives where it is used.
var me struct {
	Name     string `json:"name"`
	RelayKey string `json:"relay_key"`
}
if err := getJSON(ctx, http.DefaultClient, relay+"/v1/whoami", &me); err != nil {
	return err
}

The limits are real: no methods on an anonymous struct, no reuse. Second caller shows up, promote it to a named type and move on. Do not start copy-pasting field lists between files to preserve the aesthetic.

Sniffing other people’s config files cheaply

internal/cli/doctor_configs.go is where tincan hunts for MCP server entries across a dozen agent apps: Claude, Cursor, Codex, Grok, Gemini, Windsurf, more. Every file it touches was written by someone else’s tool, in someone else’s format, possibly last week, possibly malformed.

mcpConfigFiles assembles one candidate slice from os.UserHomeDir, os.Getwd, hardcoded paths, and environment overrides like GROK_HOME and TINCAN_AGY_MCP_CONFIG. Unset env vars contribute empty strings, and those get filtered downstream instead of guarded upstream. That keeps the candidate list a flat literal rather than twenty nested if blocks, which is the kind of tradeoff that reads sloppy and maintains well. A seen map[string]bool dedupes, then os.Stat throws out anything missing or directory-shaped.

findMCPConfigs does the reading, and it starts with the cheap test:

raw, err := os.ReadFile(f)
if err != nil || !strings.Contains(string(raw), "tincan") {
	continue
}

No parser runs until a substring match indicates that the file may contain a Tincan entry. Only after that does the code branch on extension to TOML, YAML, or JSON. It is a cheap, case-sensitive prefilter rather than proof that parsing will find a valid entry, but it avoids parsing clearly irrelevant files.

The mcpConfigEntry struct in the same file has one more move worth noting. Reported fields (File, Scope, Name, Command, Args, Problems) are exported with JSON tags; two bookkeeping fields stay lowercase:

broken bool // a problem that stops the app from running tincan
unread bool // names tincan, but tincan could not read its command

encoding/json skips unexported fields, so tincan doctor --json emits a clean payload while the decision logic keeps the state it needs. Nicer than sprinkling json:"-" on fields that were never meant to leave the package. The one catch: an external test package cannot inspect those fields directly. It can still test exported behavior or the JSON output; tests that need the internal flags themselves belong in package cli.

Pick one of these to steal this week. My vote is the silent flag, because doubled error output is the cheapest bug to ship and the most annoying one to read in CI logs.

If you’re building Go tooling that sits between agents and the outside world, the CrabTrap and Stash writeups cover adjacent ground on policy and persistence.