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.
279 lines
15 KiB
Markdown
279 lines
15 KiB
Markdown
# 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<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.
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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`.
|
||
|
||
```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<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)
|
||
|
||
```csharp
|
||
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](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("..."))`).
|