Plugins

Plugins are Go modules that hook into the conversation lifecycle. They run real Go code via the Yaegi interpreter – they can track state, make decisions, format output programmatically, and import third-party libraries via vendoring. Plugins are user-installed – aura init does not include any plugins. The repository contains example plugins under .aura/plugins/ that can be copied or used as reference.

Plugins live under .aura/plugins/ and are discovered recursively at startup. You can organize plugins into subdirectories (e.g. injectors/, diagnostics/) for human convenience — the directory nesting is invisible to the system. Plugin identity defaults to the leaf directory name, but can be overridden with an explicit name: field in plugin.yaml. Duplicate names across different nesting depths are a startup error.

Choosing an Extensibility Mechanism

Before reaching for a plugin, check whether a simpler mechanism fits your use case.

I want to… Use Where
Add a command the user types (/foo) Custom command .aura/config/commands/foo.md
Add a capability the LLM invokes autonomously Skill .aura/skills/foo.md
Override a tool’s description, usage, or examples Tool definition .aura/config/tools/foo.yaml
Run a shell script before/after tool execution Hook .aura/config/hooks/foo.yaml
React to events, modify tool args/output, inject messages, track state Plugin (injector) .aura/plugins/foo/
Define a new tool the LLM can call Plugin (tool) .aura/plugins/foo/
Connect an external tool server MCP server .aura/config/mcp/foo.yaml

Custom commands and skills are Markdown files — zero Go required. Tool definitions are YAML overrides. Hooks are YAML + shell. Plugins are Go modules for when you need state, logic, or custom tools. MCP servers connect external tool providers over stdin/HTTP.

See: Custom Commands, Skills, Tool Definitions, Hooks, MCP.

Plugin Packs

A single git repository can contain multiple plugins — this is a plugin pack. Each subdirectory with a plugin.yaml is treated as an independent plugin. The repository is cloned once and all contained plugins are discovered and loaded.

aura plugins add https://github.com/user/aura-plugins-collection

Aura validates that at least one plugin.yaml exists anywhere in the cloned tree. If none is found, the clone is removed and an error is returned.

How It Works

Each plugin is a Go module with a go.mod file that declares its module path. At startup, Aura loads each plugin through the following pipeline:

  1. Read go.mod – extract the module path (e.g. module my-plugin).
  2. Copy source + vendor/ – copy .go files and the vendor/ directory (if present) into a temporary GoPath, preserving the module path as the package import path.
  3. Derive package name – compute the Go package name from the module path: take path.Base(modulePath) and replace hyphens with underscores. For example, my-plugin becomes my_plugin.
  4. Create Yaegi interpreter – one interpreter per plugin, with its own GoPath. This ensures vendor isolation between plugins.
  5. Load symbol maps – register the Go stdlib and the Aura SDK binary symbol map (sdk.Symbols).
  6. Import the pluginimport "<modulePath>" triggers Yaegi’s GTA (Global Type Analysis), parsing all .go files and resolving imports from the plugin’s vendor/ directory.
  7. Probe for hooks – look up {packageName}.BeforeChat, {packageName}.AfterResponse, etc. using the derived package name. Each discovered hook is wrapped as an injector.Injector and registered on the injection pipeline.

Package-level variables persist across hook invocations within a session – plugins can maintain counters, accumulators, and other state. The interpreter is rebuilt only on explicit /reload.

Directory Structure

.aura/plugins/
├── diagnostics/                  # Organizational subdirectory (any name)
│   ├── tool-logger/
│   │   ├── go.mod
│   │   ├── plugin.yaml
│   │   └── main.go
│   └── turn-tracker/
│       ├── go.mod
│       ├── plugin.yaml
│       └── main.go
├── injectors/
│   ├── my-plugin/
│   │   ├── go.mod                # Required: module path
│   │   ├── go.sum                # If deps exist
│   │   ├── plugin.yaml           # Required: marks plugin root
│   │   ├── main.go               # Hook functions
│   │   ├── helpers.go            # Optional: additional .go files (same package)
│   │   └── vendor/               # Optional: vendored third-party deps
│   │       └── github.com/
│   │           └── somelib/
│   │               └── lib.go
│   └── another-plugin/
│       ├── go.mod
│       ├── plugin.yaml
│       └── main.go
└── tools/
    ├── gotify/
    │   ├── go.mod
    │   ├── plugin.yaml
    │   └── main.go
    └── notepad/
        ├── go.mod
        ├── plugin.yaml
        └── main.go

Discovery walks recursively but stops descending into any directory that contains a plugin.yaml (preventing scanning of vendor/ subdirectories and nested plugins). Hidden directories and vendor/ directories are skipped entirely.

All .go files in a plugin directory must declare the same package name, derived from the module path in go.mod. Files are loaded as a package – order does not matter.

Plugin Config

Each plugin requires a plugin.yaml:

# name: my-custom-name  # override plugin identity (default: leaf directory name)
description: "What this plugin does"
disabled: false # default: false (enabled)
override: false # replace a built-in tool with the same name
opt_in: false # tools only — hidden unless explicitly enabled by name
condition: "auto" # injectors only: condition expression
once: false # injectors only: fire only once per session
env: # optional: env var names plugin may read
  - MY_API_KEY
  - MY_API_URL
# config: # default config values (accessible via sdk.Context.PluginConfig)
#   max_failures: 3
Field Required Default Description
name no Override plugin identity (default: leaf directory name)
description no Human-readable description
disabled no false Set true to skip loading entirely
override no false Replace an existing tool with the same name. Without this, name conflicts are a startup error.
opt_in no false Tools only — hidden unless explicitly enabled by name at any filter layer
condition no Injectors only — condition expression evaluated before every hook call (see Conditions)
once no false Injectors only — fire only once per session when condition first becomes true
env no [] Environment variable names the plugin may read (see Environment Variables)
config no {} Default config values, accessible via sdk.Context.PluginConfig. Overridden by features/plugins.yaml config.global and config.local.<name>

When condition is set, the hook only fires if the condition evaluates to true. When once: true is set, the hook fires at most once per session — the first time its condition becomes true. The once state is preserved across /reload.

Creating a Plugin

Step 1: Initialize the module

mkdir -p .aura/plugins/my-plugin
cd .aura/plugins/my-plugin
go mod init my-plugin

The module name determines your Go package name. my-plugin becomes package my_plugin (hyphens replaced with underscores).

Step 2: Add the SDK dependency

go get github.com/idelchi/aura/sdk@latest

This adds the SDK to go.mod for development tooling (autocomplete, type checking). At runtime, Aura provides SDK types via a binary symbol map – the vendored SDK source is dead code.

Step 3: Write plugin code

main.go:

package my_plugin

import (
    "context"
    "fmt"

    "github.com/idelchi/aura/sdk"
)

var callCount int

func BeforeChat(_ context.Context, hctx sdk.BeforeChatContext) (sdk.Result, error) {
    callCount++
    if callCount%5 != 0 {
        return sdk.Result{}, nil
    }

    return sdk.Result{
        Message: fmt.Sprintf("Turn %d | tokens: %d (%.0f%%)", callCount, hctx.Tokens.Estimate, hctx.Tokens.Percent),
        Role:    sdk.RoleUser,
        Prefix:  "[my-plugin] ",
        Eject:   true,
    }, nil
}

The package name (my_plugin) must match what Aura derives from your go.mod module path.

Step 4: Add third-party dependencies (optional)

go get github.com/some/library
go mod vendor

Vendored dependencies are resolved by Yaegi at runtime from your plugin’s vendor/ directory. See Vendoring Dependencies for details.

Step 5: Create plugin.yaml

description: "My custom plugin"

Hook Functions

Plugins implement one or more hook functions. Each maps to an injector timing:

Function Timing When
BeforeChat before_chat Before sending messages to the LLM
AfterResponse after_response After receiving the LLM response
BeforeToolExecution before_tool_execution Before a tool call executes (modify/block)
AfterToolExecution after_tool_execution After a tool call completes
OnError on_error When the LLM provider returns an error
BeforeCompaction before_compaction Before context compaction begins (can skip)
AfterCompaction after_compaction After context compaction completes — read-only (observe, don’t modify)
OnAgentSwitch on_agent_switch When the active agent changes — read-only (observe, don’t modify). Reason: user, failover, task, cycle, resume
TransformMessages transform_messages Inside chat(), transforms message array before LLM call (ephemeral)

Scope: Hooks only fire in the conversation pipeline (aura run). The aura tools subcommand executes tools directly and does not invoke hooks. Plugin tools (custom tools with Schema/Execute exports) are available in both paths.

Signatures

Hook functions are probed using the derived package name. For a plugin with module my-plugin (package my_plugin), the hooks are:

package my_plugin

import (
    "context"
    "github.com/idelchi/aura/sdk"
)

func BeforeChat(ctx context.Context, c sdk.BeforeChatContext) (sdk.Result, error) { ... }
func AfterResponse(ctx context.Context, c sdk.AfterResponseContext) (sdk.Result, error) { ... }
func BeforeToolExecution(ctx context.Context, c sdk.BeforeToolContext) (sdk.BeforeToolResult, error) { ... }
func AfterToolExecution(ctx context.Context, c sdk.AfterToolContext) (sdk.Result, error) { ... }
func OnError(ctx context.Context, c sdk.OnErrorContext) (sdk.Result, error) { ... }
func BeforeCompaction(ctx context.Context, c sdk.BeforeCompactionContext) (sdk.Result, error) { ... }
func AfterCompaction(ctx context.Context, c sdk.AfterCompactionContext) (sdk.Result, error) { ... }
func OnAgentSwitch(ctx context.Context, c sdk.OnAgentSwitchContext) (sdk.Result, error) { ... }
func TransformMessages(ctx context.Context, c sdk.TransformContext) ([]sdk.Message, error) { ... }

All hooks are optional – implement only the ones you need. A plugin must export at least one hook OR a tool (see Tool Functions).

BeforeChat Request Modification

BeforeChat hooks can influence the chat request by returning sdk.RequestModification:

  • AppendSystem: Return sdk.Result{Request: &sdk.RequestModification{AppendSystem: &text}} to append text to the system prompt for this turn only. The appended text is not persisted — it applies to the current chat() call and is discarded afterward. Multiple hooks appending compose with \n\n separators.
  • Skip: Return sdk.Result{Request: &sdk.RequestModification{Skip: true}} to skip the chat call entirely. The turn ends immediately with an empty response (no LLM call is made).
func BeforeChat(_ context.Context, ctx sdk.BeforeChatContext) (sdk.Result, error) {
    // Inject dynamic context into the system prompt for this turn
    context := fetchRelevantContext(ctx.SystemPrompt)
    return sdk.Result{
        Request: &sdk.RequestModification{AppendSystem: &context},
    }, nil
}

BeforeToolExecution

BeforeToolExecution fires before each tool call, after tool lookup but before Pre(), policy, and sandbox checks. It receives the tool name and arguments, and can:

  • Modify arguments: Return BeforeToolResult{Arguments: modifiedArgs} to replace arguments before execution. Multiple hooks chain — each sees the previous hook’s modifications. Modified arguments are re-validated against the tool’s JSON schema; invalid modifications (unknown fields, wrong types, missing required params) are rejected before reaching Pre().
  • Block execution: Return BeforeToolResult{Block: true} to skip execution entirely. If any hook blocks, the tool is not executed. When a block result includes messages (via the embedded Result), the first message’s content is appended to the block reason shown to the LLM.
  • Inject messages: Via the embedded Result.Message, same as other hooks. Messages are only injected when the tool call survives both the block check and argument validation — if the tool is blocked or modified arguments are invalid, messages are not injected.
func BeforeToolExecution(_ context.Context, ctx sdk.BeforeToolContext) (sdk.BeforeToolResult, error) {
    if ctx.ToolName == "Bash" {
        args := make(map[string]any)
        for k, v := range ctx.Arguments {
            args[k] = v
        }
        args["command"] = "set -e; " + ctx.Arguments["command"].(string)
        return sdk.BeforeToolResult{Arguments: args}, nil
    }
    return sdk.BeforeToolResult{}, nil
}

AfterToolExecution Output Modification

AfterToolExecution hooks can rewrite tool output by setting Result.Output. When non-nil, the pointed-to string replaces the tool result sent to the LLM:

func AfterToolExecution(_ context.Context, ctx sdk.AfterToolContext) (sdk.Result, error) {
    if ctx.ToolName == "Bash" {
        cleaned := strings.ReplaceAll(ctx.ToolResult, "/home/user", "<REDACTED>")
        return sdk.Result{Output: &cleaned}, nil
    }
    return sdk.Result{}, nil
}

AfterResponse Response Modification

AfterResponse hooks can influence how the LLM response is handled by returning sdk.ResponseModification:

  • Skip: Return sdk.Result{Response: &sdk.ResponseModification{Skip: true}} to prevent the response from being added to conversation history. The response has already been streamed to the UI — this only affects the stored history.
  • Content: Return sdk.Result{Response: &sdk.ResponseModification{Content: &text}} to replace the response content before adding it to history. The original is streamed to the UI; the modified version is what the LLM sees on subsequent turns.
func AfterResponse(_ context.Context, ctx sdk.AfterResponseContext) (sdk.Result, error) {
    // Strip sensitive data from response before it enters history
    if strings.Contains(ctx.Response, "SECRET") {
        cleaned := strings.ReplaceAll(ctx.Response, "SECRET", "<REDACTED>")
        return sdk.Result{Response: &sdk.ResponseModification{Content: &cleaned}}, nil
    }
    return sdk.Result{}, nil
}

The AfterResponseContext also provides Thinking string (thinking block text) and Calls []ToolCall (pending tool calls from this response, unexecuted — Result/Error/Duration are zero).

OnError

OnError fires when the LLM provider returns an error that all built-in recovery mechanisms have failed to handle. The error has already passed through parse retry, compaction recovery, and agent failover before reaching OnError hooks.

The hook receives classification fields on OnErrorContext:

Field Type Description
Error string Raw error message
ErrorType string Classified type (see table below)
Retryable bool true for transient errors (rate_limit, server, network)
StatusCode int Always 0 — providers discard HTTP status after classification

Error types:

ErrorType Meaning
rate_limit Provider rate limit hit
auth Authentication failure (bad API key/token)
network Cannot reach provider (connection refused, DNS, timeout)
server Provider returned 5xx
content_filter Response blocked by provider’s content filter
credit_exhausted Billing quota exceeded
model_unavailable Model not found or not loaded
context_exhausted Context window exceeded (rarely reaches OnError — caught by compaction recovery)
"" (empty) Unclassified error

Hooks can influence error handling by returning sdk.ErrorModification:

  • Retry: Return sdk.Result{Error: &sdk.ErrorModification{Retry: true}} to retry the chat call without counting as a new iteration. Retries are capped at 3 attempts — after that, the error propagates normally.
  • Skip: Return sdk.Result{Error: &sdk.ErrorModification{Skip: true}} to suppress the error entirely. The loop returns nil as if chat succeeded with an empty response.

When multiple hooks run, Retry and Skip use OR logic (any hook requesting either wins). Retry takes precedence over Skip — if retry ultimately fails, OnError fires again and Skip can catch it.

func OnError(_ context.Context, ctx sdk.OnErrorContext) (sdk.Result, error) {
    // Auto-retry transient errors
    if ctx.Retryable {
        return sdk.Result{
            Error: &sdk.ErrorModification{Retry: true},
        }, nil
    }
    // Suppress auth errors silently
    if ctx.ErrorType == "auth" {
        return sdk.Result{
            Error:   &sdk.ErrorModification{Skip: true},
            Notice:  "Authentication failed — check your API key",
        }, nil
    }
    return sdk.Result{}, nil
}

BeforeCompaction

BeforeCompaction fires before context compaction begins – both auto-triggered (when context fills up) and manual (/compact). Plugins can skip built-in compaction entirely by returning sdk.CompactionModification{Skip: true}, allowing a context-management plugin to take full control.

The BeforeCompactionContext provides:

Field Type Description
Forced bool true if triggered by explicit /compact or force=true
TokensUsed int Estimated tokens before compaction
ContextPercent float64 % of context window currently used
MessageCount int Messages in conversation
KeepLast int How many recent messages will be preserved
func BeforeCompaction(_ context.Context, ctx sdk.BeforeCompactionContext) (sdk.Result, error) {
    // Skip compaction when a plugin manages context itself
    if managesContextInternally() {
        return sdk.Result{
            Compaction: &sdk.CompactionModification{Skip: true},
        }, nil
    }
    return sdk.Result{}, nil
}

Standard Result fields also work – you can inject messages or notices alongside the skip decision. The hook fires once before all retry attempts in recovery compaction (not per-retry).

TransformMessages

TransformMessages fires inside chat(), after ForLLM() filtering and AppendSystem application, before the request is built. The plugin receives the full message array and returns a modified version used for this LLM call only.

Key properties:

  • Ephemeral: Builder history is NOT modified. The plugin re-applies all transforms every turn.
  • Pipeline: Multiple TransformMessages plugins chain – output of one feeds input of next (alphabetical directory order).
  • Non-fatal: On error or validation failure, the original untransformed array is used.
  • Validation: Aura checks: non-empty array, system prompt at index 0, no orphaned tool results.

The TransformContext provides Messages []Message – the messages that would be sent to the provider:

// Message represents a conversation message visible to transform plugins.
type Message struct {
    ID         string            // Positional identifier (m0001, m0002, ...)
    Role       string            // user, assistant, system, tool
    Content    string            // Message text
    Thinking   string            // Thinking content (if any)
    ToolName   string            // Tool that produced this result (tool messages)
    ToolCallID string            // Links tool result to its call (tool messages)
    ToolCalls  []MessageToolCall // Tool calls in this message (assistant messages)
    Tokens     int               // Estimated token count
    Type       string            // normal, synthetic
    CreatedAt  time.Time         // Message timestamp
}

type MessageToolCall struct {
    ID        string         // Provider-generated call ID
    Name      string         // Tool name
    Arguments map[string]any // Tool parameters
}

Message IDs are positional (m0001, m0002, …), assigned during conversion. They are stable within a single transform call but shift between turns as new messages are added.

func TransformMessages(_ context.Context, ctx sdk.TransformContext) ([]sdk.Message, error) {
    msgs := make([]sdk.Message, len(ctx.Messages))
    copy(msgs, ctx.Messages)

    // Example: compress old tool results to save context
    for i, msg := range msgs {
        if msg.Role == "tool" && msg.Tokens > 500 {
            msgs[i].Content = summarize(msg.Content)
        }
    }

    return msgs, nil
}

Plugins can remove messages (omit from returned array), modify content/thinking, or insert new messages (with fresh IDs). Original message fields not exposed via SDK (ThinkingSignature, Images, call details) are preserved through an ID-based merge – the plugin cannot corrupt them.

Command Functions

Plugins can register a single slash command by exporting Command() and ExecuteCommand(). A plugin can define a command alongside hooks and/or a tool, or as its sole export.

Signatures

package my_plugin

import (
    "context"
    "github.com/idelchi/aura/sdk"
)

// Command declares the slash command schema (required for command plugins).
func Command() sdk.CommandSchema { ... }

// ExecuteCommand runs the command (required for command plugins).
// args is the raw string typed after the command name.
func ExecuteCommand(ctx context.Context, args string, sctx sdk.Context) (sdk.CommandResult, error) { ... }

Both exports are required when defining a command. One command per plugin is supported.

SDK Command Types

type CommandSchema struct {
    Name        string
    Description string
    Hints       []string // Tab-completion hint labels shown in the TUI
    Forward     bool     // If true, output is forwarded to the LLM as a user message
    Silent      bool     // If true, output is suppressed from display
}

type CommandResult struct {
    Output string // Text returned to the caller (forwarded or displayed)
}

Conflict Priority

When a command name collides, built-in commands take priority over custom (Markdown) commands, which take priority over plugin commands.

Example

package greeter

import (
    "context"
    "fmt"

    "github.com/idelchi/aura/sdk"
)

func Command() sdk.CommandSchema {
    return sdk.CommandSchema{
        Name:        "greet",
        Description: "Say hello to someone.",
        Hints:       []string{"name"},
    }
}

func ExecuteCommand(_ context.Context, args string, _ sdk.Context) (sdk.CommandResult, error) {
    if args == "" {
        args = "world"
    }
    return sdk.CommandResult{Output: fmt.Sprintf("Hello, %s!", args)}, nil
}

Tool Functions

Plugins can also expose tools — functions that appear in the tool registry alongside built-in tools. A plugin can be hook-only, tool-only, or both.

Signatures

Tool functions are probed the same way as hooks — using the derived package name:

package my_plugin

import (
    "context"
    "github.com/idelchi/aura/sdk"
)

// Schema declares the tool's function-calling schema (required).
func Schema() sdk.ToolSchema { ... }

// Execute runs the tool (required). Receives Go context, SDK context, and tool arguments.
func Execute(ctx context.Context, sc sdk.Context, args map[string]any) (string, error) { ... }

// Paths declares filesystem paths the tool will access (optional).
// Used for sandbox fast-path checks and filetime record/guard.
func Paths(args map[string]any) (sdk.ToolPaths, error) { ... }

// Sandboxable returns whether the tool supports sandbox re-exec (optional, default true).
func Sandboxable() bool { ... }

// Parallel returns whether the tool is safe to run concurrently with other tools (optional, default true).
// Return false if the tool mutates shared state, blocks on user input, or has ordering dependencies.
func Parallel() bool { ... }

// Init is called once when the plugin loads (optional).
// Use for one-time setup such as reading config paths.
func Init(cfg sdk.ToolConfig) { ... }

// Available returns false to prevent the tool from being registered (optional).
// Use to gate registration on runtime conditions (e.g. missing env vars).
func Available() bool { ... }

Schema() and Execute() are required. Paths(), Sandboxable(), Parallel(), Init(), and Available() are optional — omit them for defaults (no paths, sandboxable=true, parallel=true, no init, always available). Note that Init takes sdk.ToolConfig (not a context or error) — it runs synchronously at load time. A tool’s Parallel() can be overridden per-tool via Tool Definitions without changing plugin code.

Signature mismatch warnings: If an optional function is exported with the wrong signature, Aura prints a warning to stderr and ignores the function (defaults are used instead). This is visible without --debug so plugin developers get immediate feedback.

Both execution paths deliver the same sdk.Context to Execute(). The direct path injects it via Go context; the sandboxed path pipes it as JSON via stdin from the parent process. Plugin authors do not need to distinguish between the two — sdk.Context is populated identically in both cases.

SDK Tool Types

type ToolSchema struct {
    Name        string
    Description string
    Usage       string
    Examples    string
    Parameters  ToolParameters
}

type ToolParameters struct {
    Type       string                  // always "object"
    Properties map[string]ToolProperty
    Required   []string
}

type ToolProperty struct {
    Type        string
    Description string
    Enum        []any
}

type ToolPaths struct {
    Read   []string // sandbox: paths the tool will read
    Write  []string // sandbox: paths the tool will write
    Record []string // filetime: mark as "content seen by LLM" after execution
    Guard  []string // filetime: require "content seen by LLM" before execution
    Clear  []string // filetime: invalidate "content seen" (e.g. after deletion)
}

Why 5 path lists: Read and Record are NOT the same thing. Read = “sandbox, allow reading this path.” Record = “the LLM has now seen the actual file contents.” A tool that lists a directory reads it (needs sandbox permission) but the LLM only saw a listing, not file contents. Same for Write vs Guard: a tool that creates new files needs write permission but doesn’t need to assert prior reads. Clear invalidates “content seen” status — use after deleting a file so the LLM can’t patch a path it previously read but that no longer exists.

Tool Metadata in plugin.yaml

description: "What this tool plugin does"
override: false # default: false — set true to replace a built-in tool with same name
opt_in: false # default: false — set true to hide unless explicitly enabled by name
Field Default Description
override false Replace an existing tool with the same name. Without this, name conflicts are a startup error.
opt_in false Hide this tool unless explicitly named in an enabled list at any filter layer.

Example: Notepad Tool Plugin

A tool plugin that reads and writes scratch files with full sandbox and filetime integration:

go.mod:

module notepad

go 1.25

require github.com/idelchi/aura/sdk v0.0.0

main.go:

package notepad

import (
    "context"
    "fmt"
    "os"

    "github.com/idelchi/aura/sdk"
)

func Schema() sdk.ToolSchema {
    return sdk.ToolSchema{
        Name:        "Notepad",
        Description: "Read or write a scratch file.",
        Parameters: sdk.ToolParameters{
            Type: "object",
            Properties: map[string]sdk.ToolProperty{
                "action":  {Type: "string", Description: "read or write", Enum: []any{"read", "write"}},
                "path":    {Type: "string", Description: "File path"},
                "content": {Type: "string", Description: "Content to write (required for write action)"},
            },
            Required: []string{"action", "path"},
        },
    }
}

func Execute(_ context.Context, _ sdk.Context, args map[string]any) (string, error) {
    action := args["action"].(string)
    path := args["path"].(string)

    switch action {
    case "read":
        data, err := os.ReadFile(path)
        if err != nil {
            return "", err
        }
        return string(data), nil
    case "write":
        content, ok := args["content"].(string)
        if !ok {
            return "", fmt.Errorf("content is required for write action")
        }
        if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
            return "", err
        }
        return fmt.Sprintf("wrote %d bytes to %s", len(content), path), nil
    default:
        return "", fmt.Errorf("unknown action: %s", action)
    }
}

func Paths(args map[string]any) (sdk.ToolPaths, error) {
    path := args["path"].(string)
    action := args["action"].(string)

    switch action {
    case "read":
        return sdk.ToolPaths{
            Read:   []string{path},
            Record: []string{path},
        }, nil
    case "write":
        return sdk.ToolPaths{
            Write: []string{path},
            Guard: []string{path},
        }, nil
    default:
        return sdk.ToolPaths{}, nil
    }
}

plugin.yaml:

description: "Read/write scratch files  proof-of-concept tool plugin with full sandbox and filetime integration"

Test with:

aura tools Notepad                                                              # schema
aura tools Notepad action=read path=/tmp/file.txt                               # read
aura tools Notepad '{"action": "write", "path": "/tmp/file.txt", "content": "hello"}'  # write

Example: Combined Hook + Tool Plugin

A plugin can export both hooks and a tool. This example injects a context summary via BeforeChat and exposes a ProjectStats tool that reports accumulated turn counts.

go.mod:

module project-stats

go 1.25

require github.com/idelchi/aura/sdk v0.0.0

main.go:

package project_stats

import (
    "context"
    "fmt"

    "github.com/idelchi/aura/sdk"
)

// --- Hook: inject context summary every 5 turns ---

func BeforeChat(_ context.Context, c sdk.BeforeChatContext) (sdk.Result, error) {
    if c.Stats.Turns == 0 || c.Stats.Turns%5 != 0 {
        return sdk.Result{}, nil
    }
    return sdk.Result{
        Message: fmt.Sprintf("Turn %d | tokens: %d (%.0f%%) | agent: %s",
            c.Stats.Turns, c.Tokens.Estimate, c.Tokens.Percent, c.Agent),
        Role:   sdk.RoleUser,
        Prefix: "[project-stats] ",
        Eject:  true,
    }, nil
}

// --- Tool: report stats ---

func Schema() sdk.ToolSchema {
    return sdk.ToolSchema{
        Name:        "ProjectStats",
        Description: "Show session turn count.",
        Parameters:  sdk.ToolParameters{Type: "object"},
    }
}

func Execute(_ context.Context, _ sdk.Context, _ map[string]any) (string, error) {
    return fmt.Sprintf("Total turns: %d", turnCount), nil
}

plugin.yaml:

description: "Injects context summary and exposes a ProjectStats tool"

SDK Types

Every hook receives a context struct with runtime state:

type TokenState struct {
    Estimate int     // Client-side estimate: per-message sum + tool schema tokens
    LastAPI  int     // API-reported input tokens from last provider call
    Percent  float64 // 0-100 range, based on Estimate
    Max      int     // Effective context window size in tokens
}

type ModelInfo struct {
    Name           string   // e.g. "llama3:8b"
    Family         string   // e.g. "llama"
    ParameterCount int64    // e.g. 8000000000
    ContextLength  int      // e.g. 8192
    Capabilities   []string // e.g. ["vision", "tools"]
}

type Stats struct {
    StartTime    time.Time
    Duration     time.Duration
    Interactions int
    Turns        int
    Iterations   int
    ToolCalls    int
    ToolErrors   int
    ParseRetries int
    Compactions  int
    TokensIn     int
    TokensOut    int
    TopTools     []ToolCount
}

type ToolCount struct {
    Name  string
    Count int
}

type ToolCall struct {
    Name     string
    Args     map[string]any
    ArgsJSON string        // JSON-encoded Args for reliable comparison
    Result   string
    Error    string
    Duration time.Duration // wall-clock execution time
}

type FeatureState struct {
    Sandbox           bool   // Sandbox enabled at runtime
    ReadBeforeWrite   bool   // Read-before-write enforcement active
    ShowThinking      bool   // Thinking display in TUI (verbose mode)
    CompactionEnabled bool   // Whether compaction is configured
}

type Turn struct {
    Role    string // "user" or "assistant"
    Content string
}

type Context struct {
    Iteration    int
    Tokens       TokenState
    Agent        string
    Mode         string
    ModelInfo    ModelInfo
    Stats        Stats
    Workdir      string
    MessageCount int

    // Conversation state
    Auto                 bool
    DoneActive           bool
    ResponseEmpty        bool
    ResponseContentEmpty bool
    HasToolCalls         bool
    MaxSteps             int

    // Todo state
    TodoPending    int
    TodoInProgress int
    TodoTotal      int

    // Tool/patch tracking
    PatchCounts  map[string]int
    ToolHistory  []ToolCall

    // Session identity
    SessionID    string // UUID of active session (empty if unsaved)
    SessionTitle string // User-set or auto-generated title

    // Provider & runtime
    Provider  string // Active provider name (e.g., "anthropic", "ollama")
    ThinkMode string // Thinking mode: "off", "low", "medium", "high"

    // Feature state
    Features FeatureState

    // Tool awareness
    AvailableTools []string // Names of all tools available to the agent (after filtering)
    LoadedTools    []string // Deferred tools explicitly loaded this session

    // Conversation turns (text-only user/assistant messages)
    Turns []Turn

    // System prompt sent to the model
    SystemPrompt string

    // Connected MCP server names
    MCPServers []string

    // Template variables from --set flags
    Vars map[string]string

    // Plugin config (merged: plugin.yaml → global → local)
    PluginConfig map[string]any
}

Timing-specific contexts add extra fields:

Context Extra Fields
BeforeChatContext (none – base context only)
AfterResponseContext Response string, Thinking string, Calls []ToolCall (pending, unexecuted)
BeforeToolContext ToolName string, Arguments map[string]any
AfterToolContext ToolName, ToolResult, ToolError, ToolDuration
OnErrorContext Error string, ErrorType string, Retryable bool, StatusCode int
BeforeCompactionContext Forced bool, TokensUsed int, ContextPercent float64, MessageCount int, KeepLast int
AfterCompactionContext Success bool, PreMessages int, PostMessages int, SummaryLength int
OnAgentSwitchContext PreviousAgent string, NewAgent string, Reason string
TransformContext Messages []Message (ForLLM-filtered messages with positional IDs)

ToolConfig

Passed to the optional Init() function once at load time:

type ToolConfig struct {
    HomeDir   string // user home directory (~)
    ConfigDir string // project .aura/ directory
}

Return Value

type Result struct {
    Message      string   // Text to inject (empty = no injection)
    Role         Role     // sdk.RoleUser or sdk.RoleAssistant (default: assistant)
    Prefix       string   // Prepended to Message (e.g. "[my-plugin] ")
    Eject        bool     // Remove the injected message after one turn
    DisableTools []string // Tool name patterns to disable (e.g. ["*"] to disable all)
    Notice       string                // Display-only text (shown in TUI, not sent to LLM; overrides Message)
    Output       *string               // AfterToolExecution only: replaces tool result sent to LLM
    Response     *ResponseModification // AfterResponse only: skip or rewrite response before history
    Error        *ErrorModification    // OnError only: retry or skip the error
    Request      *RequestModification  // BeforeChat only: append to system prompt or skip chat
    Compaction   *CompactionModification // BeforeCompaction only: skip built-in compaction
}

type CompactionModification struct {
    Skip bool // Don't compact -- plugin manages context itself
}

type ResponseModification struct {
    Skip    bool    // Don't add response to conversation history (already streamed to UI)
    Content *string // Replace response content before adding to history (nil = keep original)
}

type ErrorModification struct {
    Retry bool // Retry chat() without counting as a new iteration (max 3 retries)
    Skip  bool // Suppress the error — loop returns nil instead of the error
}

type RequestModification struct {
    AppendSystem *string // Append text to system prompt for this turn only (not persisted)
    Skip         bool    // Skip chat() entirely — the turn ends immediately
}

BeforeToolExecution hooks return sdk.BeforeToolResult instead:

type BeforeToolResult struct {
    Result                     // Embeds Result (message injection after validation)
    Arguments map[string]any   // If non-nil, replaces tool arguments before execution
    Block     bool             // If true, skips tool execution entirely
}

Messages from BeforeToolResult are injected only after the tool passes both the block check and argument re-validation. If the tool is blocked, the first message’s content is folded into the block ephemeral result instead of being injected separately. If argument validation fails, messages are not injected at all.

TransformMessages hooks return []sdk.Message instead of sdk.Result – they transform the message array directly rather than injecting messages. See TransformMessages for details.

Return a zero sdk.Result{} (empty Message) to skip injection – the hook ran but has nothing to say.

Return an error to suppress injection silently. Plugin errors are non-fatal – they’re logged but don’t stop the conversation.

Vendoring Dependencies

Plugins can import third-party Go packages by vendoring them into the plugin directory.

Workflow

cd .aura/plugins/my-plugin

# Add a dependency
go get github.com/some/library

# Vendor it
go mod vendor

This creates a vendor/ directory inside your plugin containing the dependency source code. Yaegi interprets the vendored source at runtime.

Auto-vendoring on install and update

Both aura plugins add and aura plugins update run go mod tidy && go mod vendor automatically after cloning or pulling. This requires the Go toolchain to be available in PATH. The vendored files are persisted in the plugin directory — subsequent loads use the existing vendor/ with no Go toolchain required.

To skip vendoring (e.g. if the plugin already ships a vendor/ directory or has no external dependencies), pass --no-vendor:

aura plugins add https://github.com/user/aura-plugin-metrics --no-vendor
aura plugins update metrics --no-vendor

Private dependencies: Vendoring runs go mod tidy which uses system git — not aura’s built-in auth chain. If the plugin depends on private modules, your Go environment must be configured for access independently (e.g. ~/.netrc, git config url.insteadOf, GOPRIVATE). The auth tokens used for cloning (GITLAB_TOKEN, etc.) are not automatically forwarded to go mod tidy.

How it works

  • Yaegi resolves imports by checking the plugin’s vendor/ directory first, then the GoPath.
  • Each plugin gets its own Yaegi interpreter with its own GoPath. Two plugins can vendor different versions of the same library without conflict.
  • The Aura SDK is a special case: sdk.Symbols (a binary symbol map) provides the SDK types to the interpreter. Even if the SDK source exists in vendor/ (because go mod vendor copies it), the binary symbol map takes precedence. The vendored SDK source is dead code at runtime – it exists only to satisfy go build and tooling during development.

Why vendored SDK is dead code

When Yaegi encounters import "github.com/idelchi/aura/sdk", it checks registered symbol maps first. Since sdk.Symbols is loaded before the plugin is imported, Yaegi uses the binary types. This is critical for type identity – SDK types returned by plugins must match the compiled types used by the host binary. If Yaegi interpreted the vendored SDK source instead, the types would be distinct (interpreted vs. compiled) and type assertions would fail.

SDK version compatibility

Aura checks SDK compatibility at install time, update time, and load time. After vendoring, Aura reads const Version from the vendored sdk/version/version.go via Yaegi evaluation and compares it to the host SDK version using semver tilde-range matching (~pluginVersion):

  • ~0.1.0 matches host >=0.1.0, <0.2.0 — same minor, any patch at or above the plugin’s patch
  • A plugin built against SDK 0.1.0 loads on host 0.1.5 (patch-compatible)
  • A plugin built against SDK 0.1.5 fails on host 0.1.0 (host too old — plugin may use newer features)
  • A plugin built against SDK 0.2.0 fails on host 0.1.x (minor version mismatch)

If versions are incompatible, install/update is rejected and load-time produces a clear error:

plugin "my-plugin": plugin requires SDK ~0.2.0, host has 0.1.0

aura plugins show displays compatibility status (e.g. SDK Version: 0.1.0 (compatible) or INCOMPATIBLE).

To fix a version mismatch, update the SDK dependency and re-vendor:

aura plugins update my-plugin

Or manually:

cd .aura/plugins/my-plugin
go get github.com/idelchi/aura/sdk@latest
go mod vendor

Plugins without a vendor/ directory (in-repo test/sample plugins) skip the version check entirely.

Example Plugins

The repository includes example plugins under .aura/plugins/ that demonstrate the plugin system. These are not installed by aura init – copy them manually or use them as reference for writing your own.

Injectors (injectors/):

Plugin Timing What
todo-reminder BeforeChat Reminds about pending todos every N iterations (default: 20)
max-steps BeforeChat Disables tools when iteration limit reached
session-stats BeforeChat Shows session stats summary (turns, tool calls, context usage, top tools) every 5 turns
todo-not-finished AfterResponse Warns if todos incomplete and no tool calls
empty-response AfterResponse Handles empty LLM responses
done-reminder AfterResponse Reminds LLM to call Done tool when stopping
loop-detection AfterToolExecution Detects repeated identical tool calls
failure-circuit-breaker AfterToolExecution Stops after 3+ consecutive different tool failures
repeated-patch AfterToolExecution Warns after N patches to same file (default: 5)

Diagnostics (diagnostics/):

Plugin Type What
tool-logger Hook Logs every tool call and result to a file
turn-tracker Hook Injects a status line every N turns with token and context info

Tools (tools/):

Plugin What
gotify Sends push notifications via Gotify (opt-in)
notepad Read/write scratch files — proof-of-concept tool plugin

Installed plugins load by default. Use --without-plugins to disable all plugins.

Conditions

The condition field in plugin.yaml and the /assert command share the same condition vocabulary. Invalid condition expressions are rejected at config load time.

Boolean Conditions

Condition True when
todo_empty No todo items exist
todo_done All todo items completed
todo_pending Pending or in-progress items exist
auto Auto mode is enabled

Comparison Conditions

Greater-than Less-than Compares
context_above:<N> context_below:<N> Token usage percentage
history_gt:<N> history_lt:<N> Message count
tool_errors_gt:<N> tool_errors_lt:<N> Tool error count
tool_calls_gt:<N> tool_calls_lt:<N> Tool call count
turns_gt:<N> turns_lt:<N> LLM turn count
compactions_gt:<N> compactions_lt:<N> Compaction count
iteration_gt:<N> iteration_lt:<N> Current iteration
tokens_total_gt:<N> tokens_total_lt:<N> Cumulative tokens (in+out)
model_context_gt:<N> model_context_lt:<N> Model max context tokens

Model Conditions

Condition True when
model_has:vision Model supports vision/image input
model_has:tools Model supports tool calling
model_has:thinking Model supports reasoning output
model_has:thinking_levels Model supports configurable thinking depth
model_has:embedding Model is an embedding model
model_has:reranking Model supports reranking
model_has:context_override Model supports context length override
Greater-than Less-than Compares
model_params_gt:<N> model_params_lt:<N> Parameter count in billions (supports decimals, e.g. 0.5)

Model parameter and context conditions return false when the model is unresolved (zero values), preventing false positives like model_params_lt:10 matching before model resolution.

model_is:<name> Model name matches (case-insensitive)

Filesystem & Shell Conditions

Condition True when
exists:<path> File or directory exists (relative to CWD or absolute)
bash:<cmd> Shell command exits 0 (120s timeout)

Negation

Prefix any condition with not to negate it:

condition: "not auto"
condition: "not todo_done"

Composition

Join conditions with and – all must be true:

condition: "history_gt:5 and history_lt:10"
condition: "auto and context_above:70"
condition: "todo_pending and not turns_gt:50"
condition: "model_has:tools and model_params_gt:10"

Examples

Turn Tracker

Injects a status line every 3 turns and warns about long responses.

go.mod:

module turn-tracker

go 1.25

require github.com/idelchi/aura/sdk v0.0.0

main.go:

package turn_tracker

import (
    "context"
    "fmt"

    "github.com/idelchi/aura/sdk"
)

var lastResponse int

func BeforeChat(_ context.Context, hctx sdk.BeforeChatContext) (sdk.Result, error) {
    if hctx.Stats.Turns == 0 || hctx.Stats.Turns%3 != 0 {
        return sdk.Result{}, nil
    }

    return sdk.Result{
        Message: fmt.Sprintf(
            "=== TURN %d | context: %d tokens (%.0f%%) | agent: %s | model: %s | messages: %d ===",
            hctx.Stats.Turns,
            hctx.Tokens.Estimate,
            hctx.Tokens.Percent,
            hctx.Agent,
            hctx.ModelInfo.Name,
            hctx.MessageCount,
        ),
        Role:   sdk.RoleUser,
        Prefix: "[turn-tracker] ",
        Eject:  true,
    }, nil
}

func AfterResponse(_ context.Context, hctx sdk.AfterResponseContext) (sdk.Result, error) {
    lastResponse = len(hctx.Response)

    if lastResponse > 5000 {
        return sdk.Result{
            Message: fmt.Sprintf(
                "Last response was %d characters -- that's very long. Consider asking for shorter answers.",
                lastResponse,
            ),
            Prefix: "[turn-tracker] ",
        }, nil
    }

    return sdk.Result{}, nil
}

plugin.yaml:

description: "Tracks conversation turns and context usage with loud status messages"

Plugin with Vendored Dependency

A plugin that uses a third-party string manipulation library.

go.mod:

module metrics-logger

go 1.25

require (
    github.com/idelchi/aura/sdk v0.0.0
    github.com/dustin/go-humanize v1.0.1
)

main.go:

package metrics_logger

import (
    "context"
    "fmt"

    "github.com/dustin/go-humanize"
    "github.com/idelchi/aura/sdk"
)

var turnCount int

func BeforeChat(_ context.Context, hctx sdk.BeforeChatContext) (sdk.Result, error) {
    turnCount++
    if turnCount%5 != 0 {
        return sdk.Result{}, nil
    }

    return sdk.Result{
        Message: fmt.Sprintf("Turn %d | tokens: %s (%.0f%%)",
            turnCount,
            humanize.Comma(int64(hctx.Tokens.Estimate)),
            hctx.Tokens.Percent,
        ),
        Prefix: "[metrics] ",
        Eject:  true,
    }, nil
}

Directory after go mod vendor:

.aura/plugins/metrics-logger/
├── go.mod
├── go.sum
├── plugin.yaml
├── main.go
└── vendor/
    ├── modules.txt
    ├── github.com/
    │   ├── dustin/
    │   │   └── go-humanize/
    │   │       ├── humanize.go
    │   │       └── ...
    │   └── idelchi/
    │       └── aura/
    │           └── sdk/
    │               └── sdk.go          # dead code -- binary symbol map wins
    └── ...

Available Imports

Category Source Packages
SDK Binary symbol map github.com/idelchi/aura/sdk
Stdlib Yaegi built-in fmt, strings, net/http, encoding/json, time, sync, reflect, math, crypto/*, and all other standard library packages
Unrestricted Yaegi unrestricted os/exec — requires unsafe mode (see Unsafe Mode)
Third-party Plugin vendor/ Any Go package vendored via go mod vendor

Unsafe Mode

By default, plugins cannot import os/exec or other restricted packages. This is enforced by the Yaegi interpreter — plugins that try import "os/exec" fail at load time with an actionable error message.

To enable restricted imports for all plugins:

# .aura/config/features/plugins.yaml
plugins:
  unsafe: true

Or use the CLI flag:

aura --unsafe-plugins

When unsafe mode is enabled, Yaegi loads unrestricted.Symbols which provides os/exec, additional os functions (Exit, FindProcess), syscall symbols, and log.Fatal*.

Environment Variables

By default, plugins cannot read environment variables – os.Getenv returns empty strings. Yaegi sandboxes the os env functions in restricted mode.

To grant a plugin access to specific environment variables, declare them in plugin.yaml:

description: "Send push notifications via Gotify"
env:
  - GARFIELD_LABS_GOTIFY_URL
  - GARFIELD_LABS_GOTIFY_TOKEN

The plugin can then use os.Getenv("GARFIELD_LABS_GOTIFY_URL") normally. Undeclared env vars remain invisible (return empty string).

Wildcard access

Use "*" to grant access to all environment variables:

env:
  - "*"

This disables env sandboxing entirely for the plugin. Use sparingly – prefer declaring specific vars.

Three levels

Declaration Behavior
No env field No env var access (default)
env: [KEY1, KEY2] Only listed vars visible
env: ["*"] Full process environment

Environment variables are loaded from --env-file (defaults to secrets.env) at startup. The env field controls which of those loaded vars the plugin can see.

Task env injection

Task-scoped env: vars are automatically injected into plugins before each tool Execute() and hook Check() call. Only vars in the plugin’s whitelist are injected – the same env: list that controls startup access also controls task injection.

# Task sets GOTIFY_LOG_LEVEL at runtime
logs:
  env:
    GOTIFY_LOG_LEVEL: ERROR
  tools:
    enabled: [Gotify]

The gotify plugin sees GOTIFY_LOG_LEVEL=ERROR via os.Getenv() because it declares the var in its whitelist. Task env values override startup-captured values.

Plugin Feature Config

Global plugin settings live in .aura/config/features/plugins.yaml:

plugins:
  dir: "" # plugin directory (relative to home; empty = "plugins/")
  unsafe: false # allow os/exec in plugins (default: false)
  include: [] # only load these plugins by name (empty = all)
  exclude: [] # skip these plugins by name
Field Default Description
dir "plugins/" Plugin directory. Relative paths resolve against the config home that declares them.
unsafe false Allow plugins to use os/exec and other restricted imports
include [] Only load plugins matching these patterns. Empty = load all. Supports wildcards (*).
exclude [] Skip plugins matching these patterns. Applied after include. Supports wildcards (*).

Example — load only specific plugins:

plugins:
  include: [notepad, turn-tracker]

Example — load plugins by pattern:

plugins:
  include: ["diagnostic-*"]

Example — exclude a plugin:

plugins:
  exclude: [loop-detection]

Plugin Config Values

Plugins can receive configuration values via sdk.Context.PluginConfig. Values are merged from three layers (lowest to highest precedence):

  1. Plugin defaultsconfig: section in the plugin’s own plugin.yaml
  2. Globalplugins.config.global in features config (sent to all plugins)
  3. Localplugins.config.local.<plugin-name> in features config (per-plugin overrides)
# .aura/config/features/plugins.yaml
plugins:
  config:
    global:
      verbose: true
    local:
      failure-circuit-breaker:
        max_failures: 5

Reading config in a hook:

func AfterToolExecution(_ context.Context, ctx sdk.AfterToolContext) (sdk.Result, error) {
    threshold := 3
    if v, ok := ctx.PluginConfig["max_failures"].(int); ok {
        threshold = v
    }
    // ...
}

Reading config in a tool:

func Execute(_ context.Context, sc sdk.Context, args map[string]any) (string, error) {
    endpoint := "https://default.example.com"
    if v, ok := sc.PluginConfig["endpoint"].(string); ok {
        endpoint = v
    }
    // sc also carries: sc.Agent, sc.Mode, sc.Tokens, sc.ModelInfo, sc.Workdir, etc.
    // ...
}

YAML integers decode as Go int (not float64). Use .(int) for type assertions.

Limitations

No unsafe or syscall (without unsafe mode)

The unsafe and syscall packages are excluded from Yaegi’s stdlib by default. Use os for filesystem operations and net for networking. Enable unsafe mode to access os/exec.

No cgo

import "C" is not supported. Plugins run entirely within the Yaegi interpreter.

No generics

Type parameters, type constraints, and generic stdlib functions (slices.SortFunc, maps.Keys, etc.) are not supported. Basic type inference works for simple cases, but anything involving ~ constraints or parameterized types will fail at interpretation time.

No min/max builtins

The Go 1.21 min and max builtins are not recognized by Yaegi. Use math.Min/math.Max (for float64) or write a simple comparison.

No range-over-func

for k, v := range someFunc { ... } (Go 1.23 range-over-function iterators) panics at runtime. Use traditional iteration patterns.

No third-party generics, cgo, or assembly

Vendored packages that use generics, import "C", or assembly stubs will fail. Stick to pure-Go libraries that avoid these features. Check transitive dependencies – a library may look safe but pull in a generic helper.

Reflection on interpreted types

Host-side reflection cannot see into interpreted types. This means errors.As panics when targeting an interpreted error type, json.Marshal may not see struct tags correctly, and type assertions from host to interpreted types can fail unexpectedly. Keep error handling simple (error interface, string matching) and marshal data in the plugin before returning.

Rune type comparisons

Yaegi internally represents rune as int32 but Go’s type system expects int32 – comparisons and switch statements on rune values can produce unexpected type mismatch errors. Cast explicitly when comparing.

Interpreter quirks

  • Closure loopvar is fixed in the current Yaegi fork, but the v := v shadowing pattern is still recommended for readability and compatibility with older plugin SDK versions.
  • Recursive types work but have edge cases with deeply nested recursive definitions.
  • Complex multi-interface embedding has occasional limitations.
  • init() functions execute when the plugin is loaded – use them for one-time setup, but keep them fast.
  • Goroutines work (real OS goroutines) but are fire-and-forget from the hook’s perspective. The hook must return a Result synchronously.
  • Panics in hooks are caught and converted to errors (non-fatal).

Managing Plugins

The aura plugins command provides tools for listing, inspecting, installing, updating, and removing plugins.

List installed plugins

aura plugins list

Shows all plugins with their status, hooks, and source. Plugins installed from git include origin info. If a plugin’s probe fails (e.g. missing go.mod, broken source), the capabilities column shows (probe failed) instead of empty fields.

Inspect a plugin

aura plugins show <name>

Shows full details: description, status, condition, origin, hooks, and files. If the probe fails, a Probe: (failed: <error>) line is shown with the specific error.

Install from git

# Default branch
aura plugins add https://github.com/user/aura-plugin-metrics

# Specific tag or branch
aura plugins add https://github.com/user/aura-plugin-metrics --ref v0.2.0

# SSH URL
aura plugins add git@github.com:user/aura-plugin-metrics.git

# Custom directory name
aura plugins add https://github.com/user/aura-plugin-metrics --name custom-name

# Install globally (~/.aura)
aura plugins add https://github.com/user/aura-plugin-metrics --global

# Scope to a subdirectory within the repo
aura plugins add https://github.com/user/aura-plugins-collection --subpath tools/metrics

The plugin name defaults to the last path segment of the URL, stripped of aura-plugin- prefix. The --subpath flag scopes installation to a subdirectory: git sources clone the full repo but only discover and load plugins from the subpath; local sources copy only the subpath content. The subpath is persisted in .origin.yaml.

Authentication is tried in order: no auth (public repos), environment tokens (GITLAB_TOKEN, GITHUB_TOKEN, GIT_TOKEN), then git credential fill. For SSH URLs: SSH agent, then key files (~/.ssh/id_ed25519, id_rsa, id_ecdsa).

Install from local path

aura plugins add ./path/to/my-plugin --name my-plugin

Copies the directory into the plugins folder. Local plugins have no update path.

Update

# Update by plugin name (resolves to its pack)
aura plugins update gotify

# Update by pack name directly
aura plugins update tools

# Update all git-sourced plugins
aura plugins update --all

Only works for plugins installed from git (those with a .origin.yaml sidecar file). For pack plugins, the entire pack is updated regardless of whether you specify a plugin name or pack name.

Remove

# Remove a standalone plugin
aura plugins remove my-standalone

# Remove an entire pack
aura plugins remove tools

Removes the plugin or pack directory. Pack plugins cannot be removed individually — use disabled: true in plugin.yaml to skip a plugin within a pack. Reinstall with aura plugins add if needed.

Lifecycle

Event What happens
Startup go.mod read, source + vendor/ copied to GoPath, interpreter created, hooks + tools probed and registered
Each turn Hooks checked at their timing point; condition evaluated first if set
/reload Old interpreter destroyed, new one created from current files
Shutdown Interpreter released

Plugin state (package-level variables) persists across hook invocations but is reset on /reload.

Panic recovery: If a plugin hook panics during execution, Aura recovers instead of crashing. The panic is logged and the error is surfaced as a display-only message (visible in the UI but not injected into conversation history). This prevents a buggy plugin from taking down the entire session while keeping the user informed of the failure.

Visibility

Use /plugins to list all loaded plugin hooks grouped by timing:

BeforeChat:
  todo-reminder/BeforeChat       enabled
  max-steps/BeforeChat           enabled

AfterResponse:
  todo-not-finished/AfterResponse  enabled
  empty-response/AfterResponse     enabled
  done-reminder/AfterResponse      enabled

AfterToolExecution:
  loop-detection/AfterToolExecution           enabled
  failure-circuit-breaker/AfterToolExecution  enabled
  repeated-patch/AfterToolExecution           enabled

Plugins with a condition or once: true show extra info:

  my-plugin/BeforeChat           enabled  condition="auto and context_above:70" once=true (fired)

Back to top

Copyright © 2026 idelchi. Distributed under the MIT License.