Heimdall Plugin Development Guide¶
Table of Contents¶
- Overview
- Architecture
- Quick Start
- Plugin Interface
- Action Handlers
- Building and Loading Plugins
- Testing Plugins
- Example: Complete Plugin
- Ordering and Determinism
- Optional Lifecycle Hooks
- Best Practices
- Troubleshooting
Overview¶
Heimdall is the cognitive guardian of NornicDB - a subsystem that enables AI-powered database management through an embedded Small Language Model (SLM). Named after the Norse god who guards Bifröst with his all-seeing eye, Heimdall watches over NornicDB's cognitive capabilities.
Heimdall Plugins are a DISTINCT plugin type from regular NornicDB plugins (like APOC). While regular plugins provide Cypher functions, Heimdall plugins provide subsystem management actions that the SLM can invoke based on natural language user requests.
How It Works¶
┌─────────────────────────────────────────────────────────────────┐
│ User: "Check for graph anomalies" │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Bifrost (Chat Interface) │
│ └─ Sends message to Heimdall SLM │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Heimdall SLM (qwen3-0.6b) │
│ ├─ Receives system prompt with available actions │
│ ├─ Interprets user intent │
│ └─ Responds: {"action": "heimdall.anomaly.detect", "params": {}}│
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Action Invoker │
│ ├─ Parses JSON action command │
│ ├─ Looks up handler in SubsystemManager │
│ └─ Executes: heimdall.anomaly.detect handler │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Anomaly Plugin (Your Plugin!) │
│ └─ Executes detection logic, returns ActionResult │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ User sees: "Found 3 anomalies: [details...]" │
└─────────────────────────────────────────────────────────────────┘
Architecture¶
Plugin Types¶
| Plugin Type | Interface | Purpose | Example |
|---|---|---|---|
| Regular (APOC) | nornicdb.Plugin | Cypher functions | apoc.coll.sum() |
| Heimdall | heimdall.HeimdallPlugin | SLM-managed subsystems | heimdall.anomaly.detect |
Key Components¶
- SubsystemManager: Global registry for all Heimdall plugins and actions
- HeimdallPlugin Interface: Contract all plugins must implement
- ActionFunc: Individual action definitions with handlers
- ActionContext: Context passed to handlers (database, metrics, Bifrost)
- ActionResult: Standardized return format for actions
- BifrostBridge: Communication channel to connected UI clients
Quick Start¶
1. Define Your Plugin¶
package myplugin
import (
"github.com/orneryd/nornicdb/pkg/heimdall"
)
// MyPlugin implements heimdall.HeimdallPlugin
type MyPlugin struct {
// Your plugin state
}
// Export as HeimdallPlugin - REQUIRED for .so plugins
var Plugin heimdall.HeimdallPlugin = &MyPlugin{}
2. Implement Required Methods¶
// Identity
func (p *MyPlugin) Name() string { return "myplugin" }
func (p *MyPlugin) Version() string { return "1.0.0" }
func (p *MyPlugin) Type() string { return "heimdall" } // MUST be "heimdall"
func (p *MyPlugin) Description() string { return "My custom subsystem" }
3. Define Actions¶
func (p *MyPlugin) Actions() map[string]heimdall.ActionFunc {
return map[string]heimdall.ActionFunc{
"analyze": {
Description: "Analyze something in the graph",
Category: "analysis",
Handler: p.handleAnalyze,
},
}
}
func (p *MyPlugin) handleAnalyze(ctx heimdall.ActionContext) (*heimdall.ActionResult, error) {
// Your logic here
return &heimdall.ActionResult{
Success: true,
Message: "Analysis complete",
Data: map[string]interface{}{"findings": []string{"item1", "item2"}},
}, nil
}
MCP tool format¶
Heimdall actions are aligned with the Model Context Protocol (MCP) tool format:
- Name – Full action name (e.g.
heimdall_watcher_query) - Description – Plain-text description for the model
- InputSchema – Optional JSON Schema for parameters (same as MCP
inputSchema)
When you define an action, you can set InputSchema so that MCP clients and the assistant know the expected parameters:
"query": {
Description: "Run a Cypher query (prefer read-only for user-triggered actions)",
Category: "query",
InputSchema: json.RawMessage([]byte(`{"type":"object","properties":{"cypher":{"type":"string","description":"Cypher query"}},"required":["cypher"]}`)),
Handler: p.handleQuery,
},
Use heimdall.ActionsAsMCPTools() to export all registered actions in MCP tool list format (e.g. for merging with NornicDB’s MCP server or external clients). Invocation matches MCP: action name plus params (MCP arguments).
4. Build and Deploy¶
# Build as shared library
go build -buildmode=plugin -o myplugin.so ./plugins/myplugin
# Place in plugins directory
cp myplugin.so $NORNICDB_HEIMDALL_PLUGINS_DIR/
# Or register as built-in (see below)
Plugin Interface¶
Every Heimdall plugin must implement the HeimdallPlugin interface:
type HeimdallPlugin interface {
// === Identity ===
Name() string // Plugin/subsystem identifier (e.g., "anomaly")
Version() string // Semver version (e.g., "1.0.0")
Type() string // MUST return "heimdall"
Description() string // Human-readable description
// === Lifecycle ===
Initialize(ctx SubsystemContext) error // Called on load
Start() error // Begin background operations
Stop() error // Pause background operations
Shutdown() error // Final cleanup
// === State & Health ===
Status() SubsystemStatus // Current status
Health() SubsystemHealth // Detailed health
Metrics() map[string]interface{} // Subsystem metrics
// === Configuration ===
Config() map[string]interface{} // Current config
Configure(settings map[string]interface{}) error // Update config
ConfigSchema() map[string]interface{} // JSON schema for validation
// === Actions ===
Actions() map[string]ActionFunc // All available actions
// === Data Access ===
Summary() string // Text summary for SLM context
RecentEvents(limit int) []SubsystemEvent // Recent events
}
SubsystemContext¶
Provided during initialization:
type SubsystemContext struct {
Config Config // Heimdall configuration
Database DatabaseRouter // Multi-database graph access ("" = default database)
Metrics MetricsReader // Runtime metrics
Logger SubsystemLogger // Logging interface
Bifrost BifrostBridge // Communication to UI clients
Heimdall HeimdallInvoker // Optional: invoke the SLM from plugins
}
SubsystemStatus Values¶
const (
StatusUninitialized SubsystemStatus = "uninitialized"
StatusInitializing SubsystemStatus = "initializing"
StatusReady SubsystemStatus = "ready"
StatusRunning SubsystemStatus = "running"
StatusStopping SubsystemStatus = "stopping"
StatusStopped SubsystemStatus = "stopped"
StatusError SubsystemStatus = "error"
)
Action Handlers¶
Actions are the heart of Heimdall plugins - they define what the SLM can do.
ActionFunc Structure¶
type ActionFunc struct {
Name string // Auto-set: heimdall.{plugin}.{action}
Handler func(ctx ActionContext) (*ActionResult, error) // Your handler
Description string // Shown to SLM/users
Category string // Grouping (monitoring, analysis, etc.)
}
ActionContext¶
Passed to every handler:
type ActionContext struct {
context.Context // Standard Go context
UserMessage string // Original user request
Params map[string]interface{} // Extracted parameters
Database DatabaseRouter // Query/search by database ("" = default database)
Metrics MetricsReader // Get runtime metrics
Bifrost BifrostBridge // Communicate with UI
// Per-database RBAC (set when Bifrost is behind auth)
PrincipalRoles []string // Authenticated principal's role names
DatabaseAccessMode auth.DatabaseAccessMode // CanAccessDatabase(dbName) before running Cypher
ResolvedAccess func(dbName string) auth.ResolvedAccess // Per-DB read/write for mutations
}
When the server mounts Bifrost behind authentication, it attaches PrincipalRoles, DatabaseAccessMode, and ResolvedAccess to the request context; these are then available on ActionContext (and PreExecuteContext). Plugins that run Cypher or mutations against a specific database should enforce per-database access: call ctx.DatabaseAccessMode.CanAccessDatabase(dbName) before querying that database, and for mutations use ctx.ResolvedAccess(dbName).Write. See Per-Database RBAC & Lockout Recovery.
Example: Validating per-database RBAC for a chat completion¶
The following is a commented-out example of a PreExecute hook that validates the caller’s per-database access before an action runs (e.g. before the SLM’s chosen action is executed in a chat completion). If the principal cannot access the target database or cannot write when the action is a mutation, the hook cancels execution and returns a clear message.
// PreExecute validates per-database RBAC before the action runs (chat completion flow).
// func (p *MyPlugin) PreExecute(ctx *heimdall.PreExecuteContext, done func(heimdall.PreExecuteResult)) {
// // 1. Determine target database from action params (e.g. "database" or "db" key)
// dbName := ""
// if d, ok := ctx.Params["database"].(string); ok && d != "" {
// dbName = d
// }
// if dbName == "" {
// dbName = ctx.Database.DefaultDatabaseName()
// }
//
// // 2. Enforce database access: principal must be allowed to access this DB
// if ctx.DatabaseAccessMode != nil && !ctx.DatabaseAccessMode.CanAccessDatabase(dbName) {
// ctx.Cancel("Access to database '"+dbName+"' is not allowed for your role.", "plugin:rbac")
// done(heimdall.PreExecuteResult{Continue: false, AbortMessage: "Permission denied: you cannot access database '" + dbName + "'."})
// return
// }
//
// // 3. If this action performs mutations, require write permission for that DB
// if isMutationAction(ctx.Action) && ctx.ResolvedAccess != nil {
// ra := ctx.ResolvedAccess(dbName)
// if !ra.Write {
// ctx.Cancel("Write on database '"+dbName+"' is not allowed for your role.", "plugin:rbac")
// done(heimdall.PreExecuteResult{Continue: false, AbortMessage: "Permission denied: write on database '" + dbName + "' is not allowed."})
// return
// }
// }
//
// done(heimdall.PreExecuteResult{Continue: true})
// }
//
// func isMutationAction(action string) bool {
// // Example: list of action names that perform CREATE/DELETE/SET/MERGE, etc.
// switch action {
// case "heimdall_myplugin_create", "heimdall_myplugin_delete":
// return true
// default:
// return false
// }
// }
ActionResult¶
Standard response format:
type ActionResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Data map[string]interface{} `json:"data,omitempty"`
}
Example Handler¶
func (p *MyPlugin) handleDetect(ctx heimdall.ActionContext) (*heimdall.ActionResult, error) {
// 1. Parse parameters
threshold := 0.8
if t, ok := ctx.Params["threshold"].(float64); ok {
threshold = t
}
// 2. Query the database
results, err := ctx.Database.Query(ctx, "", `
MATCH (n)
WHERE n.score > $threshold
RETURN n.id, n.score
`, map[string]interface{}{"threshold": threshold})
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
// 3. Send progress via Bifrost (optional)
if ctx.Bifrost.IsConnected() {
ctx.Bifrost.SendNotification("info", "Scan Progress", "Found potential anomalies...")
}
// 4. Return result
return &heimdall.ActionResult{
Success: true,
Message: fmt.Sprintf("Found %d items above threshold %.2f", len(results), threshold),
Data: map[string]interface{}{
"count": len(results),
"threshold": threshold,
"items": results,
},
}, nil
}
Building and Loading Plugins¶
Method 1: External .so Plugin¶
# Build
cd plugins/myplugin
go build -buildmode=plugin -o myplugin.so .
# Deploy
export NORNICDB_HEIMDALL_PLUGINS_DIR=/path/to/plugins
cp myplugin.so $NORNICDB_HEIMDALL_PLUGINS_DIR/
Requirements for .so plugins:
- Must export
var Plugin heimdall.HeimdallPlugin = &YourType{} - Built with same Go version as NornicDB
- Same CGO settings (important for llama.cpp)
Method 2: Built-in Plugin (Recommended)¶
Register in your plugin's init():
package myplugin
import "github.com/orneryd/nornicdb/pkg/heimdall"
func init() {
manager := heimdall.GetSubsystemManager()
manager.RegisterPlugin(&MyPlugin{}, "", true) // path="", builtin=true
}
Then import in cmd/nornicdb/main.go:
Testing Plugins¶
Unit Testing¶
func TestMyPlugin_Actions(t *testing.T) {
plugin := &MyPlugin{}
// Test initialization
ctx := heimdall.SubsystemContext{
Config: heimdall.DefaultConfig(),
Bifrost: &heimdall.NoOpBifrost{},
}
err := plugin.Initialize(ctx)
require.NoError(t, err)
// Test action
actions := plugin.Actions()
action, ok := actions["analyze"]
require.True(t, ok)
actCtx := heimdall.ActionContext{
Context: context.Background(),
UserMessage: "analyze the graph",
Params: map[string]interface{}{"threshold": 0.5},
Bifrost: &heimdall.NoOpBifrost{},
}
result, err := action.Handler(actCtx)
require.NoError(t, err)
assert.True(t, result.Success)
}
Integration Testing via Chat¶
# Start NornicDB with Heimdall enabled
NORNICDB_HEIMDALL_ENABLED=true ./nornicdb
# Note: Heimdall plugins are skipped unless Heimdall is enabled and initialized.
# Environment variables override config file values, so double-check your container env.
# Open Bifrost chat UI and type:
# "run my analyze action"
# "analyze the graph with threshold 0.5"
Example: Complete Plugin¶
Here's a complete anomaly detection plugin:
// plugins/anomaly/plugin.go
package anomaly
import (
"fmt"
"sync"
"time"
"github.com/orneryd/nornicdb/pkg/heimdall"
)
var Plugin heimdall.HeimdallPlugin = &AnomalyPlugin{}
type AnomalyPlugin struct {
mu sync.RWMutex
ctx heimdall.SubsystemContext
status heimdall.SubsystemStatus
events []heimdall.SubsystemEvent
lastScan time.Time
scanCount int64
}
// === Identity ===
func (p *AnomalyPlugin) Name() string { return "anomaly" }
func (p *AnomalyPlugin) Version() string { return "1.0.0" }
func (p *AnomalyPlugin) Type() string { return "heimdall" }
func (p *AnomalyPlugin) Description() string { return "Graph anomaly detection subsystem" }
// === Lifecycle ===
func (p *AnomalyPlugin) Initialize(ctx heimdall.SubsystemContext) error {
p.mu.Lock()
defer p.mu.Unlock()
p.ctx = ctx
p.status = heimdall.StatusReady
p.events = make([]heimdall.SubsystemEvent, 0, 100)
p.addEvent("info", "Anomaly detector initialized")
return nil
}
func (p *AnomalyPlugin) Start() error {
p.mu.Lock()
defer p.mu.Unlock()
p.status = heimdall.StatusRunning
return nil
}
func (p *AnomalyPlugin) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()
p.status = heimdall.StatusStopped
return nil
}
func (p *AnomalyPlugin) Shutdown() error {
return p.Stop()
}
// === State & Health ===
func (p *AnomalyPlugin) Status() heimdall.SubsystemStatus {
p.mu.RLock()
defer p.mu.RUnlock()
return p.status
}
func (p *AnomalyPlugin) Health() heimdall.SubsystemHealth {
p.mu.RLock()
defer p.mu.RUnlock()
return heimdall.SubsystemHealth{
Status: p.status,
Healthy: p.status == heimdall.StatusRunning,
LastCheck: time.Now(),
}
}
func (p *AnomalyPlugin) Metrics() map[string]interface{} {
p.mu.RLock()
defer p.mu.RUnlock()
return map[string]interface{}{
"scan_count": p.scanCount,
"last_scan": p.lastScan,
}
}
// === Configuration ===
func (p *AnomalyPlugin) Config() map[string]interface{} {
return map[string]interface{}{"threshold": 0.8}
}
func (p *AnomalyPlugin) Configure(settings map[string]interface{}) error {
return nil // Accept all settings
}
func (p *AnomalyPlugin) ConfigSchema() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"threshold": map[string]interface{}{"type": "number"},
},
}
}
// === Actions ===
func (p *AnomalyPlugin) Actions() map[string]heimdall.ActionFunc {
return map[string]heimdall.ActionFunc{
"detect": {
Description: "Detect anomalies in the graph structure",
Category: "analysis",
Handler: p.actionDetect,
},
"scan": {
Description: "Full graph anomaly scan (params: depth, threshold)",
Category: "analysis",
Handler: p.actionScan,
},
}
}
func (p *AnomalyPlugin) actionDetect(ctx heimdall.ActionContext) (*heimdall.ActionResult, error) {
p.mu.Lock()
p.scanCount++
p.lastScan = time.Now()
p.mu.Unlock()
// Example: Find nodes with unusually high edge counts
results, err := ctx.Database.Query(ctx, "", `
MATCH (n)
WITH n, size((n)--()) as edgeCount
WHERE edgeCount > 100
RETURN n.id as id, edgeCount
ORDER BY edgeCount DESC
LIMIT 10
`, nil)
if err != nil {
return nil, err
}
return &heimdall.ActionResult{
Success: true,
Message: fmt.Sprintf("Found %d potential anomalies (nodes with >100 edges)", len(results)),
Data: map[string]interface{}{
"anomalies": results,
},
}, nil
}
func (p *AnomalyPlugin) actionScan(ctx heimdall.ActionContext) (*heimdall.ActionResult, error) {
// Parse params
depth := 3
if d, ok := ctx.Params["depth"].(float64); ok {
depth = int(d)
}
threshold := 0.8
if t, ok := ctx.Params["threshold"].(float64); ok {
threshold = t
}
// Notify user of progress
if ctx.Bifrost.IsConnected() {
ctx.Bifrost.SendNotification("info", "Scan Started",
fmt.Sprintf("Scanning with depth=%d, threshold=%.2f", depth, threshold))
}
// Your anomaly detection logic here...
return &heimdall.ActionResult{
Success: true,
Message: "Full scan complete",
Data: map[string]interface{}{
"depth": depth,
"threshold": threshold,
"findings": []string{},
},
}, nil
}
// === Data Access ===
func (p *AnomalyPlugin) Summary() string {
return fmt.Sprintf("Anomaly detector: %d scans performed", p.scanCount)
}
func (p *AnomalyPlugin) RecentEvents(limit int) []heimdall.SubsystemEvent {
p.mu.RLock()
defer p.mu.RUnlock()
if limit > len(p.events) {
limit = len(p.events)
}
return p.events[len(p.events)-limit:]
}
func (p *AnomalyPlugin) addEvent(eventType, message string) {
p.events = append(p.events, heimdall.SubsystemEvent{
Time: time.Now(),
Type: eventType,
Message: message,
})
}
Ordering and Determinism¶
Heimdall executes hooks in a deterministic order. Plugins can opt into ordering control by implementing PluginOrdering.
type PluginOrdering interface {
Priority() int // Higher runs earlier
Before() []string // Plugins that must run after this one
After() []string // Plugins that must run before this one
}
If Heimdall detects a cycle, it falls back to priority desc, name asc and logs a warning.
Execution Model¶
- PrePrompt and PreExecute: ordered and synchronous.
- PostExecute: asynchronous via a bounded worker pool.
- DatabaseEvent: per-plugin bounded queues with backpressure.
Optional Lifecycle Hooks¶
Plugins can implement optional interfaces to hook into the request lifecycle. These are opt-in - plugins only implement what they need.
Available Hooks¶
| Interface | When Called | Purpose |
|---|---|---|
PrePromptHook | Before SLM request | Modify prompts, add context, validate |
PreExecuteHook | Before action execution | Validate params, fetch data, authorize |
PostExecuteHook | After action execution | Logging, metrics, cleanup |
DatabaseEventHook | On database operations | Audit, monitoring, triggers |
PrePromptHook (Modify Prompts)¶
// Implement this interface to modify prompts before SLM processing
type PrePromptHook interface {
PrePrompt(ctx *PromptContext) error
}
// Example implementation
func (p *MyPlugin) PrePrompt(ctx *heimdall.PromptContext) error {
// Add context to the prompt
ctx.AdditionalInstructions += "\nUser is querying the analytics database."
// Send notification (appears before AI response)
ctx.NotifyInfo("Context", "Added analytics context")
// Optionally cancel the request
if !p.isAuthorized(ctx.UserMessage) {
ctx.Cancel("Unauthorized request", "PrePrompt:myplugin")
}
return nil
}
PreExecuteHook (Before Action Execution)¶
// Implement this interface to validate/modify action parameters
type PreExecuteHook interface {
PreExecute(ctx *PreExecuteContext, done func(PreExecuteResult))
}
// Example: Validate parameters asynchronously
func (p *MyPlugin) PreExecute(ctx *heimdall.PreExecuteContext, done func(heimdall.PreExecuteResult)) {
// Async validation - call done() when finished
go func() {
// Fetch additional data
data, err := p.fetchContext(ctx.Action.Params)
if err != nil {
done(heimdall.PreExecuteResult{Error: err})
return
}
// Modify parameters
ctx.Action.Params["extra_data"] = data
done(heimdall.PreExecuteResult{})
}()
}
PostExecuteHook (After Action Execution)¶
// Implement this interface for post-action processing
type PostExecuteHook interface {
PostExecute(ctx *PostExecuteContext)
}
// Example: Log all action results
func (p *MyPlugin) PostExecute(ctx *heimdall.PostExecuteContext) {
p.logger.Info("Action completed",
"action", ctx.Action.Action,
"success", ctx.Result.Success,
"duration", ctx.Duration,
)
}
PostExecute hooks run asynchronously via a bounded worker pool. If the queue is full, the job is dropped and logged.
DatabaseEventHook (React to DB Operations)¶
// Implement this interface to react to database events
type DatabaseEventHook interface {
OnDatabaseEvent(event *DatabaseEvent)
}
// Event types: node.created, node.updated, node.deleted, node.read,
// relationship.created, relationship.updated, relationship.deleted,
// query.executed, query.failed, index.created, index.dropped,
// transaction.commit, transaction.rollback,
// database.started, database.shutdown, backup.started, backup.completed
// Example: Audit node deletions
func (p *MyPlugin) OnDatabaseEvent(event *heimdall.DatabaseEvent) {
if event.Type == heimdall.EventNodeDeleted {
p.auditLog.Record(event.NodeID, event.UserID, event.Timestamp)
}
}
Database events are delivered through per-plugin bounded queues. Under sustained load, events may be dropped to preserve system health.
Autonomous Action Invocation (HeimdallInvoker)¶
Plugins can autonomously trigger SLM actions based on accumulated events. The SubsystemContext provides a Heimdall invoker for this purpose.
// HeimdallInvoker interface methods:
type HeimdallInvoker interface {
// Directly invoke a registered action
InvokeAction(action string, params map[string]interface{}) (*ActionResult, error)
// Send a natural language prompt to the SLM
SendPrompt(prompt string) (*ActionResult, error)
// Async versions (fire-and-forget, results via Bifrost)
InvokeActionAsync(action string, params map[string]interface{})
SendPromptAsync(prompt string)
}
Example: Autonomous Anomaly Detection Based on Event Accumulation
type SecurityPlugin struct {
ctx heimdall.SubsystemContext
failedQueries int64
lastReset time.Time
}
func (p *SecurityPlugin) OnDatabaseEvent(event *heimdall.DatabaseEvent) {
// Track query failures
if event.Type == heimdall.EventQueryFailed {
// Reset counter every 5 minutes
if time.Since(p.lastReset) > 5*time.Minute {
p.failedQueries = 0
p.lastReset = time.Now()
}
p.failedQueries++
// AUTONOMOUS ACTION: After 5 failures, trigger analysis
if p.failedQueries >= 5 && p.ctx.Heimdall != nil {
// Option 1: Directly invoke an action
p.ctx.Heimdall.InvokeActionAsync("heimdall.anomaly.detect", map[string]interface{}{
"trigger": "autonomous",
"reason": "query_failures",
})
// Option 2: Send natural language prompt to SLM
p.ctx.Heimdall.SendPromptAsync(
"Multiple query failures detected. Analyze for potential issues.")
p.failedQueries = 0
}
}
}
Use Cases for Autonomous Actions:
- Security Monitoring: Track failed auth attempts → trigger security analysis
- Performance Alerts: Monitor slow queries → trigger optimization suggestions
- Anomaly Detection: Detect unusual patterns → trigger investigation
- Resource Management: Monitor memory usage → trigger cleanup recommendations
- Compliance Auditing: Track sensitive operations → trigger audit reports
For a full guide on wiring database event triggers to the model and running remediation (e.g. Cypher) from plugins or actions, see Database event triggers and automatic remediation. For how the agentic loop works and how PrePrompt/PreExecute/PostExecute fit in, see Heimdall agentic loop.
Notification Methods¶
Within lifecycle hooks, use PromptContext notification methods:
// All notifications are queued and sent inline with the streaming response
ctx.NotifyInfo("Title", "Message") // ℹ️ Info
ctx.NotifyWarning("Title", "Message") // ⚠️ Warning
ctx.NotifyError("Title", "Message") // ❌ Error
ctx.NotifyProgress("Title", "Message") // 🔄 Progress
ctx.Notify("success", "Title", "Msg") // ✅ Custom type
Cancellation¶
Any hook can cancel the request:
func (p *MyPlugin) PrePrompt(ctx *heimdall.PromptContext) error {
if reason := p.shouldCancel(ctx.UserMessage); reason != "" {
ctx.Cancel(reason, "PrePrompt:myplugin")
// Request stops here, user sees cancellation message
}
return nil
}
Full Lifecycle Plugin Example¶
// Implement all hooks (convenience interface)
var _ heimdall.FullLifecycleHook = (*MyPlugin)(nil)
type MyPlugin struct {
// ... your fields ...
}
func (p *MyPlugin) PrePrompt(ctx *heimdall.PromptContext) error {
ctx.NotifyInfo("Processing", "Preparing your request...")
return nil
}
func (p *MyPlugin) PreExecute(ctx *heimdall.PreExecuteContext, done func(heimdall.PreExecuteResult)) {
// Synchronous validation
done(heimdall.PreExecuteResult{})
}
func (p *MyPlugin) PostExecute(ctx *heimdall.PostExecuteContext) {
log.Printf("Action %s completed in %v", ctx.Action.Action, ctx.Duration)
}
func (p *MyPlugin) OnDatabaseEvent(event *heimdall.DatabaseEvent) {
// React to database operations
}
Best Practices¶
1. Action Naming¶
- Use lowercase, descriptive names:
detect,scan,configure - Full action name format:
heimdall.{plugin}.{action} - Example:
heimdall.anomaly.detect
2. Parameter Extraction¶
// Always provide defaults and type-check
threshold := 0.5 // default
if t, ok := ctx.Params["threshold"].(float64); ok {
threshold = t
}
3. Error Handling¶
// Return errors, don't panic
if err != nil {
return &heimdall.ActionResult{
Success: false,
Message: fmt.Sprintf("Failed: %v", err),
}, nil // Return nil error if you handled it
}
4. Ordering and Hooks¶
- Only implement
PluginOrderingif you truly need deterministic ordering. - Avoid hard dependencies unless required; keep hooks independent when possible.
- Assume PostExecute and DatabaseEvent delivery can be dropped under load.
5. Progress Updates (Inline Notifications)¶
// Notifications are sent inline with the streaming response
// They appear in proper order - before/after the content they relate to
// Use PromptContext.Notify() in lifecycle hooks:
func (p *MyPlugin) PrePrompt(ctx *heimdall.PromptContext) error {
ctx.NotifyInfo("Processing", "Analyzing your request...")
return nil
}
// In action handlers, use the ActionContext:
if ctx.Bifrost.IsConnected() {
ctx.Bifrost.SendNotification("info", "Progress", "50% complete...")
}
Note: Notifications from lifecycle hooks (PrePrompt) are queued and sent at the start of the streaming response, ensuring proper ordering with chat content.
5. Thread Safety¶
6. Action Descriptions¶
Write clear descriptions - they're shown to both the SLM and users:
"detect": {
Description: "Detect graph anomalies - finds nodes with unusual connectivity patterns",
Category: "analysis",
Handler: p.actionDetect,
}
Troubleshooting¶
Plugin Not Loading¶
- Check plugin type returns "heimdall":
- Verify export variable exists:
- Check build mode:
Action Not Triggering¶
- Verify action is registered:
-
Check action description matches user intent - the SLM uses descriptions to map requests
-
Try explicit action name:
Database Access Fails¶
- Verify the target database exists (or pass
""to use the default database) - If your action is intended to be read-only, validate and reject write clauses (CREATE/MERGE/DELETE/SET, etc.)
- Check context isn't cancelled
- Verify database routing in
Health()
Bifrost Communication Fails¶
- Check
ctx.Bifrost.IsConnected()before sending in action handlers - Use
NoOpBifrostin tests to avoid panics - For lifecycle hooks, notifications are queued - they won't fail but may not display if the request is cancelled
Notifications Out of Order¶
Notifications from lifecycle hooks are queued and sent inline with the streaming response:
- PrePrompt notifications appear before the AI response
- They're sent as special SSE chunks with
role: "heimdall" - No separate EventSource subscription is needed in the UI
If you see ordering issues:
- Ensure you're using
ctx.NotifyInfo()etc. in PrePrompt hooks - Action handler notifications via
ctx.Bifrost.SendNotification()may arrive later
Reference¶
Available Categories¶
monitoring- Status, health, metricsanalysis- Detection, scanning, diagnosticsconfiguration- Config get/setoptimization- Query/storage tuningcuration- Memory/data managementsystem- Core system operationstest- Test/debug actions
Example Prompts → Actions¶
| User Says | Maps To |
|---|---|
| "check the status" | heimdall_watcher_status |
| "detect anomalies" | heimdall.anomaly.detect |
| "say hello" | heimdall_watcher_hello |
| "what's the health" | heimdall_watcher_health |
| "show me metrics" | heimdall_watcher_metrics |
See Also¶
Version: 1.1.0
Last Updated: 2024-12-03
Maintainer: NornicDB Team
Changelog¶
- 1.1.0: Added optional lifecycle hooks (PrePromptHook, PreExecuteHook, PostExecuteHook, DatabaseEventHook), inline notification system for proper ordering