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:
- Read
go.mod– extract the module path (e.g.module my-plugin). - Copy source + vendor/ – copy
.gofiles and thevendor/directory (if present) into a temporary GoPath, preserving the module path as the package import path. - Derive package name – compute the Go package name from the module path: take
path.Base(modulePath)and replace hyphens with underscores. For example,my-pluginbecomesmy_plugin. - Create Yaegi interpreter – one interpreter per plugin, with its own GoPath. This ensures vendor isolation between plugins.
- Load symbol maps – register the Go stdlib and the Aura SDK binary symbol map (
sdk.Symbols). - Import the plugin –
import "<modulePath>"triggers Yaegi’s GTA (Global Type Analysis), parsing all.gofiles and resolving imports from the plugin’svendor/directory. - Probe for hooks – look up
{packageName}.BeforeChat,{packageName}.AfterResponse, etc. using the derived package name. Each discovered hook is wrapped as aninjector.Injectorand 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 currentchat()call and is discarded afterward. Multiple hooks appending compose with\n\nseparators. - 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 embeddedResult), 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
TransformMessagesplugins 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 invendor/(becausego mod vendorcopies it), the binary symbol map takes precedence. The vendored SDK source is dead code at runtime – it exists only to satisfygo buildand 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.0matches 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.0loads on host0.1.5(patch-compatible) - A plugin built against SDK
0.1.5fails on host0.1.0(host too old — plugin may use newer features) - A plugin built against SDK
0.2.0fails on host0.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):
- Plugin defaults —
config:section in the plugin’s ownplugin.yaml - Global —
plugins.config.globalin features config (sent to all plugins) - Local —
plugins.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 := vshadowing 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)