Files
VolcanicAgenticCore/docs/ui-integration.md
EugeneTes 8014a0e770 refactor(scheduler): drop the reminder job kind
Jobs are prompts or watchers. A reminder is a prompt the agent passes on, so
it lands in the transcript. Stored reminder jobs load as prompts
("Remind me: <text>"). Also records the Coolify deploy target in CLAUDE.md.
2026-09-29 09:30:40 +00:00

15 KiB
Raw Permalink Blame History

Building a UI on the core (Telegram guide)

The core has no user interface. A UI is a process that

  1. creates one AgentSession per conversation,
  2. turns incoming user actions into calls on the session (PromptAsync, AbortAsync, SetModelAsync, ...),
  3. subscribes to AgentSessionEvents and renders them,
  4. implements IUserInterface so extensions can ask the user questions,
  5. optionally implements IChatChannel so the agent can send files (and schedulers text) into the chat.

This guide uses Telegram as the worked example; the finished bot lives in hosts/Volcanic.AgenticCore.Telegram (see telegram-bot.md) and is the reference for everything below. Everything transfers to Slack, Discord, a web socket front end or a desktop app; only the rendering code changes.

1. One session per chat

AgentSession is single-conversation and not thread-safe across conversations, so keep one per chat and create it lazily. Give each chat its own working directory (or a shared one per user) and use the chat id as the store scope so session files group by chat:

public sealed class ChatSessions(IAgentSessionFactory factory, string workspaceRoot) : IAsyncDisposable
{
    private readonly ConcurrentDictionary<long, Lazy<Task<AgentSession>>> _sessions = new();

    public Task<AgentSession> GetAsync(long chatId, IUserInterface ui) =>
        _sessions.GetOrAdd(chatId, id => new Lazy<Task<AgentSession>>(() => CreateAsync(id, ui))).Value;

    private async Task<AgentSession> CreateAsync(long chatId, IUserInterface ui)
    {
        var cwd = Path.Combine(workspaceRoot, chatId.ToString());
        Directory.CreateDirectory(cwd);
        return await factory.CreateAsync(new AgentSessionOptions
        {
            Cwd = cwd,
            Scope = $"telegram-{chatId}",
            Ui = ui,
            SessionId = await LastSessionIdAsync(chatId),   // resume the most recent one, or null
        });
    }

    private async Task<string?> LastSessionIdAsync(long chatId) =>
        (await factory.ListSessionsAsync("unused", $"telegram-{chatId}")).FirstOrDefault()?.SessionId;

    public async ValueTask DisposeAsync()
    {
        foreach (var s in _sessions.Values.Where(v => v.IsValueCreated)) await (await s.Value).DisposeAsync();
    }
}

Persistence is automatic (FileSessionStore under ~/.volcanic/agent/sessions/telegram-<chatId>/). To store sessions in a database instead, implement ISessionStore (four methods: create, append, load, list) and set AgenticCoreOptions.SessionStoreFactory.

2. Map chat input to session calls

Telegram input Call
Text message while idle session.PromptAsync(text, images)
Text message while session.IsStreaming session.PromptAsync(text, images, StreamingBehavior.Steer) (or FollowUp)
Photo download it, wrap as ImageContent(base64, "image/jpeg"), pass in images
Document / video / audio download (≤ 20 MB), save into the workspace, mention the path in the prompt text (image documents also as ImageContent)
Voice transcribe on the host (the core only sees text), put the transcript in the prompt
/stop session.AbortAsync()
/model list session.Models.ListModels() where IsAvailable, then session.SetModelAsync(key)
/thinking <level> session.SetThinkingLevelAsync(level) (session.AvailableThinkingLevels)
/new session.NewSessionAsync()
/sessions, /resume <id> session.ListSessionsAsync(), session.SwitchSessionAsync(id)
/compact session.CompactAsync()
/tools session.Tools, session.SetActiveTools(...)
Any other /command just pass it to PromptAsync: extension commands are dispatched there

PromptAsync returns when the run has settled, which can take minutes. Do not await it inside the Telegram update handler; start it with Task.Run (or a channel) and return so the bot keeps receiving updates (/stop must arrive while the run is active).

Use session.State (IsStreaming, Model, MessageCount, PendingSteering, ...) for a /status command.

3. Render events

Subscribe once per session. Handlers are called synchronously on the agent loop: push into your own channel and render from a separate task.

var channel = Channel.CreateUnbounded<AgentSessionEvent>();
session.Subscribe(e => channel.Writer.TryWrite(e));
_ = Task.Run(() => RenderLoopAsync(chatId, channel.Reader));

What to render:

Event Rendering
MessageStart with an AssistantMessage send a placeholder message ("…") and remember its id
MessageUpdate where AssistantMessageEvent is TextDelta append to a buffer; edit the placeholder at most once per second (Telegram rate limits edits)
MessageUpdate with ThinkingDelta optional: show a "thinking…" status; usually not displayed
MessageEnd with an AssistantMessage final edit with message.Text (split at 4096 chars); if StopReason is Error show ErrorMessage
ToolExecutionStart send or update a status line: ▶ bash: ls -la (Args is the JSON arguments)
ToolExecutionUpdate optional live output for long commands (PartialResult.TextContent)
ToolExecutionEnd replace the status line with ✓ bash or ✗ bash plus a short excerpt of Result.TextContent
MessageEnd with a ToolResultMessage nothing (already covered by ToolExecutionEnd) or a collapsed quote
QueueUpdate show queued steering/follow-up texts, if any
CompactionStart / CompactionEnd a status line ("compacting context…")
AutoRetryStart / AutoRetryEnd a status line ("provider overloaded, retrying in 4 s")
AgentSettled remove typing indicator, re-enable input
ExtensionError log; optionally tell the user
Notification send the text (rate limit warnings from Claude Code arrive here)
ModelChanged, ThinkingLevelChanged, SessionInfoChanged confirmation message

Send Telegram sendChatAction: typing on AgentStart and every few seconds until AgentSettled.

Markdown: the model writes Markdown; Telegram's MarkdownV2 needs escaping. Simplest is to send plain text, or convert to Telegram HTML (<b>, <code>, <pre>), which is more forgiving.

4. Implement IUserInterface

Extensions ask questions through ctx.Ui. With Telegram, dialogs become inline keyboards and pending requests are correlated by callback data:

public sealed class TelegramUi(ITelegramBotClient bot, long chatId) : IUserInterface
{
    private readonly ConcurrentDictionary<string, TaskCompletionSource<string?>> _pending = new();

    public async Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? o, CancellationToken ct)
    {
        var id = Guid.NewGuid().ToString("N")[..8];
        var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
        _pending[id] = tcs;
        var keyboard = new InlineKeyboardMarkup(options.Select((opt, i) => InlineKeyboardButton.WithCallbackData(opt, $"ui:{id}:{i}")));
        await bot.SendMessage(chatId, title, replyMarkup: keyboard, cancellationToken: ct);
        using var timeout = new CancellationTokenSource(o?.Timeout ?? TimeSpan.FromMinutes(10));
        using var reg = timeout.Token.Register(() => tcs.TrySetResult(null));
        using var reg2 = ct.Register(() => tcs.TrySetResult(null));
        var index = await tcs.Task;
        _pending.TryRemove(id, out _);
        return index is null ? null : options[int.Parse(index)];
    }

    public async Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? o, CancellationToken ct) =>
        await SelectAsync($"{title}\n{message}", ["Yes", "No"], o, ct) == "Yes";

    public async Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? o, CancellationToken ct)
    {
        // Ask with ForceReply and resolve from the next text message in this chat (see OnMessage below).
        var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
        _pending["input"] = tcs;
        await bot.SendMessage(chatId, title, replyMarkup: new ForceReplyMarkup { InputFieldPlaceholder = placeholder }, cancellationToken: ct);
        using var reg = ct.Register(() => tcs.TrySetResult(null));
        return await tcs.Task;
    }

    public void Notify(string message, NotifyKind kind = NotifyKind.Info) => _ = bot.SendMessage(chatId, $"{Icon(kind)} {message}");
    public void SetStatus(string key, string? text) { /* optional: keep a pinned status message per key */ }

    // Called by the bot's callback-query handler with data "ui:<id>:<index>".
    public bool TryResolve(string id, string value) => _pending.TryGetValue(id, out var tcs) && tcs.TrySetResult(value);
    public bool TryResolveInput(string text) => _pending.TryRemove("input", out var tcs) && tcs.TrySetResult(text);

    private static string Icon(NotifyKind kind) => kind switch { NotifyKind.Warning => "⚠️", NotifyKind.Error => "❌", _ => "ℹ️" };
}

When the user replies to a ForceReply prompt, route the text to TryResolveInput instead of PromptAsync. Because Ui is not NullUserInterface, ctx.HasUi is true and extensions like a permission gate will ask instead of blocking.

5. Implement IChatChannel

Optional. IChatChannel delivers content into the conversation's chat outside the rendering of assistant messages. Pass one per session as AgentSessionOptions.Channel. The core then registers the built-in send tool for that session (active by default; an explicit ActiveTools still wins), and tools and extensions reach the channel as ctx.Channel, host code as session.Channel. Sessions without a channel never see send.

public enum ChatFileKind { Auto, Photo, Document }
public sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null);
public sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId);
public sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength);

public interface IChatChannel
{
    ChatChannelLimits Limits { get; }
    Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken);
    Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken);
}

public sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception(message, inner);
  • Limits: your platform's upload limits (Telegram: photo 10 MB, document 50 MB, caption 1024 characters). send checks them before calling you, and its errors name them.
  • SendTextAsync: markdown, rendered like assistant text and split when long. It is not added to the session transcript, and it must work while no run is active: schedulers use it for failure alerts.
  • SendFileAsync: the core has already confined the path to the workspace (symlinks included), checked that the file exists and applied the limits. Auto lets you choose, and you may fall back (Telegram resends a rejected photo as a document); report what you did in SentAs. Throw only ChatDeliveryException, with a message the model or the user can read, or OperationCanceledException.
  • Keep deliveries in order with your rendering: a file sent directly could overtake an assistant message that is still queued for rendering. The Telegram host runs every delivery on its renderer's queue (ChatRenderer.RunInOrderAsync), so the file lands after the text that preceded the tool call.
  • Calls can come from any thread. Nothing delivered is persisted by the core; the send tool's result records it.

6. Minimal bot skeleton (Telegram.Bot)

var services = new ServiceCollection();
services.AddLogging(b => b.AddConsole());
services.AddAgenticCore(o =>
{
    o.DefaultModel = "claude-code/sonnet";
    o.AddExtension<ClaudeCodeProviderExtension>();
    o.AddExtension<AnthropicProviderExtension>();
});
var provider = services.BuildServiceProvider();
var sessions = new ChatSessions(provider.GetRequiredService<IAgentSessionFactory>(), "/srv/agent-workspaces");
var bot = new TelegramBotClient(Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN")!);
var uis = new ConcurrentDictionary<long, TelegramUi>();

bot.OnMessage += async (message, _) =>
{
    if (message.Text is null && message.Photo is null) return;
    var chatId = message.Chat.Id;
    var ui = uis.GetOrAdd(chatId, id => new TelegramUi(bot, id));
    if (message.ReplyToMessage is not null && ui.TryResolveInput(message.Text ?? "")) return;

    var session = await sessions.GetAsync(chatId, ui);
    AttachRenderer(session, chatId);            // idempotent: subscribe once per session

    switch (message.Text)
    {
        case "/stop": await session.AbortAsync(); return;
        case "/new": await session.NewSessionAsync(); await bot.SendMessage(chatId, "New session."); return;
        case "/status": await bot.SendMessage(chatId, Describe(session.State)); return;
        case var t when t?.StartsWith("/model ") == true:
            var ok = await session.SetModelAsync(t[7..].Trim());
            await bot.SendMessage(chatId, ok ? $"Model: {session.Model!.Key}" : "Unknown or unavailable model.");
            return;
    }

    var images = await DownloadPhotosAsync(bot, message);
    var behavior = session.IsStreaming ? StreamingBehavior.Steer : (StreamingBehavior?)null;
    _ = Task.Run(async () =>
    {
        try { await session.PromptAsync(message.Text ?? message.Caption ?? "", images, behavior); }
        catch (Exception ex) { await bot.SendMessage(chatId, $"Error: {ex.Message}"); }
    });
};

bot.OnUpdate += async (update, _) =>
{
    if (update.CallbackQuery is { Data: var data } q && data?.StartsWith("ui:") == true)
    {
        var parts = data.Split(':');
        if (uis.TryGetValue(q.Message!.Chat.Id, out var ui)) ui.TryResolve(parts[1], parts[2]);
        await bot.AnswerCallbackQuery(q.Id);
    }
};

AttachRenderer is the event loop from section 3. Keep a HashSet<string> of session ids you have subscribed to; NewSessionAsync/SwitchSessionAsync keep the same AgentSession object, so one subscription per AgentSession is enough.

7. Operational notes

  • Working directories: the agent's tools (bash, write, ...) run with the bot process's permissions in the session's Cwd. Run the bot in a container or with a dedicated user and one workspace per chat.
  • Authorisation: check message.From.Id against an allow-list before creating a session.
  • Long runs: Telegram delivers updates while your handler is running only if you do not block in it.
  • Restart: sessions resume from JSONL; extension state rebuilds on SessionStartEvent.
  • Multiple bots or workers: sessions are independent; the store must be safe for concurrent writers to different sessions (the file store is).
  • Claude Code provider: the claude CLI must be installed and logged in on the bot host; see claude-code-provider.md.
  • Headless test double: Volcanic.AgenticCore.Testing.FauxProvider lets you develop the bot without any model access (o.AddExtension(faux.Extension), faux.Enqueue(FauxTurn.Say("..."))).