Files
VolcanicAgenticCore/docs/extensions.md

14 KiB

Writing extensions

An extension is a class implementing IExtension from Volcanic.AgenticCore.Abstractions. One instance is created per AgentSession, so instance fields are per conversation. ActivateAsync registers things; the runtime binds actions afterwards.

using System.ComponentModel;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;

public sealed record HelloArgs([property: Description("Name to greet")] string Name);

public sealed class HelloTool : Tool<HelloArgs>
{
    public override string Name => "hello";
    public override string Description => "Greets someone";
    public override string? PromptSnippet => "hello: greet a person by name";

    protected override Task<ToolResult> ExecuteAsync(HelloArgs args, ToolInvocation invocation, CancellationToken ct) =>
        Task.FromResult(ToolResult.Text($"Hello, {args.Name}!", new { greeted = args.Name }));
}

public sealed class HelloExtension : IExtension
{
    public string Name => "hello";

    public ValueTask ActivateAsync(IExtensionApi api, CancellationToken ct)
    {
        api.RegisterTool(new HelloTool());

        api.RegisterCommand("hello", new CommandDefinition("Say hello through the model", (args, ctx, ct) =>
        {
            api.SendUserMessage($"Please greet {args} using the hello tool.");
            return Task.CompletedTask;
        }));

        api.On<ToolCallEvent>(async (e, ctx, ct) =>
        {
            if (e.ToolName != "bash") return;
            var command = e.Input["command"]?.GetValue<string>() ?? "";
            if (!command.Contains("rm -rf")) return;
            if (!ctx.HasUi) { e.Block("Dangerous command blocked (no UI to confirm)"); return; }
            var choice = await ctx.Ui.SelectAsync($"Run `{command}`?", ["Allow", "Block"], null, ct);
            if (choice != "Allow") e.Block("Blocked by user");
        });

        return ValueTask.CompletedTask;
    }
}

Register it on the host (o.AddExtension<HelloExtension>()), pass it per session (AgentSessionOptions.ExtraExtensions), or drop the compiled assembly into an extensions directory.

The API object

Member Purpose
On<TEvent>(handler) Subscribe to a lifecycle event. Returns an IDisposable to unsubscribe.
RegisterTool(tool) Add a tool. First registration of a name wins; an extension tool replaces a built-in of the same name.
RegisterCommand(name, def) Add /name. Duplicate names get name:2, name:3.
RegisterProvider(registration) / UnregisterProvider(id) Add an LLM back end (see providers).
SendMessage(custom, delivery, triggerTurn) Inject a CustomMessage (shown to the model as a user message). Steer, FollowUp or NextTurn.
SendUserMessage(text, images, delivery) Inject a user message; always triggers a turn.
AppendEntry(customType, data) Persist extension state on the session branch; never enters the model context.
SetModelAsync, GetThinkingLevel, SetThinkingLevel Session-scoped model control.
GetActiveTools, SetActiveTools, GetAllTools, GetCommands Introspection and tool gating.
GetSessionName, SetSessionName Display name.
ExecAsync(command, args, options) Run a process in the session directory. Its environment comes from the process policy; ExecOptions.Environment sets (or, with null, removes) variables on top of the filtered base.
Events Bus shared by all extensions in the session (Emit(channel, data), On(channel, handler)).
Logger An ILogger named after the extension.
Processes The host's IProcessSpawnPolicy: host hooks only, no extension events. Use it where there's no session, e.g. during activation.
Redactor Redact(text) replaces the host's known secrets with [REDACTED], for text you send to the model outside a tool result.

Action members throw ExtensionNotBoundException when called during ActivateAsync: do registration there and start processes or timers in a SessionStartEvent handler. Processes and Redactor work during activation. After a reload or session replacement the old API and contexts throw StaleExtensionContextException.

Events

Handlers have the shape (TEvent e, IExtensionContext ctx, CancellationToken ct) => ValueTask. Events are mutable; change them to influence the runtime.

Event When What you can do
SessionStartEvent After activation, on new/resume/switch Rebuild state from ctx.SessionManager.GetBranch(), start resources
SessionShutdownEvent Before dispose or replacement Clean up (idempotent)
SessionBeforeSwitchEvent Before new/switch session Cancel()
SessionInfoChangedEvent Name changed notify
InputEvent User text before it becomes a prompt Change Text/Images, or MarkHandled() to consume it
BeforeAgentStartEvent Before a run Edit SystemPromptOptions (base prompt, sections, tools, guidelines), set SystemPromptOverride, add InjectedMessages
AgentStartEvent / AgentEndEvent / AgentSettledEvent Run boundaries notify
TurnStartEvent / TurnEndEvent Turn boundaries TurnEndEvent.ContinueRequested = true forces another model call
ContextEvent Before each model call Edit Messages in place (system prompt excluded)
BeforeProviderRequestEvent Provider payload built Replace Payload
MessageStartEvent / MessageUpdateEvent / MessageEndEvent Message streaming MessageEndEvent.Message can be replaced (same role) before it is persisted
ToolCallEvent Before a tool runs Patch Input, or Block(reason); a throwing handler blocks
ToolResultEvent After a tool ran Edit Content, Details, IsError
ToolExecutionStart/Update/EndEvent Tool lifecycle notify
ProcessSpawnEvent Before a process the agent asked for starts Replace Context (command, cwd, environment); a throwing handler fails the spawn
SessionBeforeCompactEvent Compaction about to run Cancel() or set Override to supply your own summary
SessionCompactEvent Compaction entry written notify
ModelSelectEvent / ThinkingLevelSelectEvent Settings changed notify

Dispatch rules: handlers run in extension load order, then registration order; exceptions are reported as extension_error session events and dispatch continues (except ProcessSpawnEvent, where the exception also fails the spawn); Cancel, Block and MarkHandled stop further handlers.

The context object

IExtensionContext gives handlers and tools: Cwd, SessionId, TimeZone (the session's zone), HasUi, Ui (select/confirm/input/notify/ status), SessionManager (read-only entries and branch), ModelRegistry (models and provider-neutral CompleteAsync for nested model calls), Model, ThinkingLevel, Signal (cancelled on abort), IsIdle(), Abort(), HasPendingMessages(), GetContextUsage(), GetSystemPrompt(), Compact(), PrepareProcessAsync(request) (see spawning processes), Channel (the host's IChatChannel, or null).

Command handlers get IExtensionCommandContext, which adds WaitForIdleAsync, NewSessionAsync and SwitchSessionAsync.

Tools

Implement ITool directly or derive from Tool<TArgs>; the JSON Schema is generated from TArgs (records with [Description] attributes work well; nullable properties are optional). DelegateTool<TArgs> builds a tool from a lambda.

  • Throw to produce an error result. ToolExecutionException(result) (in Volcanic.AgenticCore.Abstractions.Tools) returns content and details while marking the result as an error.
  • ToolResult.Content is what the model sees; Details is structured data for hosts.
  • Terminate = true on every result of a batch ends the run without another model call.
  • ExecutionMode = Sequential forces the whole batch to run one at a time (the built-in edit/write do this).
  • PromptSnippet puts one line in the system prompt's tools section; PromptGuidelines adds bullets.
  • Report progress through invocation.Report(partial); hosts see it as tool_execution_update.

Spawning processes

A process started on the agent's behalf must not inherit the host's environment as it is: that's how TELEGRAM_BOT_TOKEN or OPENAI_API_KEY ends up in tool output. Build its environment through the process policy (Volcanic.AgenticCore.Abstractions.Processes):

var spawn = await ctx.PrepareProcessAsync(new ProcessSpawnRequest
{
    Purpose = ProcessPurpose.ExtensionExec,
    Command = "gh",
    Arguments = ["issue", "list"],
    Cwd = ctx.Cwd,
    Environment = new Dictionary<string, string?> { ["GH_TOKEN"] = myToken },
    Origin = "my-extension",
}, ct);

var psi = new ProcessStartInfo(spawn.Command) { UseShellExecute = false };
foreach (var a in spawn.Arguments) psi.ArgumentList.Add(a);
spawn.ApplyTo(psi); // WorkingDirectory = spawn.Cwd, Environment cleared, then spawn.Environment copied in
  • In a session, call ctx.PrepareProcessAsync. It fills Scope, SessionId and (unless you set it) TimeZoneId from the session, runs the policy, then emits ProcessSpawnEvent to extensions in load order. TimeZoneId is filled only when the session or host configured a zone; otherwise the child inherits the host's TZ. Without a session (during activation, or from a process-wide service), call api.Processes.Prepare(request): only the host hooks run.
  • The returned ProcessSpawnContext.Environment is the complete environment. spawn.ApplyTo(psi) clears ProcessStartInfo.Environment and copies it in (and sets the working directory); don't merge it with the inherited one.
  • The policy starts from the host's environment, removes what the deny-list (*_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD, their Section__Key forms, and a few names), Allow and SecretValues reject, then applies request.Environment, then sets TZ from TimeZoneId, then runs the host hooks.
  • Variables you pass explicitly are applied after the deny-list and never filtered. A process that needs a token gets it only when you pass it, and no other process sees it. A null value removes the variable.
  • Host hooks (ProcessSpawnHook) are synchronous, pure rewrites of command, arguments, cwd and environment. ProcessSpawnEvent handlers are ordinary (async) extension handlers that may replace Context; keep them free of slow I/O, because they delay the spawn. A hook or handler that throws fails the spawn (the exception comes out of PrepareProcessAsync), and nothing falls back to the inherited environment. Report it the way you report a failed start; the built-in bash returns process environment hook failed: <message>.
  • ExecAsync already does all of this with Purpose = ExtensionExec and Origin = your extension's name.
  • Tool output is redacted by the agent loop: every SecretValues entry in a tool result, progress update or error becomes [REDACTED] before the model, the transcript, hosts or ToolResultEvent handlers see it. Tools don't redact themselves. Text you send to the model some other way (SendMessage, SendUserMessage) isn't redacted: pass it through api.Redactor.Redact first.

Where state should live

State Use
Something the model should see SendMessage / SendUserMessage
Durable data outside the model context AppendEntry and rebuild from GetBranch() on SessionStartEvent
Per-run data that follows the branch tool result Details
Data spanning sessions your own storage

Discovery and loading

Sources, in precedence order (earlier wins tool/command name collisions):

  1. AgenticCoreOptions.Extensions (host code) and AgentSessionOptions.ExtraExtensions
  2. <cwd>/.volcanic/settings.json → {"extensions": ["relative/or/absolute/Ext.dll"]}
  3. <cwd>/.volcanic/extensions/*.dll and <cwd>/.volcanic/extensions/<Name>/<Name>.dll
  4. ~/.volcanic/agent/settings.json → extensions
  5. ~/.volcanic/agent/extensions/*.dll

An assembly is scanned for public, non-abstract IExtension types with a usable constructor (constructor parameters are resolved from the host's IServiceProvider). Each assembly loads in its own AssemblyLoadContext; Volcanic.AgenticCore.*, Microsoft.Extensions.* and System.* are shared with the host so contract types unify. Build an extension with <ProjectReference Include="Volcanic.AgenticCore.Abstractions.csproj" Private="false" /> (or a package reference) so the host's copy is used at runtime.

Activation or load failures are recorded (AgentSession.ExtensionErrors) and logged; other extensions still load.

Feature extensions in this repository

Besides the providers (providers, claude-code-provider.md), extensions/ holds feature extensions built on this API only: Mcp (mcp.md), GoogleCalendar (google-calendar.md), Scheduler (telegram-bot.md) and A2A.

Volcanic.AgenticCore.Extensions.A2A is the A2A client (a2a.md): A2AClientExtension(settings, target, registry) registers the a2a tool (agents, send, status, cancel over the session's .volcanic/a2a/agents.json), keeps the inbound hop count from the a2a.inbound event, and on SessionStartEvent attaches the session for live delivery and flushes results kept for its target. It is meant to be added per session by the host (AgentSessionOptions.ExtraExtensions, as the Telegram host does for each chat), not host-wide. Long remote tasks go to the process-wide A2AWatchRegistry, which follows them by push, subscribe or poll, persists them in .volcanic/a2a/pending.json and hands results to an IA2AResultDelivery: by default the target's live session (SendUserMessage as a follow-up), or the host's own (registry.UseDelivery(...), then RestoreAsync at startup and StopAsync at shutdown). A host with an A2A server routes A2AHostOptions.OnPush to registry.OnPushAsync and sets A2AClientSettings.PushBaseUrl to the server's public URL.