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, auth
  • chat.goChat() implementation with streaming
  • models.goModels() and Model() 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 the Hook struct
  • dispatch — 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 using prepareAndDispatch + buildBaseInjection, and add the timing to the guard in Check() 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 by file.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.


Back to top

Copyright © 2026 idelchi. Distributed under the MIT License.