Custom Chat recipe

Source: 08_CustomChat/ · a drop-in replacement for the workspace's default chat experience.

How it looks

A custom header with a topic selector, example chips, a transcript, and per-message thumbs-up/down commands. The recipe routes messages to a workspace endpoint; the runnable Tesserae preview below echoes a canned reply so you can try it here.

What it teaches

  • Calling a custom backend endpoint for PostMessage.
  • Rendering a custom header with a topic selector beside the assistant-template dropdown.
  • Showing custom empty-state examples that pre-fill the input box on click.
  • Adding per-message commands (thumbs up / thumbs down) for assistant replies.

Code shape

var endpoints = new CustomChatView
{
    Title       = "Recipe Chat",
    PostMessage = PostMessageAsync   // ← the only required override for a basic custom chat
};

_chatView = ChatView(endpoints, state)
              .WithCustomHeader(BuildHeader)
              .WithCustomExamples(BuildExamples)
              .WithMessageCommands(BuildMessageCommands);

Other customization points on CustomChatView:

Property Use when
NewChat You want to create the chat row yourself (e.g. attach pre-set context).
ReplaceMessage You support message editing and need to re-trigger your own pipeline.
ListTools The chat should show a curated set of tools instead of every one in the workspace.
ListChats You're filtering the chat list (per-user, per-project, archived view).

Settings on the chat view itself

Separately from CustomChatView, the view takes a ChatViewConfiguration that decides how the surface behaves for everyone using it — a property of the surface, not a switch the reader has to find:

Setting Effect
MaxMessageCharacters Caps how long a single message may be. The composer stops taking text at the limit and shows a "used / max" counter next to the send button once more than half the budget is spent; below that the counter is collapsed, so an ordinary message never carries a number. Zero (the default) means no limit and no counter.
Skills The skills every turn on this surface carries. Leave it null and the view says nothing about skills, so the run falls back to the ones configured on the chat's assistant template. An empty array is a choice, not silence — it carries no skills at all.
GroupedToolsThreshold How long a run of consecutive tool calls has to be before the transcript collapses it into one "Used N tools" summary. The default of 2 groups every run of more than one call; raise it to leave short runs as individual calls, and set it past any run a turn can produce (int.MaxValue) to never group at all. A lone call is never a group, so anything below 2 behaves as 2.
GroupedToolsDisplay How that summary presents itself: a pill opening a modal (the default), a pill expanding in place, or already expanded in place. Only affects the persisted transcript — a streaming reply always shows its calls inline and expanded.
DisableMessageQueueing By default, sending while the assistant is still replying holds the message as the chat's next turn rather than interrupting the reply. Set this to make the composer's trigger stop the reply instead.
ParseAs Which entity-linking parse the composer applies.

How the backend call works

private async Task<UID128> PostMessageAsync(CustomChatView.PostMessageRequest request)
{
    // In a real chat you'd call:
    //     return await Mosaik.API.Endpoints.CallAsync<UID128>(
    //         "recipes/chat/post-message",
    //         new RecipeChatRequest { Message = request.Message, Topic = _topic.Value });

    var reply = await RecipeEndpoints.PostChatMessageAsync(new RecipeChatRequest { ... });

    Toast().Information(reply.Reply);
    return UID128.Empty;   // "I handled it myself"
}

RecipeEndpoints.PostChatMessageAsync lives in src/API/Endpoints.cs and currently returns a hard-coded reply. Once you have a real workspace endpoint, uncomment the Mosaik.API.Endpoints.CallAsync<T> line there and delete the canned fallback.

See also

© 2026 Curiosity. All rights reserved.