Extending Aura
Step-by-step guides for adding new capabilities. Each guide lists every file that needs changes.
Adding a New Built-in Tool
1. Create the tool package
Create internal/tools/<name>/<name>.go:
package <name>
import (
"context"
"github.com/idelchi/aura/pkg/llm/tool"
)
type Tool struct {
tool.Base
}
func New() *Tool {
return &Tool{
Base: tool.Base{
Text: tool.Text{
// Description, Usage, Examples set here or via YAML overrides
},
},
}
}
func (t *Tool) Schema() tool.Schema {
return tool.Schema{
Name: "<name>",
Description: "What this tool does",
Parameters: tool.Parameters{
Type: "object",
Properties: map[string]tool.Property{
// Define parameters
},
},
}
}
func (t *Tool) Execute(ctx context.Context, args map[string]any) (string, error) {
// Implementation
return "result", nil
}
tool.Base provides no-op Pre, Post, and Paths. Override only what you need.
2. Opt-in interfaces
Implement these as needed (type-asserted at call sites, not declared on Base):
| Interface | Method | Default | Purpose |
|---|---|---|---|
PreHook | Pre(ctx, args) error | no-op | Validation before execution |
PostHook | Post(ctx, args) | no-op | Cleanup after execution |
PathDeclarer | Paths(ctx, args) (read, write, error) | no paths | Filesystem paths for Landlock sandbox |
SandboxOverride | Sandboxable() bool | true | Whether tool runs in sandbox |
ParallelOverride | Parallel() bool | true | Whether tool can run in parallel |
LSPAware | WantsLSP() bool | false | Whether tool needs LSP manager |
Previewer | Preview(ctx, args) (string, error) | none | Preview output before execution |
Overrider | Overrides() bool | false | Plugin tool overriding a built-in |
Closer | Close() | none | Cleanup on shutdown |
3. Register the tool
Add the tool in internal/tools/tools.go (All() function). Tool assembly (base tools + Task + Batch + ToolDefs) is handled by internal/tools/assemble (assemble.Tools()), so new always-present tools only need to be added in one place.
4. Consider both execution paths
Tools execute via two paths:
- Direct: in-process, full Go context available
- Sandboxed: child process re-exec with Landlock. No parent state survives — args arrive as CLI JSON, SDK context as stdin JSON, results return as stdout JSON
If your tool relies on parent-process state (context values, cached data), it will break under sandbox. Design for both paths.
5. Test
# Build
go build -o /tmp/aura-test/aura .
# Verify schema appears
/tmp/aura-test/aura tools <name>
# Verify execution (use --agent=high for tool access)
/tmp/aura-test/aura --agent=high --include-tools <Name> run "Use <name> to ..."
Adding a New Provider
1. Create the provider package
Create pkg/providers/<name>/ with at minimum:
client.go— HTTP client setup, base URL, authchat.go—Chat()implementation with streamingmodels.go—Models()andModel()listing
2. Implement core methods
At minimum: Chat(), Models(), Model(), Estimate(). The Chat() method receives a request.Request and a stream.Func callback for streaming tokens back. If your provider doesn’t support native token estimation, return providers.ErrEstimateNotSupported from Estimate().
3. Implement optional capabilities
Implement opt-in interfaces for capabilities your provider supports:
// providers.Embedder — for embedding support
func (c *Client) Embed(ctx context.Context, req embedding.Request) (embedding.Response, usage.Usage, error)
// providers.Reranker — for reranking support
func (c *Client) Rerank(ctx context.Context, req rerank.Request) (rerank.Response, error)
// providers.Transcriber — for audio transcription
func (c *Client) Transcribe(ctx context.Context, req transcribe.Request) (transcribe.Response, error)
// providers.Synthesizer — for speech synthesis
func (c *Client) Synthesize(ctx context.Context, req synthesize.Request) (synthesize.Response, error)
Callers use providers.As[providers.Embedder](provider) to check capabilities at runtime — no stub methods needed.
4. Error handling
Create errors.go with handleError() calling providers.ClassifyHTTPError() to convert HTTP responses into typed errors (rate limit, auth failure, context length exceeded, etc.).
5. Register in factory
Add a case in internal/providers/factory.go switch statement. The factory creates provider instances from config and wraps them with RetryProvider.
6. Add config and example
- Add the provider config type in
internal/config/providers.go - Add an example config file in
.aura/config/providers/
7. Test
/tmp/aura-test/aura --provider=<name> --model=<model> run "hello"
/tmp/aura-test/aura --provider=<name> models
Adding a New Hook Timing
Hook timings define when plugin code runs during the assistant lifecycle. Timings fall into two categories: typed (carry a timing-specific modification field) and generic (only produce base Injection values).
1. Add timing constant
In internal/injector/injector.go, add to the Timing enum. Add to AllTimings slice.
2. Add timing name
In internal/injector/registry.go, add to the timingNames map (maps Timing → display string).
3. Define typed injection struct and checker interface (typed timings only)
If the timing carries a modification field (like Request, Output, Compaction), add in internal/injector/injector.go:
type MyTimingInjection struct {
Injection
MyField *sdk.MyModification
}
func (inj MyTimingInjection) Base() Injection { return inj.Injection }
type MyTimingChecker interface {
CheckMyTiming(ctx context.Context, state *State) *MyTimingInjection
}
4. Add typed registry method (typed timings only)
In internal/injector/registry.go, add a one-liner RunMyTiming() method using the runCheckers generic helper:
func (r *Registry) RunMyTiming(ctx context.Context, state *State) []MyTimingInjection {
return runCheckers(r, ctx, MyTiming, castTo[MyTimingChecker], MyTimingChecker.CheckMyTiming, state)
}
5. Add timing registry entry
In internal/plugins/dispatch.go, add to timingRegistry. Each entry has:
assign— stores the plugin function on theHookstructdispatch— calls the function with the appropriate SDK context
6. Add Hook struct field and CheckXxx method
In internal/plugins/hook.go:
- Add a function field for the new timing
- For typed timings: implement the checker interface with a
CheckMyTiming()method usingprepareAndDispatch+buildBaseInjection, and add the timing to the guard inCheck()so it returns nil - For generic timings:
Check()handles it automatically (no guard exclusion needed)
7. Define SDK context type
In sdk/sdk.go, create a context struct (e.g., OnSessionEndContext) with the fields plugins need.
8. Register symbols
In sdk/symbols.go, add the new context type to the Yaegi symbol map so plugins can reference it.
9. Wire into assistant
In the appropriate assistant file (loop.go, tools.go, compact.go), call the typed RunMyTiming() method at the injection point. Use injector.Bases() to convert to []Injection when passing to injectMessages.
10. Test
Create a test plugin that exports a function matching the new timing signature, run with --debug, and verify the hook fires:
/tmp/aura-test/aura --debug --agent=high run "trigger the timing"
# Check .aura/debug.log for hook dispatch entries
Adding a Config Entity Type
1. Define the type
Create internal/config/<name>.go with the struct definition. Implement the Namer interface:
type MyEntity struct {
name string // populated from map key during loading
// fields...
}
func (e MyEntity) Name() string { return e.name }
2. Choose the collection type
- File-keyed (most entities): use
Collection[T]— one entity per file, keyed byfile.File - String-keyed (multi-per-file like providers, MCPs): use
StringCollection[T]— keyed by entity name
3. Add to Config struct
In internal/config/config.go, add a field of type Collection[MyEntity] or StringCollection[MyEntity].
4. Add Part constant
In internal/config/part.go, add Part<Name> to the constants.
5. Add file discovery
In internal/config/files.go, in the Load() method, add directory scanning for your entity’s config path.
6. Add validation
In internal/config/validate.go, add a validation block for your entity. Use struct validation tags and any custom checks.
7. Add loader
Choose the parsing path based on format:
- Markdown with frontmatter (agents, modes, systems, skills, commands) → use
frontmatter.LoadRaw()to extract YAML frontmatter and body - Pure YAML (providers, features, hooks, tasks, mcp, lsp, sandbox, tools, approval-rules) → use
yamlutil.StrictUnmarshal()with unknown-field rejection
The generic Collection[T] type provides Get(name), Names(), Filter(), and other methods automatically.
Adding a Slash Command
Three paths, from simplest to most powerful:
Custom command (no code)
Create .aura/config/commands/<name>.md with YAML frontmatter:
---
name: <name>
description: What this command does
hints:
- usage hint
---
Prompt template body. Supports {{ .Agent }}, {{ .Mode.Name }}, {{ .Args }}.
Built-in command
Create a file in internal/slash/commands/ and register the command via All(). Set the Category field to group the command in /help output (e.g. "agent", "session", "tools", "context", "execution", "config", "system"). Built-in commands have access to the full slash.Context (assistant state, config, builder).
Plugin command
Export a function matching the command signature in a Go plugin. The SDK provides sdk.CommandSchema and sdk.CommandResult types.
Writing a Plugin
Plugins are Go source loaded at runtime via Yaegi. Three types:
Hook plugin
Export functions matching timing signatures (e.g., BeforeToolExecution(sdk.BeforeToolContext) sdk.Result). The plugin loader probes for exported functions and auto-discovers hooks.
Tool plugin
Export ToolSchema() sdk.ToolSchema and ToolExecute(sdk.Context, map[string]any) (string, error). Set override: true in the schema to replace a built-in tool.
Command plugin
Export CommandSchema() sdk.CommandSchema and CommandExecute(sdk.Context, string) sdk.CommandResult.
Plugin structure
Every plugin needs:
my-plugin/
main.go # Go source with exported functions
plugin.yaml # Metadata (description, author, version)
go.mod # Module definition (imports sdk)
go.sum # Dependency checksums
The package name must match the directory name (with hyphens converted to underscores). plugin.yaml is metadata-only — hooks are auto-discovered.
SDK compatibility
The host checks plugin SDK version at load time. Plugins must use a compatible SDK version. See docs/features/plugins.md for version compatibility details.