Files
Playa/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

279 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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("..."))`).