Skip to content

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

go
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

go
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:

go
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.

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

go
// ❌ 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.

go
// ✅ 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):

go
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

go
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

go
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:

go
// 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:

go
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:

go
// 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

ScenarioUse
Internal plugin events with well-defined payloadsTypedBus[T]
Events consumed by many subscribers with known typesTypedBus[T]
Wildcard "*" logging/audit subscriberRaw EventBus
Simple string-only events ("fzf:opened", nil data)Raw EventBus
Cross-plugin events where payload type might changeRaw EventBus initially, migrate to TypedBus when stable

Plugin Lifecycle Integration

The typical pattern in a plugin:

go
// 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.

go
// 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 removal

If 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:

PatternMeaning
"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:

NeedUse
Tell other plugins something happened (no return value)Event Bus
Collect UI components from multiple plugins into a slotHook Registry
Call a method on a known service and get a resultService Container
Schedule a background taskctx.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.