telegram-bot.md: what you can send, files from the agent, speech settings, security notes. ui-integration.md: document/voice input rows and a new section 5 on IChatChannel. architecture, extensions and CLAUDE.md list IChatChannel. Drop the interim paragraph from the message-context spec; mark the attachments spec implemented with resolved questions.
15 KiB
Building a UI on the core (Telegram guide)
The core has no user interface. A UI is a process that
- creates one
AgentSessionper conversation, - turns incoming user actions into calls on the session (
PromptAsync,AbortAsync,SetModelAsync, ...), - subscribes to
AgentSessionEvents and renders them, - implements
IUserInterfaceso extensions can ask the user questions, - optionally implements
IChatChannelso 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).sendchecks 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 reminders.SendFileAsync: the core has already confined the path to the workspace (symlinks included), checked that the file exists and applied the limits.Autolets you choose, and you may fall back (Telegram resends a rejected photo as a document); report what you did inSentAs. Throw onlyChatDeliveryException, with a message the model or the user can read, orOperationCanceledException.- 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
sendtool'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'sCwd. Run the bot in a container or with a dedicated user and one workspace per chat. - Authorisation: check
message.From.Idagainst 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
claudeCLI must be installed and logged in on the bot host; see claude-code-provider.md. - Headless test double:
Volcanic.AgenticCore.Testing.FauxProviderlets you develop the bot without any model access (o.AddExtension(faux.Extension),faux.Enqueue(FauxTurn.Say("..."))).