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.
DisableNextStepSuggestions Turns off the follow-up pills described in What a reply can render as. By default a trailing next-steps fence in an assistant message is stripped from the rendered Markdown and shown as clickable pills on the last message; set this and the fence is still stripped, but nothing is rendered from it.
WelcomeTitle / WelcomePrompts The heading and the grid of starter prompts on an empty chat. A prompt is a plain string; clicking one sends it.
DisableWelcomePrompts Drops that grid without replacing it — for a surface whose empty state asks the reader something of its own through CustomHeader, where a row of generic "try this" prompts only competes with the question. Setting CustomExamples replaces the grid outright and does not need this.
ReadOnly Renders the transcript without a composer. Anything that continues the conversation is gone with it, the follow-up pills included.

What a reply can render as

A reply is Markdown, and the chat view renders it as Markdown. Four shapes are more than that, and every one of them is something the assistant writes — a fenced block, or a tool call — not a feature the view switches on. They work on any surface built on ChatView, a custom chat included.

The assistant writes The chat view renders Built-in skill
A fence tagged markdown, more than four line breaks long A Canvas — a button above a folded preview of the source, opening a side pane with a Preview / Markdown dropdown and a Download as Word button. The reader can edit the source in the pane and watch it re-render. chat-render-document
A fence tagged slides A deck in place of the source — carousel, dots, an N / M counter — plus a Slides button that opens it full size, where Download as PowerPoint sits. chat-render-slides
A trailing fence tagged next-steps, one suggestion per line Up to four clickable pills on the last message of a live chat, each sending its line verbatim as the user's next message. chat-render-next-steps
A call to ask-user-question / ask-user-multiple-choice One answerable card above the composer, gathering every pending question of the turn. Answering resumes the run. chat-render-questionnaire

Two more code-block behaviours sit beside these: a fence tagged html gets a fully sandboxed live preview next to its source (unless the workspace enables paranoid message rendering), and a fence in any other language over four line breaks gets a button that opens it in a code editor.

The four skills in the right-hand column are what teach an assistant to produce these. Add the skill to the assistant template rather than restating its rules in a system prompt — the skill is versioned with the product, a pasted copy is not.

Turning a reply into a downloadable file

The Canvas and the deck are the two shapes that become a file, and both go through the same call: POST /api/chatai/render-markdown, which runs the Markdown through the same renderer workspace code reaches as scope.ChatAI.RenderMarkdown(...).

Fence OutputType File
markdown FilesType.Documents .docx
slides FilesType.Slides .pptx

Four things are worth knowing before you build on it:

  • The file is named after the document's own title — the first # heading of the Markdown, for a deck the first slide's title. A reply with no heading falls back to Export.docx / Export.pptx. The title is text the assistant wrote, so it is stripped of characters no file name may carry and capped at 80 characters.
  • Nothing is stored. The rendered file is streamed straight back to the browser.
  • The Markdown comes from the canvas, not from the transcript, because the reader may have edited it in the pane before pressing Download. The chat UID travels with the request and is checked against the caller's own chats, so the endpoint cannot be used as a general-purpose converter.
  • Only Documents and Slides render. Any other FilesType is a 400, not a 500 out of the converter.

A deck splits on a line of three or more dashes (---), not on # headings. Nothing else splits it, so a --- used as a horizontal rule inside a slide silently starts a new one.

Suggested follow-up questions

The pills are the one shape that writes back into the composer, so they are worth spelling out. The assistant ends its reply with:

Uptime held at 99.94% for the quarter, against a 99.9% target. Both incidents
were configuration, not capacity.

```next-steps
Show me the two incidents in detail
Which services came closest to breaching the target?
Draft the summary for the quarterly review
```

The fence never renders as text — not even half-written, since the streaming path strips an unterminated fence too — and each line becomes a button under the last assistant message. Clicking one sends that line as the next user message.

  • Only the last such block in a message is read, and text after it survives into the reply, so nothing belongs after it.
  • One suggestion per line. A leading -, * or is tolerated and stripped.
  • At most four lines are shown. A line shorter than three characters or longer than 199 is dropped. Plain text only — the line renders as a button label, so Markdown, code spans and links do not survive it.
  • Each line is sent verbatim, so write it in the user's voice and make it self-contained: "Show me the failed runs from last week", never "I could show you the failed runs" or "and the other ones?".
  • Never put anything the reader needs in the block. The pills only appear on the last message of a live chat, they are gone from a read-only transcript, and a view can switch them off with DisableNextStepSuggestions.

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.