# 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 `AgentSessionEvent`s 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](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: ```csharp public sealed class ChatSessions(IAgentSessionFactory factory, string workspaceRoot) : IAsyncDisposable { private readonly ConcurrentDictionary>> _sessions = new(); public Task GetAsync(long chatId, IUserInterface ui) => _sessions.GetOrAdd(chatId, id => new Lazy>(() => CreateAsync(id, ui))).Value; private async Task 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 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-/`). 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 ` | `session.SetThinkingLevelAsync(level)` (`session.AvailableThinkingLevels`) | | `/new` | `session.NewSessionAsync()` | | `/sessions`, `/resume ` | `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. ```csharp var channel = Channel.CreateUnbounded(); 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 (``, ``, `
`), 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:

```csharp
public sealed class TelegramUi(ITelegramBotClient bot, long chatId) : IUserInterface
{
    private readonly ConcurrentDictionary> _pending = new();

    public async Task SelectAsync(string title, IReadOnlyList options, UiRequestOptions? o, CancellationToken ct)
    {
        var id = Guid.NewGuid().ToString("N")[..8];
        var tcs = new TaskCompletionSource(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 ConfirmAsync(string title, string message, UiRequestOptions? o, CancellationToken ct) =>
        await SelectAsync($"{title}\n{message}", ["Yes", "No"], o, ct) == "Yes";

    public async Task 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(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::".
    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`.

```csharp
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 SendTextAsync(string markdown, CancellationToken cancellationToken);
    Task 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)

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

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` 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](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("..."))`).