12 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.
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 |
/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. 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.
6. 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("..."))).