Writing a plugin
Build a provider plugin — embed a base type, implement one method, register it.
A model plugin is two small pieces:
- 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. - The registration — a tiny factory plus an
initsoagents.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 building | Embed | Implement |
|---|---|---|
| LLM | *llm.BaseLLM | Chat — returns a stream of reply chunks |
| STT | *stt.BaseSTT | Recognize (one-shot) and/or Stream |
| TTS | *tts.BaseTTS | Synthesize (one-shot) and Stream |
| VAD | *vad.BaseVAD | the 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:
| Kind | Implement | Handed to | Example |
|---|---|---|---|
| Transport | transport.AudioInput/AudioOutput (+ optional agents.Binding) | Start | LiveKit, Twilio |
| Telemetry | telemetry.Tracer | Config.Telemetry | OpenTelemetry |
| kv backend | kv.Store | the coordinator | Redis |
Related
- Reference implementation:
plugins/openai(LLM/STT/TTS in one package). - Plugins overview · Models & providers
- Reference: Models — the interfaces and base types.