Event Bus
The Event Bus is how plugins talk to each other without knowing about each other. If the Service Container is the "give me this service" mechanism, the Event Bus is the "something happened, whoever cares can listen" mechanism.
The Golden Rule
Use the Event Bus when you don't need a return value. Use the Service Container when you do.
If Plugin A fires "fzf:opened" and Plugin B (the status bar) needs to update its display, that's the Event Bus. If Plugin A needs to call a method on Plugin B and get a result back, that's a shared service through the container.
1. Raw Event Bus (Untyped)
Publishing
ctx.EventBus().Publish("fzf:opened", map[string]any{
"query": "current search term",
})
// Sugar — shorthand via ctx directly (in plugins)
ctx.Emit("fzf:opened", map[string]any{"query": "current search term"})
// Sugar — shorthand via app directly (in main.go)
app.Emit("fzf:opened", map[string]any{"query": "current search term"})Publish is synchronous on the caller's goroutine — handlers are called inline before Publish returns. However, the Bubble Tea adapter bridges this into async by using go p.Send(busEventMsg{e}) inside its wildcard subscription, so from a TUI perspective events are non-blocking.
The event is also appended to an internal queue that adapters drain via the contracts.EventQueue interface on each render tick.
Subscribing
func (p *Plugin) OnActivate(ctx *tako.Context) error {
// Option 1 — scoped to a context (auto-pruned when ctx is cancelled)
cancelFn := ctx.EventBus().Subscribe(ctx, "fzf:opened", func(e contracts.Event) {
p.currentState = "Searching"
})
p.cancelSub = cancelFn
// Option 2 — sugar: Subscribe using ctx as the scoped context
cancelFn = ctx.Subscribe(ctx, "fzf:opened", func(e contracts.Event) {
p.currentState = "Searching"
})
// Option 3 — On: lives until cancel is called (context.Background internally)
cancelFn = ctx.On("fzf:opened", func(e contracts.Event) {
p.currentState = "Searching"
})
// Option 4 — On directly on the bus
cancelFn = ctx.EventBus().On("fzf:opened", func(e contracts.Event) {
p.currentState = "Searching"
})
// Option 5 — Once: fires exactly once then auto-unsubscribes
ctx.Once("app:ready", func(e contracts.Event) {
p.initUI()
})
return nil
}
func (p *Plugin) OnDeactivate(ctx *tako.Context) error {
if p.cancelSub != nil {
p.cancelSub()
}
return nil
}Subscribe returns an unsubscribe function. Call it in OnDeactivate to prevent memory leaks. Alternatively, pass a context that you control — when the context is cancelled, the bus automatically prunes the subscriber on the next Publish to that event.
The Wildcard Subscription
Subscribing to "*" receives every event published on the bus:
ctx.EventBus().Subscribe(ctx, "*", func(e contracts.Event) {
fmt.Printf("Event: %s\n", e.Name)
})The framework's Profiler uses this to record all events for the live inspector. You can use it for debugging, audit logging, or any cross-cutting observer.
2. Typed Event Bus (Recommended)
The standard contracts.EventBus stores event payloads as any. That's flexible, but it means every subscriber has to type-assert the payload — and if the publisher changes the shape, your subscriber silently breaks at runtime.
The Typed Event Bus wraps the standard bus with Go generics to give you compile-time safety on event payloads.
The Problem It Solves
// ❌ The untyped way — fragile
ctx.EventBus().Subscribe(ctx.Context, "search:result", func(e contracts.Event) {
result := e.Data.(SearchResult) // panics if Data is something else
fmt.Println(result.Hits)
})If the publisher sends a different type by mistake, this panics at runtime. No compiler warning, no early feedback.
// ✅ The typed way — safe at compile time
type SearchResult struct { Query string; Hits int }
searchBus := event.NewTypedBus[SearchResult](ctx.EventBus())
searchBus.Subscribe(ctx.Context, "search:result", func(e event.TypedEvent[SearchResult]) {
fmt.Println(e.Data.Hits) // e.Data is SearchResult — guaranteed
})Setup
TypedBus is a thin wrapper around any contracts.EventBus. Create one per payload type (or per event name — whatever makes sense for your domain):
import "github.com/takoterm/tako/internal/event"
// One TypedBus per payload type.
// Typically created in a plugin's OnInit or in a shared package.
searchBus := event.NewTypedBus[SearchResult](ctx.EventBus())
clockBus := event.NewTypedBus[time.Time](ctx.EventBus())TypedBus[T] wraps the same underlying bus. Typed and untyped subscribers on the same event name coexist — both will receive the event. No migration required.
Publishing
searchBus.Publish("search:result", SearchResult{Query: "golang", Hits: 42})Under the hood this calls bus.Publish("search:result", result) — the raw event is published normally. Untyped subscribers (including the profiler's wildcard subscriber) still receive it.
Subscribing
cancel := searchBus.Subscribe(ctx.Context, "search:result", func(e event.TypedEvent[SearchResult]) {
// e.Name — the event name string
// e.Data — SearchResult, fully typed, no assertion needed
log.Printf("Search '%s' returned %d hits", e.Data.Query, e.Data.Hits)
})
defer cancel() // unsubscribe when done (call in OnDeactivate)The returned cancel function works exactly like the one from the raw bus.
Wildcard Subscription
SubscribeAll subscribes to "*" but only delivers events whose payload can be decoded as T:
// Receives ALL events on the bus, but only fires when payload is a SearchResult
searchBus.SubscribeAll(ctx.Context, func(e event.TypedEvent[SearchResult]) {
log.Printf("Search event: %+v", e.Data)
})Events with non-matching payloads are silently skipped — no crash, no error.
How Payload Decoding Works
TypedBus tries to get a T from the raw any payload in two steps:
Step 1 — Direct assertion (fast path): If the publisher used the same concrete type T, this succeeds in O(1) with no allocation.
Step 2 — JSON round-trip (slow path): Handles cross-package scenarios where the publisher sent a map[string]any with compatible field names, or a different-but-structurally-compatible struct. DisallowUnknownFields ensures that a structurally unrelated type (e.g. a completely different struct with different fields) won't accidentally decode as T.
Incompatible payload: If both steps fail, the handler is simply not called. No panic, no error log. This is intentional — you don't want one malformed event to crash an otherwise-healthy subscriber.
Type Safety on the Same Event Name
Multiple TypedBus instances with different types can share the same event name. Each only fires for payloads that match its type:
type SearchResult struct { Query string; Hits int }
type SearchError struct { Query string; Err string }
resultBus := event.NewTypedBus[SearchResult](bus)
errorBus := event.NewTypedBus[SearchError](bus)
// Two subscribers on the same event name:
resultBus.Subscribe(ctx, "search:done", handleResult)
errorBus.Subscribe(ctx, "search:done", handleError)
// Publisher sends a SearchResult — only handleResult fires:
resultBus.Publish("search:done", SearchResult{Query: "go", Hits: 10})
// Publisher sends a SearchError — only handleError fires:
errorBus.Publish("search:done", SearchError{Query: "go", Err: "timeout"})Relationship to the Untyped Bus
TypedBus is not a replacement — it's a layer on top. The underlying bus is still contracts.EventBus and still works exactly as before. All existing untyped code continues to work. You can mix typed and untyped subscribers on the same event:
// Untyped — still receives the event
ctx.EventBus().Subscribe(ctx, "search:result", func(e contracts.Event) {
// e.Data is still 'any' here
})
// Typed — also receives the same event
searchBus.Subscribe(ctx, "search:result", func(e event.TypedEvent[SearchResult]) {
// e.Data is SearchResult here
})Both handlers fire. Neither interferes with the other.
When to Use TypedBus vs Raw EventBus
| Scenario | Use |
|---|---|
| Internal plugin events with well-defined payloads | TypedBus[T] |
| Events consumed by many subscribers with known types | TypedBus[T] |
Wildcard "*" logging/audit subscriber | Raw EventBus |
Simple string-only events ("fzf:opened", nil data) | Raw EventBus |
| Cross-plugin events where payload type might change | Raw EventBus initially, migrate to TypedBus when stable |
Plugin Lifecycle Integration
The typical pattern in a plugin:
// In plugin.go or a shared types file:
type ClockTick struct{ Time time.Time }
var ClockBus = func(bus contracts.EventBus) *event.TypedBus[ClockTick] {
return event.NewTypedBus[ClockTick](bus)
}
// In clock plugin's OnActivate:
func (p *Plugin) OnActivate(ctx *tako.Context) error {
ctx.Spawn(func(c context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
tb := event.NewTypedBus[ClockTick](ctx.EventBus())
for {
select {
case t := <-ticker.C:
tb.Publish("clock:tick", ClockTick{Time: t})
case <-c.Done():
return
}
}
})
return nil
}
// In status plugin's OnActivate:
func (p *Plugin) OnActivate(ctx *tako.Context) error {
tb := event.NewTypedBus[ClockTick](ctx.EventBus())
cancel := tb.Subscribe(ctx.Context, "clock:tick", func(e event.TypedEvent[ClockTick]) {
p.currentTime = e.Data.Time.Format("15:04:05")
})
p.cancelSub = cancel
return nil
}3. General Architecture
Event Flow
Context-Based Auto-Cleanup
The bus passively prunes subscribers whose context has been cancelled. This happens inside Publish — no separate cleanup goroutine needed.
// This subscriber will be automatically removed when pluginCtx is cancelled
cancelFn := ctx.EventBus().Subscribe(pluginCtx, "some:event", handler)
// You can still call cancelFn() manually for immediate removalIf you're spawning a derived context for a scoped operation, the subscriber lives only as long as that context.
Naming Conventions
There's no enforced naming scheme, but the codebase follows this pattern:
| Pattern | Meaning |
|---|---|
"plugin-id:action" | Something happened in plugin-id (e.g. "fzf:opened") |
"plugin-id:closed" | A layer/overlay was closed |
"plugin-id:tick" | Periodic update (e.g. "clock:tick") |
Use lowercase, colon-separated names. Be specific enough that wildcard subscribers can filter by prefix if needed.
Events vs. Hooks vs. Direct Calls
It's worth being explicit about when to use what:
| Need | Use |
|---|---|
| Tell other plugins something happened (no return value) | Event Bus |
| Collect UI components from multiple plugins into a slot | Hook Registry |
| Call a method on a known service and get a result | Service Container |
| Schedule a background task | ctx.Spawn |
Up next: 03.03 — Hook Registry — the mechanism for UI extension slots. We'll look at
SingleHook,MultiHook, and how the host app renders contributions from multiple plugins.
