Tommy core contracts
The core interfaces as they actually exist in the code, kept current as waves
land. This is the authoritative reference - where it disagrees with
docs/implementation-plan.md, this document is right. If a contract needs to
change, raise it rather than patching it in place; nearly every real gap found so
far was reported by an implementer rather than worked around, and several were
found independently by two or three tasks at once.
Module: github.com/can3p/tommy, Go 1.26. Core depends on spf13/cobra and
pelletier/go-toml/v2 (plus can3p/kleiner, pre-existing) with
PuerkitoBio/goquery for tests; individual providers add their own protocol
libraries. The vendor SDKs are deliberately kept out of this module - they live
in test/integration, a nested module of its own.
Deviations from the plan
Three, all reported rather than silent.
-
plugin.Muxreplaces*http.ServeMuxinRegisterAPI,RegisterUIandRegisterIngress.type Mux interface { Handle(pattern string, handler http.Handler) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) }*http.ServeMuxsatisfies it, so provider code is written exactly as the plan shows and a test can still passhttp.NewServeMux(). The change is required by the plan's own hard requirement that ingress registration "detect collisions between providers AND against reserved core prefixes, failing loudly at startup naming both claimants": routes written straight onto a concreteServeMuxare invisible to the core,ServeMuxpanics without naming the claimant on an exact duplicate, and shadowing patterns (/v3/mail/sendvs/v3/{x}/send) resolve silently. With an interface the ingress sees every registration before it happens. -
Blobs are never evicted.
blob.BlobStore.Putreturnsblob.ErrCapacityExceededonce the cap is reached instead of dropping the oldest blob. The plan requires the blob store's lifetime to be independent of ring-buffer eviction (§7.1); evicting on our own would break the download link of a message still listed in the UI. A loud error is the lesser evil. Clearing events therefore does not clear blobs. -
Snippetinterpolation.SnippetCtxkeeps every field the plan lists and adds anAddrs map[string]stringwithAddr(plugin, provider)andPort(plugin, provider)helpers, so a listener provider added later (TFTP, MLLP, …) needs no new struct field.
Everything else matches the plan.
core/event
type ID string
type Event struct {
ID ID `json:"id"`
Plugin string `json:"plugin"`
Provider string `json:"provider"`
Type string `json:"type"` // "<plugin>.<resource>", free-form
ReceivedAt time.Time `json:"received_at"`
Summary Summary `json:"summary"`
Meta map[string]any `json:"meta,omitempty"`
Payload any `json:"payload,omitempty"`
Raw Raw `json:"raw"`
}
type Summary struct {
From string `json:"from,omitempty"`
To []string `json:"to,omitempty"`
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
}
type Raw struct {
Transport string `json:"transport"` // http | tcp | udp | smtp | ftp | ssh
PeerAddr string `json:"peer_addr,omitempty"`
Method string `json:"method,omitempty"` // http only
Path string `json:"path,omitempty"` // http only
Headers http.Header `json:"headers,omitempty"` // http/smtp; nil elsewhere
Body []byte `json:"body,omitempty"` // bytes, may be binary
Text bool `json:"text"` // render as text, else hex viewer
}
func (e *Event) Clone() *Event // shallow copy; Summary.To is copied
func (e *Event) WithoutRawBody() *Event // what the SSE stream sends
func NewID() string // 24 hex chars, time-sortable, collision-resistant
Rules:
- Events are immutable once appended. The store hands out
Clone()s of the envelope, butMeta,PayloadandRaw.Bodyare shared. Never mutate them afterAppend. Typeis not an enum. A plugin may emit several types and its UI/API must switch on the type rather than assume one payload shape.- Always populate
Rawwith the untouched request. - Bytes never go inline. Attachments and uploads hold a
blob.Ref.
core/store
type Query struct {
Plugin, Provider, Type, Search string
Since time.Time
Limit, Offset int
}
func (q Query) Matches(e *event.Event) bool
type Store interface {
Append(ctx context.Context, e *event.Event) error
List(ctx context.Context, q Query) ([]*event.Event, error) // newest first
Get(ctx context.Context, id event.ID) (*event.Event, error)
Delete(ctx context.Context, id event.ID) error
Clear(ctx context.Context, plugin string) error // "" clears everything
Subscribe(ctx context.Context) <-chan *event.Event
}
var ErrNotFound = errors.New("store: event not found")
AppendassignsIDandReceivedAtwhen empty, writing them back onto the caller's event, then stores its own copy. A duplicate id is an error.Searchis a case-insensitive substring match overSummary.From,Summary.To,Summary.Title,Summary.SnippetandType.Sinceis exclusive.Limit <= 0means no limit.- Ordering is
ReceivedAtdescending, with arrival order breaking ties. Subscribecloses the channel whenctxis done. Delivery is best effort: a subscriber that cannot keep up misses events, becauseAppendsits on the hot path of a fake API answering a real SDK and must never block.
core/store/memory.New(capacity int, opts ...Option) *Store — one ring buffer
per plugin, so a chatty plugin cannot evict a quiet one. Options:
WithPluginCapacity, WithClock, WithIDFunc, WithSubscriberBuffer. Extras
for tests: Len(), Dropped(), Subscribers().
core/blob
type Ref struct {
ID string `json:"id"`
Size int64 `json:"size"`
ContentType string `json:"content_type,omitempty"`
Filename string `json:"filename,omitempty"`
}
type BlobStore interface {
Put(ctx context.Context, r io.Reader, meta Ref) (Ref, error)
Open(ctx context.Context, id string) (io.ReadSeekCloser, Ref, error)
Delete(ctx context.Context, id string) error
Stat(ctx context.Context, id string) (Ref, error)
}
var ErrNotFound, ErrCapacityExceeded error
Put generates an id when meta.ID is empty and fills in the real Size. It
reads at most one byte past the remaining headroom, so an oversized body cannot
blow up memory before the cap is checked. Re-putting an existing id overwrites
it and reuses its space. core/blob/memory.New(limit int64); extras: Used(),
Limit(), Len(), Reset().
Generic download route: GET /api/v1/blobs/{id} (range requests supported,
?inline=1 for an inline Content-Disposition).
Persistent plugin state — core/state, core/persistence
What persists, and what never does. A plugin that owns state beyond the
events it captures — today the s3 catalog and the files tree — can keep it
across restarts. Captured events never persist, whatever is configured:
the event store and the core blob store that events write to are always in
memory. Persisting events was deferred, not forgotten; the reasons and the
prerequisites are in docs/implementation-plan.md → Deferred: persisting
captured events.
// core/state
type Store interface {
Load(ctx context.Context, key string) ([]byte, error) // ErrNotFound if never saved
Save(ctx context.Context, key string, data []byte) error // atomic replace
Delete(ctx context.Context, key string) error
}
func NewFilesystem(dir string) *Filesystem // created on first Save
func NewMemory() *Memory // tests only; never handed out
// core/plugin
type StorageBinder interface { BindStorage(context.Context, Storage) error }
type Storage struct {
State state.Store // nil for a memory scope: nothing to restore or save
Blobs blob.BlobStore // the scope's own store; a blob.Lister when persistent
}
// core/blob
type Lister interface { List(ctx context.Context) ([]Ref, error) }
func Sweep(ctx context.Context, s BlobStore, live map[string]struct{}) (int, error)
- Plugins never see the layout. They save and load opaque snapshots under
keys of their own (lowercase letters, digits,
.,-,_; no leading dot) and store bytes throughStorage.Blobs. The backend decides the rest. - Backends:
memory(the default everywhere, including the container image) andfilesystem. Resolution is provider override > plugin override > global. The filesystem backend gives each scope its own directory:<storage.path>/plugins/<plugin>[/providers/<provider>]/{state,blobs}. Nothing is created until a scope first writes; astorage.paththat exists and is not a directory is a startup error. - Shared state belongs to the plugin. The
s3catalog andfilestree are shared by every provider of their plugin, so they bind at the plugin scope and a provider override cannot split them. No provider owns state today. - Overrides that cannot take effect are refused at startup — one naming an
unknown plugin or provider, or one that keeps no state.
[storage.plugins.mail]would otherwise look as if it kept captured mail. - Lifecycle:
server.NewcallsBindStorageon every enabled binder — plugins, then providers — before any listener binds. An error aborts startup, so a snapshot that cannot be decoded, has an unknown version, or names bytes that are missing is a complaint, never a silent reset. A binder must be ready before any API, UI or ingress handler runs, and must makeStorage.Blobsthe store its state uses — not whichever store some handler's deps offered first.s3does it throughAttach(the first attach wins, and binding comes first);filesreplaces any earlier store outright, refusing if its tree already has entries. - Whole snapshots, versioned. Each stateful plugin rewrites one snapshot
per logical change, carrying a version number; an unknown version stops
startup. This is deliberately simple — tommy is a development tool, and a
snapshot never grows past the plugin's own limits because each one replaces
the last. It is O(catalog) per write; if that ever matters, the
state.Storeinterface admits a journalling backend without changing a plugin. - Concurrency. A mutation bumps a revision under the plugin's own lock;
the save happens after the lock is released, serialised by a second mutex,
and always writes the newest revision — so no disk I/O runs under the
catalog lock, and a slow save can never overwrite a newer snapshot. Saves use
context.WithoutCancel, so a client that disconnects mid-request cannot leave memory and disk diverged. A failed save is returned to the caller: the change is visible in-process but not claimed durable. - Crash ordering. New bytes are written before the snapshot that names
them; replaced or deleted bytes are removed only after a snapshot that no
longer names them is saved. A crash can therefore leave unreferenced bytes,
never a reference to missing bytes. After restoring, the owner calls
blob.Sweepwith its live set to reclaim them. Only the owner may sweep a store, which is why each scope has its own: a sweep can never touch a plugin that is not running this time. storage.blob_limitcaps memory only. Persistent bytes are bounded by the owning plugin's limits (object count and size fors3, entry count and file size forfiles), not by the core cap.- The core blob route still serves persistent bytes.
Server.Blobs()writes and deletes in memory, and reads fall through to every persistent scope, because a captureds3event links to its object's blob. It never deletes from a persistent store; those bytes belong to their plugin.
core/plugin
type Plugin interface {
Name() string // "mail" - url segment, lowercase letters/digits/dashes
Title() string // "Mail" - UI tab label
Description() string // one or two real sentences
Providers() []Provider
RegisterAPI(mux Mux, d Deps) // mounted under /api/v1/<name>/
RegisterUI(mux Mux, d Deps) // mounted under /ui/<name>/
Templates() fs.FS // may be nil
}
type Provider interface {
Name() string
Plugin() string // must equal the plugin that lists it
Description() string
Endpoints() []Endpoint // every mounted route is declared here
Snippets() []Snippet // at least one
RegisterIngress(mux Mux, d Deps)
}
type ListenerProvider interface {
Provider
Listen(ctx context.Context, d Deps) error // reads its ports from d.Config; blocks until ctx done
}
// Transport-agnostic: the core only starts Listen in a goroutine and waits for
// it to return once ctx is cancelled, so nothing here assumes a connection.
// TFTP (UDP, net.ListenPacket), MLLP (framed TCP) and NFSv3 (ONC RPC, two
// programs on one port) all implement it unchanged alongside SMTP, FTP and
// SFTP. A provider whose backend library hands it neither a context nor a
// connection - go-billy, for NFS - can only learn the peer at mount time; the
// contract does not help there. Snippets() is a Provider member, not a Plugin one, which is why a
// plugin core with no provider yet cannot advertise a listener - and why
// plugintest rejects a plugin whose Providers() is empty.
// AddressableProvider is optional, and every listener provider should implement
// it. A provider that took an ephemeral port, or that fell back to its own
// package default because the config named none, is the only thing that knows
// where it ended up listening; without this the discovery surface advertises no
// address at all in exactly those two cases. Addr blocks until the listener has
// bound or the timeout elapses. The server re-resolves these addresses once the
// providers are up, since none have bound when it is built.
type AddressableProvider interface {
ListenerProvider
Addr(timeout time.Duration) (string, error)
}
// PortProvider is optional, and every listener provider must implement it -
// plugintest fails one that does not. It answers "where would this bind under
// this configuration" without binding anything, which Addr cannot do: Addr
// needs a running listener. That is the difference between the two, and the
// precedence follows from it - a bound address always wins, and PortProvider
// is what the discovery surface shows when nothing is running.
//
// Until this existed the default ports lived only in seven package-level
// DefaultPort constants, so `tommy providers` reported no address at all for
// any listener provider on a default run, and nothing that publishes those
// ports - the container image, the compose file, the site's port table - could
// derive them.
type PortProvider interface {
ListenerProvider
ListenPort(pc ProviderConfig) ListenPort
}
// ListenPort is where a listener provider would bind, and what it speaks there.
// Port is the configured value, else the provider's own default; 0 means the
// configuration asked for an ephemeral port, which is unknowable before
// binding and is reported as no port at all rather than as the default.
type ListenPort struct {
Port int `json:"port"`
Network string `json:"network"` // "tcp" | "udp"
}
func (l ListenPort) Ephemeral() bool // Port == 0
func (l ListenPort) String() string // "6969/udp" - the docker EXPOSE and -p form
type Endpoint struct{ Method, Path, Description string }
// Optional, and required of any plugin that mounts API routes: what its own
// OpenAPI description is generated from. Paths are relative to /api/v1/<name>.
type APIDescriber interface{ APIEndpoints() []APIEndpoint }
type APIEndpoint struct {
Method, Path, Description string
Query []APIParam // query parameters; never exhaustive by contract
Response any // zero value of the JSON body; the schema comes from its type
Produces string // media type when it is not application/json
Status int // success status when it is not 200
}
type APIParam struct{ Name, Description, Type string } // Type: "string" (default), "integer", "boolean"
func CommonListParams() []APIParam // the filters every listing inherits from api.ParseQuery
// A Mux that remembers what was registered on it. The ingress needs it to name
// both claimants of a colliding route; the API needs it to check a plugin's
// declarations against what it actually mounted.
type RecordingMux struct{ ... }
func NewRecordingMux() *RecordingMux
func (m *RecordingMux) Patterns() []string // "METHOD /path", sorted
type Snippet struct{ Title, Lang, Code string } // Code is a template over SnippetCtx
func (s Snippet) Render(ctx SnippetCtx) (string, error)
type SnippetCtx struct {
Host, IngressURL, UIURL, APIURL string
SMTPAddr, FTPAddr, SFTPAddr string
Addrs map[string]string // "<plugin>.<provider>" -> host:port
}
func (c SnippetCtx) Addr(plugin, provider string) string
func (c SnippetCtx) Port(plugin, provider string) string
func (c *SnippetCtx) SetAddr(plugin, provider, addr string)
type Deps struct {
Store store.Store
Blobs blob.BlobStore
Config ProviderConfig // = config.ProviderConfig
Logger *slog.Logger
Now func() time.Time
NewID func() string
ConfigDir string // dir of the config file, "" when built in memory
}
func (d Deps) Normalize() Deps // fills Now/NewID/Logger
func (d Deps) WithConfig(pc ProviderConfig) Deps
func (d Deps) WithLogger(args ...any) Deps
func (d Deps) Append(ctx context.Context, e *event.Event) error // stamp + store + collect
// The collector behind that last step. Nothing in a provider touches it.
type EventCollector struct{ ... }
func NewEventCollector() *EventCollector
func (c *EventCollector) IDs() []event.ID
func WithEventCollector(ctx context.Context, c *EventCollector) context.Context
func EventCollectorFrom(ctx context.Context) *EventCollector
Templates rendered with missingkey=error, so a typo in a snippet is a failure
rather than a blank.
Pass the request's own context to Append. Besides cancellation, it is how
the id reaches the collector the ingress put there, and therefore how the
response gets its X-Tommy-Event-URL. A provider that appends with a context of
its own still works; its caller just gets no link.
ConfigDir is the directory the config file was read from, and is empty for
every CLI shortcut, every test, and tommy serve with no -c. It exists for
the one thing a ProviderConfig setting cannot express: where to keep something
a provider generates and then wants back on the next run - the AS2 identity, and
Wave 9's --tls certificate. "Beside the config" is the only location a user
finds without being told, and a provider cannot work it out for itself, since
Deps carries a ProviderConfig rather than the *config.Config that knows.
Prefer it, and fall back to os.UserConfigDir when it is empty.
Anything a provider generates must be generated on first use, not in
RegisterIngress. Registration runs for anything that merely builds a
server, plugintest.Conformance included - so generating eagerly put a real
private key in the user's own config directory during make check. Validate
configured paths eagerly, because a path that does not resolve is a startup
complaint; create nothing until something actually needs it.
Registry
func New(cfg *config.Config, plugins ...Plugin) (*Registry, error)
func (r *Registry) Plugins() []Plugin // enabled, registration order
func (r *Registry) AllPlugins() []Plugin
func (r *Registry) DisabledPlugins() []Plugin
func (r *Registry) Plugin(name string) (Plugin, bool) // enabled only
func (r *Registry) Providers(plugin string) []Provider // enabled only
func (r *Registry) Refs() []Ref // {Plugin, Provider}
func (r *Registry) IngressRefs() []Ref // HTTP providers
func (r *Registry) ListenerRefs() []Ref // ListenerProviders
func (r *Registry) ProviderConfig(plugin, provider string) ProviderConfig
func (r *Registry) DepsFor(base Deps, plugin, provider string) Deps
func (r *Registry) Describe(ctx SnippetCtx) ([]PluginInfo, error)
func (r *Registry) ListenPort(plugin, provider string) (ListenPort, bool) // false for a non-listener
func (r *Registry) ConfiguredAddrs(ctx *SnippetCtx) // fills a SnippetCtx, binds nothing
ConfiguredAddrs writes the configured-or-default address of every listener
provider into a SnippetCtx, and records nothing for an ephemeral one. A
running server then overwrites each entry with what actually bound. Between them
a snippet always renders a real address, which is why no provider template
carries a hardcoded port fallback any more (rule 6).
ProviderInfo carries both halves: addr is where a running listener bound,
and port/network are where it would bind, omitted when ephemeral.
New fails on a duplicate plugin name, a duplicate provider inside a plugin, a
non-URL-safe name, or a provider whose Plugin() disagrees with the plugin that
lists it. Describe is what /api/v1/plugins, the UI panel and
tommy providers all render, so those three never drift.
Registration stays explicit in plugins/all/all.go; no init() magic.
core/plugin/plugintest
func Conformance(t *testing.T, p plugin.Plugin)
func ConformanceProvider(t *testing.T, prov plugin.Provider)
func CheckPlugin(p plugin.Plugin) []error // same checks, no *testing.T
func CheckProvider(prov plugin.Provider) []error
func NewDeps() plugin.Deps // fixed clock, counting ids
func Deps(t testing.TB) plugin.Deps
func SnippetCtx() plugin.SnippetCtx
Fails on: empty or boilerplate descriptions (also on Endpoint.Description),
descriptions under 24 characters, a name that is not URL-safe, zero snippets, a
snippet without a title or language, a snippet that fails to parse or render, a
declared endpoint that is never mounted, and a mounted route that is not
declared — the last two for a provider's ingress routes, and for a plugin's
API routes against APIEndpoints(). A plugin that mounts API routes without
implementing APIDescriber fails too: an undescribed route is one no reader
knows exists. A ListenerProvider with no endpoints is exempt from the route
checks, but owes a port instead: it must implement PortProvider, report a
tcp or udp network and an in-range port, report the port it was configured
with rather than its default, leave an explicit port: 0 ephemeral, and carry
that port in at least one snippet. A task is not done until this passes.
core/config
TOML and the CLI build the same struct and run one bootstrap.
bind = "127.0.0.1" # default interface for the core listeners
host = "localhost" # hostname used in printed URLs and snippets
[ui] port = 8811
[api] port = 8811 # omit to share the UI listener
[ingress] port = 8822
h2c = true # cleartext HTTP/2 alongside HTTP/1.1; on by default
[storage]
capacity = 500 # events retained per plugin
blob_limit = "256MB" # integer bytes or a string like "1.5GiB"; memory only
backend = "memory" # or "filesystem": keeps plugin state, never events
path = "/data/tommy" # required when any scope is "filesystem"
# [storage.plugin_capacity] mail = 2000
# [storage.plugins.s3] backend = "memory"
# [storage.plugins.s3.providers.http] backend = "..." (only for a provider with state)
default_enabled = true # what an unmentioned plugin/provider does
[plugins.mail]
enabled = true
[plugins.mail.providers.smtp]
enabled = true
port = 1025 # a listener provider's own port
type ListenerConfig struct {
Port *int `toml:"port"`
Bind string `toml:"bind"`
H2C *bool `toml:"h2c"` // nil = unset: true for ingress, false for ui/api
}
const DefaultIngressH2C = true
func (c *Config) H2C(surface string) bool // "ui" | "api" | "ingress"
h2c is a property of the listener, not the surface. A listener several
surfaces share serves cleartext HTTP/2 when any of them asks for it, and the
ingress asks by default — so pointing the ingress at the UI port puts h2c on the
listener carrying the UI and the API too. That is safe (a browser never attempts
h2c) and it is logged rather than silent. [ingress] h2c = false clears the
shared listener as well.
func Default() *Config
func Ephemeral() *Config // port 0 everywhere; defaults NOT applied yet
func Load(path string) (*Config, error)
func Parse(data []byte) (*Config, error)
func (c *Config) ApplyDefaults() // idempotent
func (c *Config) Validate() error // reports every problem at once
func (c *Config) PluginEnabled(name string) bool
func (c *Config) ProviderEnabled(plugin, provider string) bool
func (c *Config) Provider(plugin, provider string) ProviderConfig
func (c *Config) SetProvider(plugin, provider string, pc ProviderConfig)
func (c *Config) SetPluginEnabled(plugin string, enabled bool)
func (c *Config) APISharesUIListener() bool
func (c *Config) IngressSharesUIListener() bool
ProviderConfig keeps the unknown keys of its section:
type ProviderConfig struct {
Enabled *bool // nil means "inherit"
Port int // listener providers only - see below
}
func NewProviderConfig(values map[string]any) ProviderConfig
func (p ProviderConfig) Decode(v any) error // into your own toml-tagged struct
func (p ProviderConfig) String(key, def string) string
func (p ProviderConfig) Int(key string, def int) int
func (p ProviderConfig) Bool(key string, def bool) bool
So a provider adds a setting by declaring it in its own struct — the core config
never grows a field for it. tommy mail --enabled-providers mailjet is built by
setting DefaultEnabled = false and switching on just what was asked for.
Ports: 0 means "bind an ephemeral port". Build the config first, then call
ApplyDefaults — "unset" and "0" are only distinguishable before defaulting.
ProviderConfig.Port is honoured for listener providers only. SMTP, FTP and
SFTP each read it in their own LoadConfig, and the core reads it in
listenerAddr purely to report where such a provider bound. Every HTTP provider
is path-routed onto the one shared ingress, which has no per-provider port, so a
port set on one is range- and collision-checked by Validate and then ignored.
Giving an HTTP provider a listener of its own is unbuilt work, not a supported
setting — tommy.toml advertised it for three waves before anyone tried to drive
it from the command line and found nothing listening.
core/server/ingress
func New(logger *slog.Logger, opts ...Option) *Ingress
func WithEventURL(f func(event.ID) string) Option
const LinkHeader = "X-Tommy-Event-URL"
func (i *Ingress) For(plugin, provider string) plugin.Mux
func (i *Ingress) Mount(reg *plugin.Registry, base plugin.Deps) error
func (i *Ingress) Err() error
func (i *Ingress) Routes() []Route
func (i *Ingress) Has(method, path string) bool
func (i *Ingress) SetNotFound(h http.Handler)
func NotFoundHandler(info InfoFunc) http.Handler
func ParsePattern(pattern string) (Pattern, error)
var ReservedPrefixes = []string{"/api/v1/", "/ui/", "/_tommy/"}
Registration fails, naming both claimants, when two providers claim the same
route (wildcard names are normalised, so /a/{id} and /a/{sid} collide;
different methods on one path do not), when a route falls under a reserved core
prefix, when a route would swallow everything (/, /{x...}), or when
net/http rejects the pattern (the panic is turned into an error). Mount also
fails when a declared Endpoint is not reachable.
Note the reserved prefix is /api/v1/, not /api/: a chat provider
legitimately wants POST /api/chat.postMessage.
Unmatched ingress requests get a 404 whose body lists every enabled provider,
its endpoints and a pointer to tommy providers — text, or JSON for a JSON
client.
Every provider response carries X-Tommy-Event-URL, once per event that
request produced, naming the page of what was captured. It is the one header
tommy sends that no vendor does, and it is deliberate: an application's own log
then contains the link with nothing added to the application, and SDKs ignore
response headers they do not know. No provider implements it. Middleware
puts a plugin.EventCollector in the request context, Deps.Append records the
id it assigned, and the wrapper stamps the header before the first write — so a
provider gets this for free precisely as long as it passes r.Context() to
Append, which is how they are all written. WithEventURL takes a function
rather than a base URL because listener ports are not known when the ingress is
built. Without it — every test that constructs an ingress of its own — handlers
are left untouched.
core/server/api — /api/v1
| Route | Notes |
|---|---|
GET /health |
{status, uptime, plugins[], events, version} |
GET /plugins |
descriptions, endpoints, and snippets rendered against the live ports |
GET /events |
?plugin=&provider=&type=&search=&since=&limit=&offset=&include_raw= |
GET /events/{id} |
the full event, raw body included |
GET /events/stream |
SSE, same filters |
DELETE /events |
?plugin= to narrow; 204 |
DELETE /events/{id} |
204, or 404 |
GET /blobs/{id} |
streams a blob with range support |
GET /openapi.json |
the OpenAPI 3.1 description of the events API |
/api/v1/<plugin>/… |
whatever the plugin mounted in RegisterAPI |
GET /<plugin>/openapi.json |
that plugin's own description, mounted by the core |
since accepts an RFC3339 timestamp, a duration (5m = "in the last five
minutes"), or unix milliseconds. Listings omit Raw.Body — they can be
megabytes each; pass include_raw=1 or fetch the single event.
Every event the API returns carries a url: the absolute link to its own
page, on /events, /events/{id} and both SSE streams. It is not a field of
event.Event — events are immutable and stored, and a URL on one would put a UI
concern in the store contract and be wrong the moment a port moved — but of an
envelope that embeds the event, so the wire shape gains exactly one key:
// core/server/ui
func EventURL(origin string, id event.ID) string // origin "" ⇒ site-relative
func Origin(uiURL string, r *http.Request) string
type EventJSON struct { *event.Event; URL string `json:"url,omitempty"` }
func WithURL(e *event.Event, origin string) EventJSON
func WithURLs(events []*event.Event, origin string) []EventJSON
// core/server/api — for a plugin's own read-back handlers
func EventURL(r *http.Request, id event.ID) string
The link is absolute, built from the configured UI URL rather than the
request host, because the caller is usually talking to the ingress — on a port
with no UI on it. api.EventURL reads an origin the core puts in the request
context when it mounts a plugin's API, because a plugin handler has no other way
to learn it: Deps carries a ProviderConfig, not the server's addresses. It
degrades to a site-relative path outside the server, so a handler mounted on a
bare mux in a test still returns something usable.
A plugin API that returns event-shaped resources must carry the same url
field, built from api.EventURL. All six that have one do.
The OpenAPI description
func BuildSpec(opts SpecOptions) *Spec // the events API; ServerURL only
func BuildPluginSpec(o PluginSpecOptions) *Spec // one plugin's API, nil if it mounts none
func (s *Spec) JSON() ([]byte, error) // indented, trailing newline: the checked-in form
func (a *API) Routes() []string // every mounted route, "METHOD /path"
One document per surface. docs/openapi.json is the events API;
docs/openapi-<plugin>.json is that plugin's own read-back API, generated from
its APIEndpoints() and served at /api/v1/<plugin>/openapi.json (mounted by
the core, not by the plugin — it is the same route everywhere, and a plugin that
had to remember it would eventually forget). A reader asserting about mail wants
the mail document, not everything tommy mounts.
The events document describes the events API, and only that: GET/DELETE /events,
GET/DELETE /events/{id}, GET /events/stream, GET /blobs/{id}. That is the
surface every consumer of tommy programs against, whatever it is capturing, and
it is the one worth generating a client from. Out of scope, deliberately and
stated in the document itself:
- the fake vendor endpoints — Mailjet's, Twilio's, Slack's specifications rather than tommy's, and a partial copy of somebody else's API is worse than none;
- each plugin's read-back routes (
/api/v1/mail/messagesand its kin) — they have documents of their own; /healthand/plugins— operational details of one server rather than a contract.
It is generated, never edited:
- Routes come from
eventEndpoints()incore/server/api/openapi.go, an unexported table beside the handlers.Routes()reports what the core actually mounted, and a test asserts every described route is one of them — a described route that is not mounted is a promise the server does not keep. - Schemas come from the Go types by reflection (
core/server/api/schema.go), so a field added to a response cannot be missing from the document. Embedded structs are inlined the wayencoding/jsoninlines them,[]byteis base64,anyis unconstrained, and named struct types become components qualified by package. - The checked-in copies are
docs/openapi.jsonanddocs/openapi-<plugin>.json, produced bymake openapi(which runstommy openapiandtommy openapi <plugin>). Tests regenerate them and fail when a file differs, naming the first differing line. Runmake openapiand commit the result whenever a described route or a type it serves changes. A new plugin with an API also needs its name in the Makefile's list and inplugins/all/openapi_test.go, and both say so when they fail. GET /api/v1/openapi.jsonserves the same document with this server's own absolute URL, so a request can be pasted out of a rendered page and run.info.versionis the API version (v1), not the build version: the latter would rewrite the file on every release for no change a reader can act on.securityis an explicit empty list. The API has no authentication, and saying so is what stops a reader assuming a scheme was forgotten.
SSE frame format
Each event produces two frames:
id: <event id>
data: {"id":"…","plugin":"mail",…,"url":"http://…/ui/events/…"}
← default "message" frame, no Raw.Body
event: mail.message
data: <event id> ← named frame, for hx-trigger="sse:mail.message"
Plus : ping every 25s. Any plain EventSource gets everything through
onmessage; htmx triggers on the type-named frame.
sse.Options.Envelope func(*event.Event) any replaces the JSON payload of the
data frame; it is how the url gets there. Core needs no envelope of its own,
and the sse package does not know what the wrapper is.
core/server/ui — /ui
The shell renders the tab bar from the registry, holds one SSE connection
per page (sse-connect="/ui/stream" on <body>), and serves the vendored
assets from /ui/static/ (htmx 2.0.4 and its SSE extension are committed, never
hotlinked — see core/server/ui/static/VENDOR.md).
Each plugin gets its own mux mounted at /ui/<name>/. Whatever it does not
claim falls back to the generic event view, probed route by route:
| Route | Falls back to |
|---|---|
GET /{$} |
the generic page |
GET /list |
the list fragment |
GET /events/{id} |
the detail fragment |
DELETE /events |
clear, returning the list fragment |
So a new protocol plugin is useful on day one with zero UI code, and a bespoke
tab is an upgrade rather than a prerequisite. /ui/ itself is the cross-plugin
overview.
GET /ui/events/{id} is one event's own page, whatever plugin captured it:
the canonical link, and what every url in the API points at. htmx asking the
same URL still gets the detail fragment, so selecting a row inside a tab is
unchanged and only a browser navigation renders a page.
The page shows the owning plugin's own detail by dispatching an in-process
request to that plugin's /ui/<plugin>/events/{id} fragment route with
HX-Request: true, and embedding the result. A plugin therefore needs to
implement nothing: the route it already serves — or the generic one filled in
for it — is what renders. A plugin that answers with an error or nothing falls
back to the generic inspector. Keep that fragment route free of side effects
and cheap, because the page calls it on every render.
For a plugin handler:
func PluginTemplates(pluginFS fs.FS, patterns ...string) (*template.Template, error)
func Render(w http.ResponseWriter, r *http.Request, title string, body template.HTML) error
func IsPartial(r *http.Request) bool // htmx asked for a fragment
func ShellFrom(r *http.Request) *Shell
func (s *Shell) Info() []plugin.PluginInfo // the plugins in scope of the active tab
Render writes the full shell for a normal request and a bare fragment for an
htmx swap. The shell arrives through the request context, so Deps did not have
to grow a field.
Shell.Info() describes the active tab's plugin — every provider, with snippets
already rendered against the ports this instance actually bound. A bespoke tab
needs it to build the how-to-test panel and a snippet-carrying empty state;
without it, writing your own tab silently costs you the one thing that tells a
newcomer how to fill it. Evaluated on demand, since rendering snippets is not
free and most requests never ask.
core/server/ui/components
components.Template() returns every component with the helpers installed;
PluginTemplates gives you those plus your own files. After parsing your own
templates you must call components.Bind(tpl) (PluginTemplates does it), which
installs render, the helper that makes the layouts compositional:
{{template "master-detail" (dict
"Title" "Inbox"
"List" (render "my-list" .)
"Detail" (render "my-detail" .)
"ListID" "list" "DetailID" "detail")}}
Components: badge, badges, copy-button, kv-table, table,
json-inspector (collapsible, copyable), hex-viewer, raw-viewer (text or
hex, decided by Raw.Text with a content sniff as fallback), master-detail,
stream, snippet, provider-card, how-to-test, empty-state,
event-list, event-detail, event-pane, generic-event-view.
Helpers: jsonView, hexView, isText, pretty, bytesHuman, truncate,
timeShort, timeFull, since, join, lower, asString, dict, badge,
kv, add, hasPrefix, int64, rawMeta, summaryOf, render.
Go types behind them: Badge, KV, KVTable, Cell, Row, Table,
MasterDetail, Stream, JSONView, HexView, EmptyState, HowToTest,
EventView, EventFilter.
how-to-test takes a components.HowToTest{Info []plugin.PluginInfo, Open bool}.
Pass Open true when your tab has nothing to show - an empty tab is exactly
when someone needs to know how to fill it - and false once it has content. Build
it from ui.ShellFrom(r).Info(); plugins/mail/ui.go and plugins/sms/ui.go
both do, and every tab should use this shared component rather than cloning it.
core/server — lifecycle
func New(opts Options) (*Server, error) // binds the core listeners; does not serve
func (s *Server) Start(ctx context.Context) error
func (s *Server) Shutdown(ctx context.Context) error
func (s *Server) Addrs() Addrs
func (s *Server) URLs() (uiURL, apiURL, ingressURL string)
func (s *Server) SnippetCtx() plugin.SnippetCtx
func (s *Server) Store() store.Store
func (s *Server) Blobs() blob.BlobStore
func (s *Server) Registry() *plugin.Registry
func (s *Server) Describe() ([]plugin.PluginInfo, error)
func Run(ctx context.Context, opts Options) error
New restores persistent plugin state (BindStorage, above) and then binds,
so Addrs() is valid before anything is served — that is what makes ephemeral
ports usable — and a failed restore leaves no port bound. It fails when an ingress route collides or a
declared endpoint is unreachable. Start runs every HTTP listener plus every
ListenerProvider in its own goroutine; Shutdown cancels their context,
gracefully stops the HTTP servers, and waits (5s by default).
Every core listener is built through one seam (newHTTPServer + listenerOptions)
which decides the protocols it speaks. The ingress serves cleartext HTTP/2
alongside HTTP/1.1 on the same port — prior knowledge only; the deprecated
Upgrade: h2c handshake is answered as HTTP/1.1, since RFC 9113 removed it. This
uses net/http's own Server.Protocols/SetUnencryptedHTTP2 (Go 1.26), not
golang.org/x/net/http2/h2c, which is deprecated and would fail the staticcheck
gate. Wave 9's --tls adds a field to listenerOptions rather than a second
construction path.
Listener composition: the API shares the UI listener unless it has a port of its own; the ingress shares it when the ports match. On a shared listener the core prefixes win over the ingress catch-all.
core/testutil
func Start(t testing.TB, cfg *config.Config, plugins ...plugin.Plugin) *Instance
cfg == nil means ephemeral ports including listener providers: their config
sections are pinned to port 0 so nothing binds a well-known port. That matters -
config.Ephemeral() alone only zeroes the three core listeners, so a provider
falls back to its package default and a test asking for an ephemeral server binds
1025 or 2121, colliding with a real mail catcher, another test binary, or a stray
server, and failing only sometimes and only on some machines. Pass a config of
your own and an explicit port in it is left alone.
Returns resolved UIURL, APIURL, IngressURL, plus Store, Blobs,
Registry, Config and an http.Client. Every instance is fresh and is shut
down through t.Cleanup.
Helpers: Get, GetBody, GetJSON, PostJSON, Do, Events(q),
WaitForEvents(n, q, timeout), the URL builders API, UI, Ingress, and
Stop(), which shuts the instance down before cleanup so a restart test can
boot a second instance over the same storage.path.
core/testutil/fakeplugin is a complete worked example — an HTTP provider, a
TCP ListenerProvider, endpoints, snippets, a plugin API route and no UI (so it
exercises the generic view). Read it before writing a plugin.
CLI
tommy serve [-c tommy.toml] [--ui-port] [--api-port] [--ingress-port] [--bind] [--host] [--log-level] [--h2c]tommy providers [plugin|plugin/provider] [--json] [-c tommy.toml]— prints descriptions, endpoints and snippets rendered against the ports the current configuration would bind.tommy <plugin>— one single-plugin shortcut per plugin (mail,sms,files,chat), each mirroringserve's flags where they apply plus--in-port,--h2cand--enabled-providers. It builds the sameConfigin memory and runs the same bootstrap; there is no lighter-weight second server.- Provider options are
--<provider>-<option>(--smtp-port,--ftp-passive-ports,--mailjet-api-key, …), contributed only when the flag was actually changed so an unset flag never overrides a provider default. Naming a provider that--enabled-providersexcluded is an error. - Storage, on
serveand every shortcut:--persist PATH(filesystem backend rooted at PATH, made absolute against the working directory) and--storage SCOPE=BACKEND, repeatable, SCOPE beingglobal,<plugin>or<plugin>/<provider>.TOMMY_PERSIST=PATHis--persistfor a container's default command. Precedence: flag > environment > config file. - Every command is
cobra.NoArgs: a stray positional argument is rejected rather than ignored.
A new plugin or provider must extend this surface in the same task
(CLAUDE.md rule 10). A plugin needs its own tommy <plugin>; a provider must
be selectable through --enabled-providers and expose any option worth setting,
or document why not.
Checklist for a plugin or provider task
Description()on the plugin and on every provider: one or two real sentences.- Every mounted ingress route is declared in
Endpoints(), with a description, and every declared endpoint is mounted. - At least one
Snippetthat works from a cold start, written as a template overSnippetCtx— never a hardcoded port. plugintest.Conformance(t, p)in the package tests.- A
README.mdin the directory carrying the same snippet. - Bytes in
blob.BlobStore, never inline in an event.Rawalways populated. - Read-back endpoints serve from the
Store, so an SDK that writes then fetches sees its own write. - Never import another provider's package.
- A listener provider implements both
AddressableProviderandPortProvider, and treatsport: 0as ephemeral, so tests never bind a well-known port.plugintestfails a listener that reports no port, reports a port other than the one it was configured with, substitutes its default for an explicitport: 0, or has no snippet carrying the port it reports;plugins/all/ports_test.goadditionally holds every shipped listener to a distinct, unprivileged default, because that list is what the container image and the site's port table are generated from. - A bespoke tab renders the shared
how-to-testcomponent fromui.ShellFrom(r).Info(), open when the tab is empty. - Anything captured is untrusted: interpolate as a plain string through
html/template, nevertemplate.HTML; check URLs against a scheme allowlist before they reach anhref/src; keep captured HTML out of the page DOM. - Verify wire formats against live vendor documentation, and test a wire protocol with a real client over a socket - both have repeatedly caught errors that hand-built tests did not.
- Keep the CLI level with the config: a new plugin gets a
tommy <plugin>subcommand, a new provider is selectable through--enabled-providers, and a provider option worth setting gets a flag. Nothing should be reachable only through a config file.