Plugin Hooks
DecisionBox exposes a small set of generic extension points so plugins can add behavior without forking the platform code.
Each hook is a leaf-level registry: a plugin imports it (often with a blank import) and calls Register* from init(); the platform consults the registry at the right moment in its normal flow.
This page documents the seven hooks. They are intentionally feature-agnostic so multiple unrelated plugins can attach without naming collisions.
Hook 1 — Context providers
Plugins can append markdown sections to agent prompts (alongside the project's knowledge sources). The agent walks every registered provider and concatenates non-empty sections in registration order.
import (
"context"
"github.com/decisionbox-io/decisionbox/libs/go-common/agentplugin"
)
type myProvider struct{}
func (myProvider) Name() string { return "my-context" }
func (myProvider) Section(ctx context.Context, projectID, query string, opts agentplugin.ContextProviderOpts) (string, error) {
// Return a markdown section, or "" when there is nothing to add.
// Honor opts.Limit / opts.MinScore for retrieval-style providers.
return "## My Context\nThis project belongs to the wholesale tenant.", nil
}
func init() {
agentplugin.RegisterContextProvider(myProvider{})
}
Behavior:
- Multiple registrations are allowed; output is emitted in registration order.
- Empty strings are dropped silently — providers don't have to special-case "nothing to add".
- A panicking or erroring provider does not abort the prompt; its section is skipped and the failure is reported via the
onErrorcallback the agent passes. - Calling
RegisterContextProviderwith a duplicate name or a nil provider panics.
The platform's built-in knowledge-sources retriever registers itself through this hook (libs/go-common/sources/contextprovider.go).
That registration is the canonical example — built-in behavior is identical to a single-call site, but the registry lets future plugins (column hints, area priorities, …) attach without orchestrator edits.
Hook 2 — ListTables filter
Plugins can shrink the per-dataset table list the agent discovers, after the warehouse driver returns it and before per-table schema discovery starts. Use it to implement allow / deny lists, regex denylists, or any policy that maps "tables the warehouse exposes" to "tables this project should consider".
import (
"context"
"github.com/decisionbox-io/decisionbox/libs/go-common/agentplugin"
)
func init() {
agentplugin.RegisterListTablesFilter("my-scope", func(ctx context.Context, projectID, dataset string, in []string) ([]string, error) {
out := in[:0:0]
for _, t := range in {
if t != "deprecated_table" {
out = append(out, t)
}
}
return out, nil
})
}
Rules:
- Filters MUST NOT add tables to the input. The warehouse's exposed list is the upper bound.
- Filters run in registration order. The output of one filter is the input to the next.
- A filter that returns an error fails the dataset; the agent logs and skips it, same as a
ListTablesInDatasetfailure. - Empty names or nil functions panic. Re-registering the same name panics.
Hook 3 — Ask handler override
Plugins can replace the built-in handler for POST /api/v1/projects/{id}/ask so they can add tool-use loops, agentic flows, or alternative synthesis on top of the platform.
import (
"net/http"
"github.com/decisionbox-io/decisionbox/services/api/apiserver"
)
type customAsk struct{}
func (customAsk) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ... full agentic flow, tool calls, proposal cards, …
}
func init() {
apiserver.RegisterAskOverride(customAsk{})
}
Behavior:
- The built-in route reads the override on every request, so plugins can register from
init()or later. - With no override registered the built-in RAG handler runs unchanged.
- Calling
RegisterAskOverridewithnilor twice panics — the override is process-global and silent shadowing would be a footgun. - The override receives the raw
*http.Requestafter RBAC middleware (the route is gated atviewer); it is responsible for any further role checks (e.g. requiringmemberto mutate state).
Hook 4 — Cached-schema filter
Plugins can shrink the catalog the agent loads from project_schema_cache at the start of a discovery run.
Different trigger point from Hook 2: ListTablesFilter runs during a fresh schema-index pass (constraining what gets cached); CachedSchemaFilter runs every discovery run after the cache load (constraining what the LLM sees this run).
Together they let an allow-/deny-list save take effect for both already-indexed and freshly-indexed projects without re-indexing.
import (
"context"
"github.com/decisionbox-io/decisionbox/libs/go-common/agentplugin"
)
func init() {
agentplugin.RegisterCachedSchemaFilter("my-scope", func(ctx context.Context, projectID string, qualified []string) ([]string, error) {
out := qualified[:0:0]
for _, t := range qualified {
if t != "demo.deprecated" {
out = append(out, t)
}
}
return out, nil
})
}
Rules:
- Input is a slice of qualified table names (
<dataset>.<table>,<schema>.<table>, etc. — whatever shape the warehouse provider canonicalised on when it wrote the schema cache). Sorted ascending so filter behavior and downstream logs are deterministic across runs. - Filters MUST NOT add tables. The orchestrator validates the output is a subset of the input and aborts the run with an explicit error if a filter invents a key, so a misbehaving plugin can't surface phantom tables to the LLM.
- A filter that drops every table aborts the run with a "review the discovery scope" error rather than letting an empty catalog hit the LLM.
- A non-nil error from any filter aborts the run; the chain stops on the first error and the orchestrator surfaces the wrapped reason.
- Empty names or nil functions panic. Re-registering the same name panics.
Hook 5 — Discovery run completion
Plugins can react to every discovery run that reaches a terminal state (completed, failed, or cancelled).
Use it to enqueue a downstream side effect — generate a summary document, fire a webhook, write a record to an internal operational log — without patching the agent or the run-completion path.
import (
"context"
"github.com/decisionbox-io/decisionbox/services/api/apiserver"
)
func init() {
apiserver.RegisterRunCompletionHook("exec-summary", func(ctx context.Context, run apiserver.RunCompletion) error {
if run.Status != "completed" {
return nil
}
// Enqueue executive-summary generation for the discovery.
return enqueueExecSummary(ctx, run.RunID, run.ProjectID)
})
}
Behavior:
- Each registered hook is named; registering with an empty name, a
nilfunction, or a duplicate name panics — silent shadowing in a process-global registry would be a footgun. - The API spins up a 15-second-tick background dispatcher only when at least one hook is registered. The dispatcher scans for runs in a terminal state (
completed/failed/cancelled) whosecompletion_hooks_fired_atfield is unset, fires every hook in registration order, and stamps the field once every hook returnsnil. - A non-nil return from any hook leaves the run unmarked so every hook re-fires on the next tick. Hooks MUST therefore be idempotent — a peer hook failing on the same run will cause successful peers to be invoked again.
- A hook that panics is recovered; its result records a panic-tagged error and subsequent hooks still run.
- Hook execution is sequential within one run so an upstream hook (e.g. an internal record write) finishes before a downstream one (e.g. a webhook fire) observes its side effect.
- The
RunCompletionpayload carriesRunID,DiscoveryID,ProjectID,Status,CompletedAt, andError(set only forfailedruns).RunIDis thediscovery_runs._id;DiscoveryIDis thediscoveries._idthe run produced and is what consumers should use to queryinsights/recommendations/executive_summaries/ any collection keyed ondiscovery_id. The agent stampsDiscoveryIDinRunRepository.Completeimmediately before flipping the run tocompleted, so successful runs always carry it.DiscoveryIDis empty forfailed/cancelledruns (no discovery was produced) and for completed runs that pre-date the field (legacy data — a one-off backfill is the supported recovery path; a hook seeing an empty value should treat it as a hard error rather than guess).
Hook 6 — Plugin route groups
Plugins can mount additional HTTP routes on the API server's authenticated mux without modifying the core router. Use it to expose plugin-specific endpoints (an extra admin surface, a sidecar dashboard backend) under a dedicated prefix.
import (
"net/http"
"github.com/decisionbox-io/decisionbox/services/api/apiserver"
)
func init() {
sub := http.NewServeMux()
sub.HandleFunc("GET /api/myplugin/status", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
})
apiserver.RegisterRouteGroup("/api/myplugin", sub)
}
Behavior:
- Each group is mounted at
{prefix}/on the authenticated mux, so every route under the prefix flows through the API server's global auth + RBAC chain. The handler is responsible for its own role checks (useauth.RequireRole(...)on the handlers it composes). - The handler receives the full request URL including the prefix — plugins typically construct a sub-mux at the same prefix or strip explicitly.
- Prefixes must start with
/and must not end with one (the mux appends the trailing slash). The empty string, anilhandler, and a duplicate prefix all panic at registration time so a typo fails noisily during boot rather than silently shadowing a built-in route. - Groups are mounted after built-in routes; Go's longest-match rule keeps built-in routes preferred for the exact prefixes they own.
The default build registers no route groups; the registry is a no-op until a plugin attaches.
Hook 7 — External model registry
Plugins can surface additional LLM model entries for the dashboard model picker and answer opaque-model-ID resolution queries from agent-side resolvers, without editing any built-in provider catalog. Use it when a plugin owns its own model directory whose entries aren't known at provider-init time (e.g. dynamically registered project-scoped models).
import (
"context"
gollm "github.com/decisionbox-io/decisionbox/libs/go-common/llm"
"github.com/decisionbox-io/decisionbox/libs/go-common/llm/catalog"
)
type myExtender struct{}
func (myExtender) Extend(ctx context.Context, projectID string) ([]gollm.ModelEntry, error) {
return []gollm.ModelEntry{
{ID: "ext:my-custom-model", DisplayName: "My Custom Model", Wire: gollm.WireOpenAICompat, MaxOutputTokens: 4096},
}, nil
}
func (myExtender) Resolve(ctx context.Context, modelID string) (*gollm.ModelEntry, error) {
if modelID == "ext:my-custom-model" {
return &gollm.ModelEntry{ID: modelID, Wire: gollm.WireOpenAICompat, MaxOutputTokens: 4096}, nil
}
return nil, catalog.ErrModelNotFound
}
func init() {
catalog.RegisterExtender(myExtender{})
}
Behavior:
Extend(ctx, projectID)returns project-scoped entries. The API endpointGET /api/v1/projects/{id}/llm/extended-modelsexposes the concatenation across all registered extenders, in registration order, so the dashboard can merge them into its model picker alongside the built-in catalog (GET /api/v1/providers/llm).Resolve(ctx, modelID)answers "does any extender own this opaque model ID?". Extenders that don't own the ID returnnil, catalog.ErrModelNotFound; the first extender that returns a non–not-found answer wins.- Empty project IDs / model IDs on the wrapper functions return an error so plugins don't accidentally expose entries without project scope.
- Registering
nilpanics. Order of registration is preserved; later extenders see calls only when earlier ones disclaim ownership (forResolve) or after earlier ones finish (forExtend).
The default build registers no extenders; both endpoints return empty lists until a plugin attaches.
Migration & compatibility
These hooks are additive — plugins that don't care about a given hook simply do not register. The platform code paths invoke the registries unconditionally; an empty registry is a no-op.
A reference consumer for context providers (libs/go-common/sources) and a regression guard in the orchestrator (orchestrator_plugin_hooks_test.go) keep the prompt output byte-for-byte stable when no plugin is loaded.