A function hook wired by main, exit codes carried by error values, and why agent-chrome-relay hand-parses interleaved flags and arguments.
· 7 min read

Agent Chrome Relay in Go: Function Hooks, Exit-Code Errors, and Ordered JSON


The most revealing line in agent-chrome-relay is in cmd/chrome-relay/main.go:

cli.Serve = relay.Run

That assignment is explicit wiring, but it does not break an import cycle. At this snapshot, internal/cli/serve.go already imports internal/relay for relay.Options, while the relay package does not import the CLI package. The variable is a replaceable function hook: the CLI supplies argument handling and options, and the binary entry point supplies the real server implementation.

Chrome Relay is a macOS tool that lets coding agents (Claude Code, Codex, Cursor) drive background tabs in your everyday Chrome over CDP, signed in as you, through an extension plus a local relay. The browser automation is the headline; the CLI patterns underneath are reusable in less specialized tools.

Wiring the server with a package-level function hook

internal/cli declares an exported function variable, main replaces its default implementation, and cli.ServeCmd calls it. Shortened to the relevant lines, the source looks like this:

// package cli
var Serve = func(o relay.Options) error {
	return errors.New("relay not built yet")
}

func ServeCmd(args []string, version string) int {
	dev, _ := devMode(args)
	in, err := thisInstance(dev)
	if err != nil {
		return 1
	}
	if err := Serve(in.serveOptions(version)); err != nil {
		return 1
	}
	return 0
}

A package-level function variable is global mutable state, and parallel tests that replace it would need synchronization. The default function avoids a nil-call panic and produces a deliberate error if the binary forgets the wiring. This snapshot does not use the hook in tests, so it is best described as an explicit seam rather than evidence of looser package coupling.

An injected interface or function field on a command struct would usually make ownership and test isolation clearer. Here the package variable keeps the entry point short, but the source still has a direct CLI-to-relay type dependency.

main.go does one more tidy thing on the way to dispatch. A command helper walks os.Args[1:] and returns the first argument that isn’t --dev, so a global flag can sit in front of the subcommand without derailing the router. Then it branches: serve to cli.ServeCmd, everything else to cli.Main. Both hand back an int that goes straight into os.Exit.

Exit codes carried as errors

That int is load-bearing. internal/cli/do.go defines:

type exitCode struct {
	code int
	msg  string
}

func (e exitCode) Error() string { return e.msg }

Here’s why it exists. chrome-relay do "<goal>" runs a goal in an agent tab, filling whatever --data names, replaying saved flows, and asking a fast decision model for everything left over. When the model isn’t confident, the tool stops and hands the work back to the calling agent. The usage text documents that as exit code 2, separate from 1, which means something actually broke.

Command functions in Go return error. Adding a second return value for the status code means threading it through every intermediate call, and intermediate calls are exactly where people drop things. Wrapping the code inside an error type keeps the one-value signature and lets the top level dig it back out:

package main

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

type exitCode struct {
	code int
	msg  string
}

func (e exitCode) Error() string { return e.msg }

func run() error {
	return fmt.Errorf("handing back: %w", exitCode{code: 2, msg: "not confident"})
}

func main() {
	err := run()
	if err == nil {
		return
	}
	fmt.Fprintln(os.Stderr, err)

	var ec exitCode
	if errors.As(err, &ec) {
		os.Exit(ec.code)
	}
	os.Exit(1)
}

errors.As is the piece that survives wrapping. A middle layer can bolt on context with %w and the code still comes through intact. Humans skim stderr. Agents branch on $?. If your tool is going to be driven by a script or another program, the exit code is the real API. We walked through a Cobra flavour of the same trick in Agent Tincan in Go.

Repeatable flags without the flag package

parseDo in internal/cli/do.go is a hand-rolled for i := 0; i < len(args); i++ loop. It starts from a struct with defaults already in place (maxSteps: 25, timeout: 120 * time.Second, decider: "auto", minConf: 0.5) and overwrites fields as it walks. A next closure handles the “this flag takes a value” case and returns a readable error when the value is missing.

So why skip the standard library? --until, --data and --allow can each appear several times, and --until text:Saved carries a kind:value pair split by strings.Cut. The plain flag helpers give you one value per flag and nothing more.

You can get repeatability out of flag by implementing flag.Value, and you should know how before deciding against it:

package main

import (
	"flag"
	"fmt"
	"strings"
)

type datum struct{ Key, Value string }

type dataFlag []datum

func (d *dataFlag) String() string {
	parts := make([]string, 0, len(*d))
	for _, x := range *d {
		parts = append(parts, x.Key+"="+x.Value)
	}
	return strings.Join(parts, " ")
}

// Set is called once per occurrence of the flag.
func (d *dataFlag) Set(v string) error {
	k, val, ok := strings.Cut(v, "=")
	if !ok || strings.TrimSpace(k) == "" {
		return fmt.Errorf("--data %q: use key=value", v)
	}
	*d = append(*d, datum{Key: strings.TrimSpace(k), Value: val})
	return nil
}

func main() {
	var data dataFlag
	fs := flag.NewFlagSet("do", flag.ContinueOnError)
	fs.Var(&data, "data", "a value for the goal (repeatable)")
	if err := fs.Parse([]string{"--data", "Email=ada@x.com", "--data", "Name=Ada"}); err != nil {
		return
	}
	fmt.Println(data.String()) // Email=ada@x.com Name=Ada
}

flag.Var accumulates because Set fires on every occurrence. The awkward part for Chrome Relay is the positional goal before later options. The standard flag package stops parsing at the first non-flag argument, leaving the remaining tokens in FlagSet.Args; it would not parse the trailing --until options automatically. Supporting this command grammar therefore still needs a second pass or a custom parser. If you want the fundamentals first, see our guide to the flag package.

JSON that keeps the order a human wrote it in

internal/cli/jsonx.go defines an object type:

type object struct {
	keys []string
	vals map[string]any // *object, []any, string, json.Number, bool or nil
}

The comment gives the motive. identities.json and config.json are edited by hand, and the CLI writes them back in the same key order. Decoding into map[string]any loses source order; the standard JSON encoder currently sorts string map keys, which is deterministic but still not the order the person wrote.

A slice records order and a map provides lookup. set leaves an existing key in place and appends a new one; del updates both structures. Numbers also remain json.Number rather than being decoded through float64. json.Decoder.UseNumber() preserves the original decimal spelling and avoids precision loss for large integer identifiers.

The same instinct shows up in internal/cli/decider.go, which pulls the decider object out of config, calls dc.del("jev") or dc.set(which, "off"), then deletes the whole key once len(dc.keys) == 0. No orphaned {} sitting in a file a person is going to read.

Validate a credential before you store it

decider.go has one more move I’d lift wholesale. Run pbpaste | chrome-relay decider jev and the key does not go straight into the Keychain. The tool builds a minimal probe request and fires it first:

ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
probe := &decide.Request{Body: map[string]any{ /* a one-question page state */ }}
if _, err := (&decide.Jev{Key: key}).Ask(ctx, probe); err != nil {
	return fmt.Errorf("not saved: %v", err)
}
if err := macos.KeychainSet(keychainService, jevAccount, []byte(key)); err != nil {
	return fmt.Errorf("Keychain: %v", err)
}

A bounded context.WithTimeout so a hung endpoint can’t wedge the command, an early return that literally says “not saved”, and the write only after. Store-now-fail-later turns a pasted-wrong key into a baffling automation failure forty minutes into a run, by which point nobody connects it to the credential. If contexts are new territory, our context primer covers the ground.

The guard above it matters too. macos.Linked is a compile-time constant recording whether this build has the macOS security layer, and the error message spells out the fallback: install Xcode command-line tools for a linked build, or set TYPESAFE_API_KEY. internal/macos splits into macos_darwin.go and macos_other.go, so other builds expose the same API with Linked false. Build constraints keep runtime OS branches out of the callers.

Of these patterns, the exit-code error is the most broadly reusable: it survives %w wrapping and lets scripts distinguish a deliberate hand-back from a failure. The custom parser is justified by this command’s interleaved grammar, while the package-level server hook carries more tradeoffs. For a different take on driving Chrome from Go, we pulled apart how gemini-config-checker finds and drives a Chromium browser.