# 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. ```csharp 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 { public override string Name => "hello"; public override string Description => "Greets someone"; public override string? PromptSnippet => "hello: greet a person by name"; protected override Task 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(async (e, ctx, ct) => { if (e.ToolName != "bash") return; var command = e.Input["command"]?.GetValue() ?? ""; 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()`), pass it per session (`AgentSessionOptions.ExtraExtensions`), or drop the compiled assembly into an extensions directory. ## The API object | Member | Purpose | |---|---| | `On(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](providers.md)). | | `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](#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`; the JSON Schema is generated from `TArgs` (records with `[Description]` attributes work well; nullable properties are optional). `DelegateTool` 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`): ```csharp var spawn = await ctx.PrepareProcessAsync(new ProcessSpawnRequest { Purpose = ProcessPurpose.ExtensionExec, Command = "gh", Arguments = ["issue", "list"], Cwd = ctx.Cwd, Environment = new Dictionary { ["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: `. - `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. `/.volcanic/settings.json` → `{"extensions": ["relative/or/absolute/Ext.dll"]}` 3. `/.volcanic/extensions/*.dll` and `/.volcanic/extensions//.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 `` (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](providers.md), [claude-code-provider.md](claude-code-provider.md)), `extensions/` holds feature extensions built on this API only: `Mcp` ([mcp.md](mcp.md)), `GoogleCalendar` ([google-calendar.md](google-calendar.md)), `Scheduler` ([telegram-bot.md](telegram-bot.md#scheduling)) and `A2A`. `Volcanic.AgenticCore.Extensions.A2A` is the A2A client ([a2a.md](a2a.md#client)): `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.