SwitchTenderSwitchTender Docs

Extend SwitchTender in Go

SwitchTender's seams are public. Import one package, github.com/kordloom/switchtender/sdk, register what you built, and run it either way: compiled into the server binary, or dropped next to a stock release binary as a plugin the server loads at startup. A registered tool submits, validates, executes, and audits like a built-in. The SDK covers four kinds of extension:

The rules

Register from an init function or from main before the server starts. Registries are read while serving and never written after, so registration is a startup step, not a runtime one. An empty, duplicate, or reserved name panics at boot, where the mistake is cheap, instead of failing on first use.

An extension loads one of two ways. Compile it into the binary for a single static build with no version skew. Or ship it as a plugin binary in the server's --plugins-dir for a stock release binary that loads extensions at startup. Same seams, same registration, same behavior at run time.

A complete extension

One file makes a SwitchTender with a custom hello tool. Start a module and take the current release, since everything the server enforces at the door comes with the version you build against:

mkdir switchtender-hello && cd switchtender-hello
go mod init example.com/switchtender-hello
go get github.com/kordloom/switchtender@latest

main.go:

// Package main builds a SwitchTender server with a custom tool compiled in through the SDK.
package main

import (
    "context"
    "fmt"
    "io"

    "github.com/kordloom/switchtender/cmd"
    "github.com/kordloom/switchtender/sdk"
)

// init registers the tool before the server starts.
func init() {
    sdk.RegisterTool("hello", sdk.ToolRunnerFunc(
        func(_ context.Context, spec sdk.ToolSpec, out io.Writer) (sdk.ToolResult, error) {
            fmt.Fprintf(out, "hello from an external plugin: %s\n", spec.Command)
            return sdk.ToolResult{ExitCode: 0}, nil
        }))
}

// main runs the stock SwitchTender CLI with the extension compiled in.
func main() {
    cmd.Execute(nil)
}

Build it, run it, and submit a run that names the new tool:

go mod tidy && go build -o switchtender-hello .
./switchtender-hello serve --addr :8080 --db switchtender.db

The first start mints an admin token and prints it once; send it as the bearer token:

curl -s -X POST localhost:8080/v1/runs \
  -H "Authorization: Bearer $ST_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"tool":"hello","command":"world"}'

The run is accepted, executed, and audited like any built-in tool, and its log holds the runner's output:

curl -s -H "Authorization: Bearer $ST_TOKEN" localhost:8080/v1/runs/<id>/logs
hello from an external plugin: world

The binary keeps every stock command: serve, worker, desktop, demo, import, token, user, and audit. Passing nil to cmd.Execute skips the embedded documentation pages in the UI. Embed your own file tree there to serve them.

Ship it as a plugin binary

The same extension runs as its own process, loaded by a stock SwitchTender release binary. Swap the main for plugin.Serve and build:

// Command helloplugin serves the hello tool as a SwitchTender plugin.
package main

import (
    "context"
    "fmt"
    "io"

    "github.com/kordloom/switchtender/sdk"
    "github.com/kordloom/switchtender/sdk/plugin"
)

// main serves the extension as a plugin process.
func main() {
    plugin.Serve(&plugin.Extension{
        Tools: map[string]sdk.ToolRunner{
            "hello": sdk.ToolRunnerFunc(
                func(_ context.Context, spec sdk.ToolSpec, out io.Writer) (sdk.ToolResult, error) {
                    fmt.Fprintf(out, "hello from a plugin process: %s\n", spec.Command)
                    return sdk.ToolResult{ExitCode: 0}, nil
                }),
        },
    })
}

Drop the binary in a directory and point the server at it:

go build -o plugins/hello-plugin .
switchtender serve --plugins-dir ./plugins

At startup the server launches each executable in the directory, asks what it provides, and registers every seam it declares. One plugin serves any mix of tools, notifiers, AI providers, and secret engines from a single Extension. The process speaks gRPC over a local socket with mutual TLS, supervised by the server. It starts with the server and exits with it. The worker command takes the same flag, so a plugged-in tool runs wherever runs execute.

A plugin starts with a deliberately small environment: PATH, HOME, the temporary directory and locale variables, SYSTEMROOT and USERPROFILE on Windows, and every variable named with the SWITCHTENDER_PLUGIN_ prefix. Names match exactly, except on Windows, where case does not tell two variables apart. The plugin library adds its own handshake: SWITCHTENDER_EXTENSION, plus PLUGIN_MIN_PORT, PLUGIN_MAX_PORT, PLUGIN_PROTOCOL_VERSIONS, and PLUGIN_CLIENT_CERT, which carry the port range, the protocol versions, and SwitchTender's certificate for mutual TLS. plugin.Serve reads those, so a plugin never has to. Everything else the server holds is withheld, its encryption key and every secret it reads from its own environment included. Configure a plugin through SWITCHTENDER_PLUGIN_ variables. A plugin that reads any other name finds it empty, and one that exits for want of configuration is skipped like any plugin that fails to launch.

A plugin that fails to launch or describe itself is logged and skipped, so one broken binary does not take the server down. A name that collides with a built-in or with a plugin already loaded is refused the same way: the plugin is skipped whole, nothing it declared is registered, its process is ended rather than left holding a socket, and the reason is logged as a warning naming the file. The server starts. A dropped-in binary claiming bash would otherwise receive every bash run on the fleet, and refusing it is not a reason to take the control plane down with it.

Plugins are read in filename order, so where two declare the same name the first one loaded keeps it and the second is the one refused. Check the startup log after adding a plugin: a skipped one is a warning, not a failure, so the server running is not by itself proof that everything you installed is loaded. Compiling in and plugging in register the same way and behave the same at run time. Pick per extension: compile in for one static artifact, plug in for extending a release binary you did not build.

Discord, ntfy, and Microsoft Teams are built-in notification channels, configured with the --notify-discord, --notify-ntfy, and --notify-teams flags, so they need no plugin. ServiceNow change requests and Jira issues are the official switchtender-notify plugin's, from kordloom/switchtender-plugins: label a run with its change number or issue key, and when the run ends it is recorded on that ticket as a work note or a comment. Write a channel of your own the same way the tool above is written: a Notifier on the same Extension, compiled in or dropped in.

The seams in detail

Execution tools

A ToolRunner receives the run as a ToolSpec and a writer for its output. Command carries the tool's input, the same field the bash and python tools read their script from. DryRun asks for the tool's no-change mode. Nothing scans what a registered tool does in that mode, so the gate never reads its dry run as change free, and a rule with exclude_dry_run matches it as the real run. ExtraVars, Env, and Dir carry the run's variables, environment, and working directory. Return the process exit code in ToolResult. Return an error only when the tool could not be launched or supervised.

Notification channels

A Notifier receives each top-level run once it reaches a terminal state: succeeded, failed, canceled, interrupted, or rejected. Delivery happens off the executor path with a bounded timeout, and a failed delivery is logged and dropped, so a slow channel cannot stall runs.

AI providers

An AIProviderFactory builds a provider from three settings: the model name, the endpoint URL, and an API key. Validate what you require and fail at startup, not on first use. The provider's one method turns a system instruction and a user prompt into text.

Secret engines

A SecretResolver fetches a value from a source's config at run time. A SecretMinter does the same for a dynamic engine and also returns a lease built with NewSecretLease, naming the engine and capturing how to revoke the minted credential. Pass a nil revoke func when the secret only expires on the engine's own TTL. A lease built this way is revoked only by the process that minted it, so a secret minted for a run whose process dies first expires on the engine's own TTL.

See also the secrets guide, Bash runs for how a tool's command and variables behave, and the HTTP API for submitting runs to your new tool.