agents-go
Plugins

Writing a plugin

Build a provider plugin — embed a base type, implement one method, register it.

A model plugin is two small pieces:

  1. The model — a type that satisfies llm.LLM / stt.STT / tts.TTS / vad.VAD. You don't write all its methods: you embed a base type that carries the boilerplate (name, metrics, lifecycle) and implement just the one method that talks to your provider.
  2. The registration — a tiny factory plus an init so agents.Config{LLM: "yourprovider/model"} resolves once someone imports your package. About 15 lines.

Most of the effort is piece 1; piece 2 is boilerplate. plugins/openai is the full reference implementation to copy from.

The shortcut: embed a base type

Each model interface has a Base* type that implements everything except the work. Embed it, and you implement only:

You're buildingEmbedImplement
LLM*llm.BaseLLMChat — returns a stream of reply chunks
STT*stt.BaseSTTRecognize (one-shot) and/or Stream
TTS*tts.BaseTTSSynthesize (one-shot) and Stream
VAD*vad.BaseVADthe VAD Stream

The base provides Label(), Model(), Provider(), Metrics(), Prewarm(), Close(), and error events — so those aren't yours to write.

Worked example: an LLM provider

Write the model

Embed *llm.BaseLLM, expose a typed constructor, and implement Chat. The base constructor sets the provider/model/label the runtime reports.

package acme

import (
    "context"
    "os"

    "github.com/webdeveloperben/agents-go/llm"
    "github.com/webdeveloperben/agents-go/model"
)

type LLMOptions struct {
    Model  string
    APIKey string // falls back to ACME_API_KEY when empty
}

type LLM struct {
    *llm.BaseLLM
    apiKey string
}

func NewLLM(opts LLMOptions) (*LLM, error) {
    key := opts.APIKey
    if key == "" {
        key = os.Getenv("ACME_API_KEY")
    }

    modelName := opts.Model
    if modelName == "" {
        modelName = "acme-default"
    }

    return &LLM{
        BaseLLM: llm.NewBaseLLM(
            llm.WithBaseProvider("acme"),
            llm.WithBaseModel(modelName),
            llm.WithBaseLabel("acme-" + modelName),
        ),
        apiKey: key,
    }, nil
}

// Chat is the only method you write. It returns a stream the runtime reads for
// text chunks and tool calls; inside the stream you call your provider's API and
// adapt each chunk to llm.LLMStream. See plugins/openai/llm.go for a complete
// streaming implementation to model yours on.
func (m *LLM) Chat(
    ctx context.Context,
    opts llm.ChatOptions,
    conn model.APIConnectOptions,
) llm.LLMStream {
    return newACMEStream(ctx, m, opts) // your llm.LLMStream implementation
}

Make "acme/model" specs resolve

Add a thin modelspec factory that adapts your constructor, and register it in init. model and variant are parsed from the spec ("acme/big:v2"model="big", variant="v2"); Options carries an optional key/base URL.

// register.go
package acme

import (
    "github.com/webdeveloperben/agents-go/llm"
    "github.com/webdeveloperben/agents-go/modelspec"
)

type factory struct{}

func (factory) NewLLM(model, _ string, o modelspec.Options) (llm.LLM, error) {
    return NewLLM(LLMOptions{Model: model, APIKey: o.APIKey})
}

func init() {
    modelspec.Register("acme", factory{}) // add aliases: Register("acme", factory{}, "acme-ai")
}

Because this runs in init, a blank import is all a consumer needs.

Use it

import _ "github.com/you/acme" // registers the provider

session, _ := agents.NewSession(agents.Config{LLM: "acme/big-model"})

The typed constructor still works too — Config{LLM: acme.NewLLM(...)} — for callers who want full options.

Verify

import _ "github.com/you/acme"

m, err := modelspec.ResolveLLM("acme/big-model")

Add a parse/resolve test in the shape of the modelspec tests.

Register factories, not secrets. Read the API key from the environment (or modelspec.Options) inside the constructor — never store credentials in the registry. Register panics on a duplicate name/alias or a value implementing no factory interface, so mistakes surface at startup, not mid-call.

STT, TTS, and VAD

Same shape, different base and method. Their base constructors take the capabilities (and, for TTS, sample rate / channels) the runtime needs to advertise:

// TTS: embed *tts.BaseTTS, implement Synthesize + Stream
b := tts.NewBaseTTS(sampleRate, numChannels, caps, tts.WithBaseProvider("acme"))

// STT: embed *stt.BaseSTT, implement Recognize (+ Stream)
b := stt.NewBaseSTT(caps, stt.WithBaseProvider("acme"))

Your modelspec factory then implements the matching interface — STTFactory.NewSTT / TTSFactory.NewTTS / VADFactory.NewVAD — and one value can implement several (as plugins/openai does for LLM+STT+TTS).

Optional: a discovery identity

If the plugin should be discoverable by capability through plugin.Registry (e.g. "which plugins provide TTS?"), add a plugin.Plugin identity:

func NewPlugin() *Plugin {
    return &Plugin{BasePlugin: plugin.NewPlugin(
        plugin.WithName("acme"),
        plugin.WithCapabilities(plugin.CapabilityLLM),
    )}
}

This is independent of modelspec — it's for discovery, not spec resolution.

Non-model plugins

Transports, telemetry, and kv backends don't register with modelspec; they're constructed and handed to the session or coordinator directly:

KindImplementHanded toExample
Transporttransport.AudioInput/AudioOutput (+ optional agents.Binding)StartLiveKit, Twilio
Telemetrytelemetry.TracerConfig.TelemetryOpenTelemetry
kv backendkv.Storethe coordinatorRedis

On this page